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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7238c6526..7dbf16f71 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" @@ -129,6 +131,10 @@ jobs: - 'scripts/cargo-target-gc.sh' - 'scripts/tests/cargo-cache-build-tests.sh' - 'scripts/tests/cargo-target-gc-tests.sh' + - 'scripts/sign-macos-artifacts.sh' + - 'scripts/macos-signing-keychain.c' + - 'scripts/build-mac-installer.sh' + - 'scripts/tests/macos-signing-secret-transport-tests.sh' - 'packaging/systemd/user/hypercolor-cargo-target-gc.*' - 'justfile' - '.github/actions/rust-build-cache/**' @@ -235,6 +241,181 @@ 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" + + - name: Verify macOS signing secret transport + run: ./scripts/tests/macos-signing-secret-transport-tests.sh + + - name: Install NASM + if: matrix.expected-arch == 'x86_64' + run: brew install nasm + + - 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: 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 + cargo check --workspace --locked + + - name: Clippy macOS interop + run: >- + ./scripts/cargo-cache-build.sh + cargo clippy --locked -p hypercolor-macos-gpu-interop --features screen-capture + --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: 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 + cargo nextest run --locked -p hypercolor-macos-gpu-interop + --features screen-capture + + - name: Run macOS capture fixtures + run: | + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -p hypercolor-macos-capture --features capture-fixtures + ./scripts/cargo-cache-build.sh \ + cargo nextest run --locked \ + -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 + cargo nextest run --locked + -p hypercolor-daemon --no-default-features --features wgpu + -E 'test(/api::system::tests::(input_source_status|macos_)/)' + + - 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" \ + "${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: name: Generated Effects @@ -1015,23 +1196,18 @@ jobs: /tmp/hypercolor-e2e-* # ── Docs (Zola → GitHub Pages) ───────────────────────────────── - docs: - name: Docs + docs-build: + name: Docs Build needs: changes if: >- - github.ref == 'refs/heads/main' && - ( + (github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true') || + (github.ref == 'refs/heads/main' && ( (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) - ) + )) runs-on: ubuntu-latest permissions: contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v6 @@ -1046,10 +1222,34 @@ jobs: run: zola build - name: Upload Pages artifact + if: >- + github.ref == 'refs/heads/main' && + ( + (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) + ) uses: actions/upload-pages-artifact@v5 with: path: docs/public + docs-deploy: + name: Docs Deploy + needs: [changes, docs-build] + if: >- + github.ref == 'refs/heads/main' && + ( + (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) + ) + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: - name: Deploy to GitHub Pages id: deploy uses: actions/deploy-pages@v5 @@ -1114,7 +1314,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, python, python-generated] strategy: fail-fast: false matrix: @@ -1129,27 +1329,21 @@ 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 + bundles: app + artifact-kind: unsigned-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 + target/aarch64-apple-darwin/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 + bundles: app + artifact-kind: unsigned-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 + target/x86_64-apple-darwin/release/bundle/macos/*.app runs-on: ${{ matrix.os }} env: # Absolute on purpose. Cargo resolves a relative CARGO_TARGET_DIR @@ -1161,6 +1355,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 }} @@ -1293,12 +1498,7 @@ jobs: 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: "-" + RUST_TARGET: ${{ matrix.rust-target }} run: | $configArgs = @() if (Test-Path "tauri.bundle.conf.json") { @@ -1308,45 +1508,37 @@ 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" + # Public CI proves packaging without receiving release credentials. + $buildArgs += "--no-sign" + if ($env:RUNNER_OS -eq "macOS") { + $buildArgs += @("--target", $env:RUST_TARGET) } cargo tauri build @buildArgs @configArgs - - name: Normalize macOS DMG artifact name + - name: Verify unsigned macOS app deployment target 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 - } + profile_dir="target/${{ matrix.rust-target }}/release" + app="${profile_dir}/bundle/macos/Hypercolor.app" + ./scripts/verify-macos-deployment-target.sh "${app}" - - name: Upload native app bundle + - name: Upload native release bundle + if: runner.os == 'Windows' uses: actions/upload-artifact@v7 with: name: hypercolor-app-${{ steps.version.outputs.version }}-${{ matrix.target }}-${{ matrix.artifact-kind }} path: ${{ matrix.artifact-path }} if-no-files-found: error + - name: Upload unsigned macOS packaging fixture + if: runner.os == 'macOS' + uses: actions/upload-artifact@v7 + with: + name: oss-ci-${{ steps.version.outputs.version }}-${{ matrix.target }}-${{ matrix.artifact-kind }} + path: ${{ matrix.artifact-path }} + if-no-files-found: error + retention-days: 7 + # ── Build Release Tarballs ──────────────────────────────────── build-release-smoke: name: Release Tarball Smoke (linux-amd64) @@ -1400,8 +1592,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 @@ -1460,7 +1650,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, python, python-generated] strategy: fail-fast: false matrix: @@ -1471,9 +1661,6 @@ jobs: - target: linux-arm64 os: ubuntu-24.04-arm rust-target: aarch64-unknown-linux-gnu - - target: macos-arm64 - os: macos-latest - rust-target: aarch64-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 @@ -1556,7 +1743,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 @@ -1584,8 +1772,6 @@ 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" # Kept for the failure modes that leave the runner alive. Memory @@ -1614,7 +1800,8 @@ 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 \ @@ -1663,7 +1850,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 @@ -1687,7 +1874,7 @@ 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 '*.deb' -o -name '*-setup.exe' \) | sort) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found" >&2 exit 1 @@ -1789,102 +1976,6 @@ jobs: with: packages-dir: dist/ - # ── Update Homebrew Tap ──────────────────────────────────────── - update-homebrew: - if: >- - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && - startsWith(github.ref, 'refs/tags/') && - !contains(github.ref_name, '-') - needs: create-release - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - - name: Determine version - id: version - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - - name: Download release artifacts and compute checksums - id: checksums - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - run: | - for platform in linux-amd64 linux-arm64 macos-arm64; do - tarball="hypercolor-${VERSION}-${platform}.tar.gz" - url="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${tarball}" - echo "Downloading ${tarball}..." - curl -fSL "${url}" -o "${tarball}" - sha=$(sha256sum "${tarball}" | cut -d' ' -f1) - echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" - echo " ${platform}: ${sha}" - done - - for arch in arm64 x86_64; do - dmg="Hypercolor-${VERSION}-${arch}.dmg" - url="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${dmg}" - echo "Downloading ${dmg}..." - curl -fSL "${url}" -o "${dmg}" - sha=$(sha256sum "${dmg}" | cut -d' ' -f1) - case "${arch}" in - arm64) echo "sha256_macos_app_arm64=${sha}" >> "$GITHUB_OUTPUT" ;; - x86_64) echo "sha256_macos_app_x86_64=${sha}" >> "$GITHUB_OUTPUT" ;; - esac - echo " macos-app-${arch}: ${sha}" - done - - - name: Generate Homebrew files - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - sed \ - -e "s/VERSION_PLACEHOLDER/${VERSION}/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" \ - packaging/homebrew/hypercolor.rb > hypercolor.rb - sed \ - -e "s/VERSION_PLACEHOLDER/${VERSION}/g" \ - -e "s/SHA256_MACOS_APP_ARM64/${{ steps.checksums.outputs.sha256_macos_app_arm64 }}/g" \ - -e "s/SHA256_MACOS_APP_X86_64/${{ steps.checksums.outputs.sha256_macos_app_x86_64 }}/g" \ - packaging/homebrew/hypercolor-app.rb > hypercolor-app.rb - echo "Generated formula:" - cat hypercolor.rb - echo "Generated cask:" - cat hypercolor-app.rb - - - name: Upload Homebrew files - uses: actions/upload-artifact@v7 - with: - name: homebrew-hypercolor-${{ steps.version.outputs.version }} - path: | - hypercolor.rb - hypercolor-app.rb - if-no-files-found: error - - - name: Push to homebrew-tap - env: - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - set -euo pipefail - if [ -z "${HOMEBREW_TAP_TOKEN}" ]; then - echo "HOMEBREW_TAP_TOKEN is not configured; artifact only." - exit 0 - fi - - git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/hyperb1iss/homebrew-tap.git" tap - mkdir -p tap/Formula tap/Casks - cp hypercolor.rb tap/Formula/hypercolor.rb - cp hypercolor-app.rb tap/Casks/hypercolor-app.rb - cd tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/hypercolor.rb Casks/hypercolor-app.rb - git commit -m "hypercolor: update to ${{ steps.version.outputs.version }}" - git push - # ── Update AUR Package ──────────────────────────────────────── update-aur: if: >- diff --git a/AGENTS.md b/AGENTS.md index 4437106c6..35d55cbb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,10 @@ 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-macos-owner/ # Shared durable macOS daemon ownership and handover coordination 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 +94,10 @@ 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 + MO[hypercolor-macos-owner] --> D[hypercolor-daemon] & CLI[hypercolor-cli] & APP[hypercolor-app] WGI[hypercolor-windows-gpu-interop] --> CORE WPI[hypercolor-windows-pawnio] --> CORE WC[hypercolor-windows-capture] --> CORE & WGI @@ -246,7 +252,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/CONTRIBUTING.md b/CONTRIBUTING.md index f5e0fccf6..969b0532b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,10 @@ just verify # fmt + lint + test: run this after every change - Linux, Windows, and macOS are all supported; Linux additionally integrates udev rules and systemd user services +Platform-specific setup (system libraries, udev rules, the macOS dev +signing certificate, Windows hardware support) lives in +[docs/development/DEV_SETUP.md](docs/development/DEV_SETUP.md). + ## What to Work On **Effects** are the easiest way to contribute. The SDK makes it straightforward to create something diff --git a/Cargo.lock b/Cargo.lock index 8fa85bf5d..c13d769d1 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]] @@ -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" @@ -2771,7 +2757,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2803,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]] @@ -3209,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]] @@ -4034,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]] @@ -4942,12 +4928,15 @@ dependencies = [ "dirs 6.0.0", "futures-util", "hypercolor-core", + "hypercolor-macos-owner", "hypercolor-types", "image", + "objc2 0.6.4", "open", "reqwest 0.12.28", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -4973,8 +4962,10 @@ dependencies = [ "clap", "clap_complete", "dirs 6.0.0", + "futures-util", "hypercolor-core", "hypercolor-daemon", + "hypercolor-macos-owner", "hypercolor-tui", "opaline", "open", @@ -4984,6 +4975,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tokio-tungstenite 0.26.2", "toml 0.8.2", "tracing-subscriber", "unicode-width", @@ -5001,7 +4993,6 @@ dependencies = [ "chrono", "cpal", "criterion", - "device_query", "dirs 6.0.0", "dpi", "evdev", @@ -5016,7 +5007,9 @@ dependencies = [ "hypercolor-driver-api", "hypercolor-hal", "hypercolor-linux-gpu-interop", + "hypercolor-macos-capture", "hypercolor-macos-gpu-interop", + "hypercolor-macos-input", "hypercolor-platform-fs", "hypercolor-types", "hypercolor-windows-capture", @@ -5074,8 +5067,10 @@ dependencies = [ "axum", "base64 0.22.1", "clap", + "core-foundation 0.10.1", "cpal", "criterion", + "dispatch2", "fast_image_resize", "gif", "http 1.4.0", @@ -5084,6 +5079,10 @@ dependencies = [ "hypercolor-driver-api", "hypercolor-driver-builtin", "hypercolor-leptos-ext", + "hypercolor-macos-capture", + "hypercolor-macos-gpu-interop", + "hypercolor-macos-input", + "hypercolor-macos-owner", "hypercolor-network", "hypercolor-platform-fs", "hypercolor-types", @@ -5093,17 +5092,24 @@ dependencies = [ "if-addrs", "image", "mdns-sd", + "notify", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", "owo-colors", "pollster", "reqwest 0.12.28", "rmcp", "sd-notify", + "security-framework", "serde", "serde_json", + "sha2 0.10.9", "single-instance", "socket2 0.6.3", "spin_sleep", "stats_alloc", + "subtle", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -5330,6 +5336,25 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "hypercolor-macos-capture" +version = "0.3.2" +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", + "tracing", + "uuid", +] + [[package]] name = "hypercolor-macos-gpu-interop" version = "0.3.2" @@ -5339,10 +5364,13 @@ dependencies = [ "euclid", "gleam", "glow", + "hypercolor-macos-capture", "image", "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-video", + "objc2-foundation 0.3.2", "objc2-io-surface", "objc2-metal 0.3.2", "pollster", @@ -5355,6 +5383,33 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "hypercolor-macos-input" +version = "0.3.2" +dependencies = [ + "crossbeam-queue", + "mach2 0.5.0", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "thiserror 2.0.18", +] + +[[package]] +name = "hypercolor-macos-owner" +version = "0.3.2" +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" @@ -7215,16 +7270,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" @@ -7891,7 +7936,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]] @@ -8017,7 +8062,7 @@ dependencies = [ "rustix 1.1.4", "slab", "tokio", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8248,6 +8293,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" @@ -8273,6 +8333,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -8392,6 +8453,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" @@ -10236,18 +10313,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" @@ -10727,7 +10792,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10785,7 +10850,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13115,7 +13180,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]] @@ -14129,10 +14194,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]] @@ -15044,7 +15109,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -16321,7 +16386,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]] @@ -16345,15 +16410,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 ce187adbb..4a1f489fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,9 +96,11 @@ 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" +subtle = "2.6" arc-swap = "1.7" rayon = "1.12" strum = { version = "0.28", features = ["derive"] } @@ -142,6 +144,9 @@ 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-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" } @@ -160,7 +165,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 @@ -172,6 +176,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"] } @@ -220,8 +225,14 @@ 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-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 } 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/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/assets/brand/build.py b/assets/brand/build.py index a78b0ade9..26258fe88 100644 --- a/assets/brand/build.py +++ b/assets/brand/build.py @@ -28,6 +28,14 @@ DERIVED = BRAND / "derived" APP_ICON_DIR = REPO_ROOT / "crates" / "hypercolor-app" / "icons" +APP_ICON_ASSETS = ( + "32x32.png", + "128x128.png", + "128x128@2x.png", + "icon.png", + "icon.icns", + "icon.ico", +) INSTALLER_APP_ASSETS = ("installer.ico", "nsis-header.bmp", "nsis-sidebar.bmp") AI_PETAL_SOURCES = { @@ -637,8 +645,14 @@ def build_app_icons() -> None: format="ICO", sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], ) + master_icon.save(out / "icon.icns", format="ICNS") + + APP_ICON_DIR.mkdir(parents=True, exist_ok=True) + for asset in APP_ICON_ASSETS: + shutil.copy2(out / asset, APP_ICON_DIR / asset) print(f" → app-icon: {len(list(out.glob('*')))} files") + print(f" → Tauri app icons: {len(APP_ICON_ASSETS)} files") def build_tray() -> None: @@ -887,6 +901,14 @@ def build_derived() -> None: def main() -> None: stage = sys.argv[1] if len(sys.argv) > 1 else "all" + if stage == "app-icon": + if not MASTER.exists(): + raise SystemExit(f"missing master/ — run a full build first ({MASTER})") + print("rebuilding app icons from master/") + build_app_icons() + print("\n✦ done.") + return + # `installer` rebuilds only the Windows installer art from the checked-in # masters — no source/ needed, and it won't churn unrelated derived assets. if stage == "installer": diff --git a/crates/hypercolor-app/.gitignore b/crates/hypercolor-app/.gitignore index 2c41ac3ea..82a1a965d 100644 --- a/crates/hypercolor-app/.gitignore +++ b/crates/hypercolor-app/.gitignore @@ -1 +1,2 @@ /gen/ +/permissions/autogenerated/ diff --git a/crates/hypercolor-app/Cargo.toml b/crates/hypercolor-app/Cargo.toml index e628b2c43..2f4b8e761 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" @@ -58,5 +59,9 @@ windows-sys = { version = "0.61.2", features = [ "Win32_UI_WindowsAndMessaging", ] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { workspace = true, features = ["std"] } +sysinfo = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } 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/build.rs b/crates/hypercolor-app/build.rs index 261851f6b..bdb5fb499 100644 --- a/crates/hypercolor-app/build.rs +++ b/crates/hypercolor-app/build.rs @@ -1,3 +1,20 @@ fn main() { - tauri_build::build(); + let manifest = tauri_build::AppManifest::new().commands(&[ + "is_first_run_pending", + "mark_first_run_complete", + "reset_first_run", + "choose_daemon_owner", + "execute_macos_daemon_owner_offline_remedy", + "macos_daemon_owner_offline_status", + "restart_macos_capture_owner", + "detect_pawnio_support", + "detect_windows_daemon_service", + "launch_pawnio_helper", + "repair_smbus_service", + "open_external_url", + "open_macos_system_settings", + "get_verified_daemon_connection", + ]); + tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) + .expect("failed to build Tauri application manifest"); } diff --git a/crates/hypercolor-app/capabilities/default.json b/crates/hypercolor-app/capabilities/default.json index 162a315cc..ad5332335 100644 --- a/crates/hypercolor-app/capabilities/default.json +++ b/crates/hypercolor-app/capabilities/default.json @@ -3,12 +3,6 @@ "identifier": "default", "description": "Default capability for the main window", "windows": ["main"], - "remote": { - "urls": [ - "http://127.0.0.1:9420/*", - "http://localhost:9420/*" - ] - }, "permissions": [ "core:default", "autostart:allow-enable", @@ -18,6 +12,20 @@ "core:window:allow-show", "core:window:allow-hide", "core:window:allow-set-focus", - "core:window:allow-unminimize" + "core:window:allow-unminimize", + "allow-is-first-run-pending", + "allow-mark-first-run-complete", + "allow-reset-first-run", + "allow-choose-daemon-owner", + "allow-execute-macos-daemon-owner-offline-remedy", + "allow-macos-daemon-owner-offline-status", + "allow-restart-macos-capture-owner", + "allow-detect-pawnio-support", + "allow-detect-windows-daemon-service", + "allow-launch-pawnio-helper", + "allow-repair-smbus-service", + "allow-open-external-url", + "allow-open-macos-system-settings", + "allow-get-verified-daemon-connection" ] } diff --git a/crates/hypercolor-app/entitlements.plist b/crates/hypercolor-app/entitlements.plist index 4bf59647c..820bab5b8 100644 --- a/crates/hypercolor-app/entitlements.plist +++ b/crates/hypercolor-app/entitlements.plist @@ -6,6 +6,11 @@ com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + com.apple.security.network.client com.apple.security.network.server diff --git a/crates/hypercolor-app/icons/icon.icns b/crates/hypercolor-app/icons/icon.icns index 968c93676..ea1c4ab0e 100644 Binary files a/crates/hypercolor-app/icons/icon.icns and b/crates/hypercolor-app/icons/icon.icns differ diff --git a/crates/hypercolor-app/icons/icon.png b/crates/hypercolor-app/icons/icon.png index 3bcbb5ab1..9961d92a9 100644 Binary files a/crates/hypercolor-app/icons/icon.png and b/crates/hypercolor-app/icons/icon.png differ 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..399e2732a 100644 --- a/crates/hypercolor-app/src/main.rs +++ b/crates/hypercolor-app/src/main.rs @@ -5,7 +5,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use tauri::{WebviewUrl, webview::WebviewWindowBuilder}; +use tauri::{Manager, WebviewUrl, webview::WebviewWindowBuilder}; fn maybe_open_devtools(window: &tauri::WebviewWindow) { #[cfg(debug_assertions)] @@ -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,11 +48,17 @@ 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, 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, + hypercolor_app::supervisor::get_verified_daemon_connection ]) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { let forwarded = hypercolor_app::cli::AppArgs::parse(args); @@ -54,27 +69,32 @@ 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() .expect("HYPERCOLOR_URL must be a valid URL"); - tracing::info!(scheme = %url.scheme(), host = ?url.host_str(), port = ?url.port_or_known_default(), "creating webview window"); - - let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url.clone())) - .title("Hypercolor") - .inner_size(1200.0, 800.0) - .min_inner_size(800.0, 500.0) - .initialization_script(hypercolor_app::window::visibility_state_script( - !cli.start_minimized, - )) - .on_new_window(hypercolor_app::window::open_new_window_in_system_browser) - .visible(!cli.start_minimized) - .build()?; + tracing::info!("creating bundled webview window"); + + let window = + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("Hypercolor") + .inner_size(1200.0, 800.0) + .min_inner_size(800.0, 500.0) + .initialization_script(hypercolor_app::window::visibility_state_script( + !cli.start_minimized, + )) + .on_new_window(hypercolor_app::window::open_new_window_in_system_browser) + .on_navigation(|url| { + let trusted = hypercolor_app::window::navigation_is_trusted(url); + if !trusted { + tracing::warn!(%url, "blocked untrusted navigation in the app webview"); + } + trusted + }) + .visible(!cli.start_minimized) + .build()?; maybe_open_devtools(&window); @@ -93,6 +113,17 @@ fn main() -> anyhow::Result<()> { Ok(()) }) .on_window_event(|window, event| { + if let tauri::WindowEvent::Focused(false) = event + && let Some(webview_window) = window.app_handle().get_webview_window(window.label()) + && let Err(error) = + hypercolor_app::window::release_webview_secure_input(&webview_window) + { + tracing::warn!( + %error, + label = %window.label(), + "failed to release webview secure input" + ); + } if let tauri::WindowEvent::CloseRequested { api, .. } = event { tracing::info!(label = %window.label(), "hiding window instead of closing"); if let Err(error) = hypercolor_app::window::hide(window) { @@ -101,8 +132,17 @@ fn main() -> anyhow::Result<()> { api.prevent_close(); } }) - .run(tauri::generate_context!()) - .map_err(|e| anyhow::anyhow!("tauri runtime error: {e}"))?; + .build(tauri::generate_context!()) + .map_err(|e| anyhow::anyhow!("tauri build error: {e}"))? + .run(|app_handle, event| { + // app.exit() terminates without unwinding, so the managed + // daemon must be reaped here, while the process still lives. + if matches!(event, tauri::RunEvent::Exit) { + app_handle + .state::() + .terminate_managed_daemon_for_exit(); + } + }); Ok(()) } diff --git a/crates/hypercolor-app/src/ownership.rs b/crates/hypercolor-app/src/ownership.rs new file mode 100644 index 000000000..dc4d63e68 --- /dev/null +++ b/crates/hypercolor-app/src/ownership.rs @@ -0,0 +1,1341 @@ +//! Local-only macOS daemon ownership coordination. + +use hypercolor_macos_owner::{ + MACOS_APP_PRODUCT_NAME, MacosDaemonOwner, MacosHandoverPhase, 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, + }, + }); + } + let incarnation = record.incarnation(); + store + .request_stop_if_current(&incarnation, || executor.flush_and_stop(&incarnation)) + .map_err(anyhow::Error::from)?; + let restart = (|| { + if !executor + .wait_for_guard_release(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)?; + apply_owner_choice_outcome(&state, &outcome); + Ok(outcome) +} + +#[cfg(target_os = "macos")] +fn apply_owner_choice_outcome(state: &SupervisorState, outcome: &MacosOwnerCoordinatorOutcome) { + match outcome { + MacosOwnerCoordinatorOutcome::Active { owner, .. } => { + 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); + } + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + .. + } => release_app_sidecar_supervisor(state), + MacosOwnerCoordinatorOutcome::PendingStandalone { .. } + | MacosOwnerCoordinatorOutcome::RolledBack { .. } + | MacosOwnerCoordinatorOutcome::RecoveryRequired { .. } => {} + } +} + +#[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())?; + if store.load_handover_journal()?.is_some_and(|journal| { + app_sidecar_recovery_needs_rearm(journal.requested_owner, journal.phase) + }) { + hypercolor_macos_owner::MacosOwnerExecutor::start( + &mut executor, + MacosDaemonOwner::AppSidecar, + )?; + } + 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")] +const fn app_sidecar_recovery_needs_rearm( + requested_owner: MacosDaemonOwner, + phase: MacosHandoverPhase, +) -> bool { + matches!( + (requested_owner, phase), + ( + MacosDaemonOwner::AppSidecar, + MacosHandoverPhase::RequestedOwnerStarted + ) + ) +} + +#[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")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum AppOwnerStopAuthority { + SupervisorChild, + LaunchctlService(String), + HomebrewService(&'static str), + UserDirected, +} + +#[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)?, + )) + } + + fn stop_authority( + &self, + owner: MacosDaemonOwner, + ) -> Result { + app_owner_stop_authority(owner, &self.uid) + } +} + +#[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 preflight_stop_authority( + &mut self, + incarnation: &hypercolor_macos_owner::MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + match self.stop_authority(incarnation.owner)? { + AppOwnerStopAuthority::SupervisorChild => { + self.state.preflight_app_sidecar_stop(incarnation) + } + AppOwnerStopAuthority::LaunchctlService(_) + | AppOwnerStopAuthority::HomebrewService(_) => Ok(()), + AppOwnerStopAuthority::UserDirected => Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )), + } + } + + fn flush_and_stop( + &mut self, + incarnation: &hypercolor_macos_owner::MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + if incarnation.owner == MacosDaemonOwner::AppSidecar { + hold_app_sidecar_supervisor(&self.state); + } + match self.stop_authority(incarnation.owner)? { + AppOwnerStopAuthority::SupervisorChild => self.state.stop_app_sidecar(incarnation), + AppOwnerStopAuthority::LaunchctlService(target) => { + let output = command_output("/bin/launchctl", &["print", &target])?; + if !output.status.success() { + return Ok(()); + } + run_command("/bin/launchctl", &["kill", "SIGTERM", &target]) + } + AppOwnerStopAuthority::HomebrewService(formula) => { + let brew = homebrew_binary()?; + run_command(&brew.to_string_lossy(), &["services", "stop", formula]) + } + AppOwnerStopAuthority::UserDirected => Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )), + } + } + + 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, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_macos_guard_release( + 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 app_owner_stop_authority( + owner: MacosDaemonOwner, + uid: &str, +) -> Result { + Ok(match owner { + MacosDaemonOwner::AppSidecar => AppOwnerStopAuthority::SupervisorChild, + MacosDaemonOwner::DirectLaunchd => { + AppOwnerStopAuthority::LaunchctlService(format!("gui/{uid}/{}", service_label(owner)?)) + } + MacosDaemonOwner::Homebrew => AppOwnerStopAuthority::HomebrewService("hypercolor"), + MacosDaemonOwner::Standalone => AppOwnerStopAuthority::UserDirected, + }) +} + +#[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::{ + AppOwnerStopAuthority, MacosCaptureOwner, MacosCaptureOwnerRestartOutcome, + MacosDaemonOwnerRemedyOutcome, MacosStartupRecoveryDisposition, app_owner_stop_authority, + app_sidecar_recovery_needs_rearm, apply_owner_choice_outcome, complete_offline_remedy_with, + execute_offline_remedy_with, 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, MacosHandoverPhase, MacosOwnerCoordinatorOutcome, + MacosOwnerExecutionError, MacosOwnerExecutor, MacosOwnerIdentity, MacosOwnerIncarnation, + MacosOwnerRemedy, MacosOwnerStore, + }; + + use crate::supervisor::{MacosDaemonOwnerOfflineStatus, SupervisorState}; + + struct RestartFixtureExecutor { + store: MacosOwnerStore, + operations: Vec, + stopped_incarnations: Vec, + next_pid: u32, + guard_released: bool, + } + + impl RestartFixtureExecutor { + fn new(store: MacosOwnerStore) -> Self { + Self { + store, + operations: Vec::new(), + stopped_incarnations: 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 preflight_stop_authority( + &mut self, + _incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + Ok(()) + } + + fn flush_and_stop( + &mut self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + self.stopped_incarnations.push(incarnation.clone()); + self.operations.push(match incarnation.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, + _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 requested_app_sidecar_recovery_rearms_the_supervisor() { + assert!(app_sidecar_recovery_needs_rearm( + MacosDaemonOwner::AppSidecar, + MacosHandoverPhase::RequestedOwnerStarted, + )); + assert!(!app_sidecar_recovery_needs_rearm( + MacosDaemonOwner::AppSidecar, + MacosHandoverPhase::StartRequested, + )); + assert!(!app_sidecar_recovery_needs_rearm( + MacosDaemonOwner::DirectLaunchd, + MacosHandoverPhase::RequestedOwnerStarted, + )); + } + + #[test] + fn newer_prior_app_sidecar_rollback_releases_only_proven_terminal_gate() { + let state = SupervisorState::default(); + hold_app_sidecar_supervisor(&state); + + apply_owner_choice_outcome( + &state, + &MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: hypercolor_macos_owner::MacosHandoverPhase::RollbackAutostartsRestored, + }, + ); + assert!(state.owner_handover_stop()); + + apply_owner_choice_outcome( + &state, + &MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::DirectLaunchd, + failure: "fixture rollback".to_owned(), + }, + ); + assert!(state.owner_handover_stop()); + + apply_owner_choice_outcome( + &state, + &MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + failure: "newer prior publication restored ownership".to_owned(), + }, + ); + assert!(!state.owner_handover_stop()); + } + + #[test] + fn managed_topologies_resolve_only_their_exact_stop_authority() { + assert_eq!( + app_owner_stop_authority(MacosDaemonOwner::AppSidecar, "501") + .expect("app sidecar authority should resolve"), + AppOwnerStopAuthority::SupervisorChild + ); + assert_eq!( + app_owner_stop_authority(MacosDaemonOwner::DirectLaunchd, "501") + .expect("launchd authority should resolve"), + AppOwnerStopAuthority::LaunchctlService( + "gui/501/tech.hyperbliss.hypercolor".to_owned() + ) + ); + assert_eq!( + app_owner_stop_authority(MacosDaemonOwner::Homebrew, "501") + .expect("Homebrew authority should resolve"), + AppOwnerStopAuthority::HomebrewService("hypercolor") + ); + } + + #[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 {}, + ] + ); + assert_eq!(executor.stopped_incarnations, [record.incarnation()]); + } + + #[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..09af7eb38 100644 --- a/crates/hypercolor-app/src/supervisor/mod.rs +++ b/crates/hypercolor-app/src/supervisor/mod.rs @@ -10,7 +10,17 @@ 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, + MacosProtectedControlCredential, MacosServerSessionId, +}; +#[cfg(target_os = "macos")] +use hypercolor_macos_owner::{ + MacosDaemonSessionAttestation, MacosOwnerExecutionError, MacosOwnerIncarnation, + try_acquire_macos_daemon_guard, +}; +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. @@ -18,6 +28,28 @@ pub const DEFAULT_DAEMON_BIND: &str = "127.0.0.1:9420"; const DAEMON_EXECUTABLE_STEM: &str = "hypercolor-daemon"; +pub const VERIFIED_DAEMON_CONNECTION_CHANGED_EVENT: &str = "verified-daemon-connection-changed"; + +/// Process-memory connection proof exposed only to the bundled app UI. +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedDaemonConnection { + pub base_url: String, + pub server_session_id: Option, + pub protected_control_credential: Option, +} + +/// Monotonic supervisor snapshot used to reject invoke/event reordering. +#[derive(Clone, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedDaemonConnectionSnapshot { + pub revision: u64, + pub connection: Option, +} + +type VerifiedConnectionEmitter = + Arc; + /// Linux systemd user service name for the daemon. pub const SYSTEMD_USER_SERVICE: &str = "hypercolor.service"; @@ -30,6 +62,9 @@ pub const DAEMON_STARTUP_TIMEOUT: Duration = Duration::from_secs(20); /// Delay between daemon startup health probes. pub const DAEMON_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(250); +#[cfg(target_os = "macos")] +const EXTERNAL_OWNER_VERIFY_INTERVAL: Duration = Duration::from_secs(1); + /// Watchdog circuit-breaker: max rapid daemon restarts within /// [`WATCHDOG_FAILURE_WINDOW`] before the supervisor gives up. pub const WATCHDOG_MAX_RAPID_RESTARTS: u32 = 5; @@ -49,6 +84,8 @@ pub struct DaemonCommand { pub program: PathBuf, /// Daemon command-line arguments. pub args: Vec, + /// Explicit launcher metadata inherited by the daemon process. + pub environment: Vec<(String, String)>, } /// Current state of the Linux systemd user service from the app supervisor's perspective. @@ -76,17 +113,31 @@ pub enum SystemdUserServicePlan { /// App-managed daemon supervisor state. /// /// Tracks the PID of whichever daemon child the watchdog is currently -/// supervising. The actual `Child` handle lives inside the watchdog task -/// so blocking waits can run on a dedicated thread without holding the -/// state mutex. +/// supervising. On macOS, the handover authority and watchdog share the +/// retained `Child` through `app_sidecar_child`. #[derive(Clone, Default)] pub struct SupervisorState { child_pid: Arc>>, + #[cfg(target_os = "macos")] + app_sidecar_child: Arc>>, /// Latched true when the watchdog circuit-breaker fires — /// `WATCHDOG_MAX_RAPID_RESTARTS` failures within `WATCHDOG_FAILURE_WINDOW`. /// 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>>, + verified_connection: Arc>, + verified_connection_emitter: 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,12 +155,292 @@ 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) + } + + /// Return the current exact daemon-session proof, if one remains valid. + #[must_use] + pub fn verified_daemon_connection(&self) -> VerifiedDaemonConnectionSnapshot { + self.verified_connection + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn install_verified_connection_emitter(&self, emitter: VerifiedConnectionEmitter) { + *self + .verified_connection_emitter + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(emitter); + } + + fn replace_verified_connection(&self, connection: Option) { + let snapshot = { + let mut current = self + .verified_connection + .lock() + .unwrap_or_else(PoisonError::into_inner); + if current.connection == connection { + return; + } + current.revision = current.revision.saturating_add(1); + current.connection = connection; + current.clone() + }; + if let Some(emitter) = self + .verified_connection_emitter + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + { + emitter(snapshot); + } + } + + fn clear_verified_connection(&self) { + self.replace_verified_connection(None); + } + + pub(crate) fn set_owner_handover_stop(&self, stopping: bool) { + if stopping { + self.clear_verified_connection(); + } + 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); } fn clear_child(&self) { + self.clear_verified_connection(); *self.child_guard() = None; + #[cfg(target_os = "macos")] + { + *self + .app_sidecar_child + .lock() + .unwrap_or_else(PoisonError::into_inner) = None; + } + } + + #[cfg(target_os = "macos")] + fn app_sidecar_is_live(&self, incarnation: &MacosOwnerIncarnation) -> bool { + self.preflight_app_sidecar_stop(incarnation).is_ok() + } + + #[cfg(target_os = "macos")] + fn register_app_sidecar_child( + &self, + incarnation: MacosOwnerIncarnation, + daemon: SharedManagedDaemon, + ) -> Result<(), MacosOwnerExecutionError> { + let child_pid = daemon.lock().unwrap_or_else(PoisonError::into_inner).id(); + if incarnation.owner != MacosDaemonOwner::AppSidecar + || incarnation.identity.pid != child_pid + { + return Err(MacosOwnerExecutionError::new( + "app-sidecar owner identity does not match the retained child", + )); + } + *self + .app_sidecar_child + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(AppSidecarChild { + incarnation, + daemon, + }); + Ok(()) + } + + #[cfg(target_os = "macos")] + pub(crate) fn preflight_app_sidecar_stop( + &self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + let authority = self + .app_sidecar_child + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(authority) = authority.as_ref() else { + return Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires a retained child handle", + )); + }; + if authority.incarnation != *incarnation { + return Err(MacosOwnerExecutionError::new( + "app-sidecar owner identity does not match the retained child", + )); + } + let mut daemon = authority + .daemon + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(child) = daemon.child.as_mut() else { + return Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires an unreaped child handle", + )); + }; + if child + .try_wait() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .is_some() + { + return Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires a live unreaped child handle", + )); + } + Ok(()) + } + + #[cfg(target_os = "macos")] + pub(crate) fn stop_app_sidecar( + &self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + let authority = self + .app_sidecar_child + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(authority) = authority.as_ref() else { + return Ok(()); + }; + if authority.incarnation != *incarnation { + return Err(MacosOwnerExecutionError::new( + "app-sidecar owner identity does not match the retained child", + )); + } + let mut daemon = authority + .daemon + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(child) = daemon.child.as_mut() else { + return Ok(()); + }; + hypercolor_macos_owner::request_macos_child_termination(child) + } + + /// Reap the managed daemon child on app exit. + /// + /// Tray quit runs `app.exit(0)`, which terminates the process without + /// unwinding, so `ManagedDaemon::Drop` never fires on its own. This is + /// called from the `RunEvent::Exit` handler while the process is still + /// alive: it suppresses the watchdog, requests graceful termination, + /// and escalates to a hard kill if the daemon lingers. The daemon's + /// own parent-death watch is the backstop for exit paths that skip + /// even this (crash, SIGKILL). + pub fn terminate_managed_daemon_for_exit(&self) { + self.set_owner_handover_stop(true); + #[cfg(target_os = "macos")] + { + let authority = self + .app_sidecar_child + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + let Some(authority) = authority else { + // Quit before the child was bound (still starting, or a + // reclaim in flight): no retained handle exists, so fall + // back to a best-effort SIGTERM by pid. The daemon's own + // parent-death watch is the backstop either way. + if let Some(pid) = self.child_pid() { + if let Err(error) = hypercolor_macos_owner::request_macos_pid_termination(pid) { + tracing::warn!(pid, %error, "unbound daemon termination failed on app exit"); + } else { + tracing::info!(pid, "unbound daemon asked to terminate on app exit"); + } + } + return; + }; + let mut daemon = authority + .daemon + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(child) = daemon.child.as_mut() else { + return; + }; + let pid = child.id(); + if let Err(error) = hypercolor_macos_owner::request_macos_child_termination(child) { + tracing::warn!(pid, %error, "graceful daemon termination failed on app exit"); + } + // The daemon's own shutdown budget is ~8s worst case (3s API + // drain, device teardown, 5s persistence flush); killing at 2s + // would routinely lose LED blanking and shutdown persistence. + // Ten seconds matches the managed-handover allotment. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(status)) => { + tracing::info!(pid, ?status, "managed daemon reaped on app exit"); + daemon.child = None; + return; + } + Ok(None) => {} + Err(error) => { + tracing::warn!(pid, %error, "managed daemon wait failed on app exit"); + break; + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + let _ = child.wait(); + daemon.child = None; + tracing::info!(pid, "managed daemon force-killed on app exit"); + } } fn mark_permanent_failure(&self) { @@ -124,19 +455,35 @@ impl SupervisorState { } } +/// Read the connection proof currently held by the native supervisor. +#[tauri::command] +#[must_use] +pub fn get_verified_daemon_connection( + state: tauri::State<'_, SupervisorState>, +) -> VerifiedDaemonConnectionSnapshot { + state.verified_daemon_connection() +} + +type SharedManagedDaemon = Arc>; + +#[cfg(target_os = "macos")] +#[derive(Clone)] +struct AppSidecarChild { + incarnation: MacosOwnerIncarnation, + daemon: SharedManagedDaemon, +} + /// App-owned daemon child process. pub struct ManagedDaemon { - /// Underlying child handle. Wrapped in `Option` so the watchdog can - /// `take()` it before performing a blocking `wait()` without tripping - /// the kill-on-drop fallback below. + /// Child handle retained until the watchdog reaps it. On macOS, the + /// handover authority shares this same managed daemon. pub(crate) child: Option, #[allow(dead_code)] pub(crate) platform_guard: PlatformGuard, } impl ManagedDaemon { - /// Return the child process ID, or 0 if the child has been taken out - /// (which only happens inside the watchdog right before wait()). + /// Return the child process ID, or 0 after the watchdog reaps it. #[must_use] pub fn id(&self) -> u32 { self.child.as_ref().map_or(0, Child::id) @@ -145,8 +492,10 @@ impl ManagedDaemon { impl Drop for ManagedDaemon { fn drop(&mut self) { + // Kill unless the child has provably exited: a try_wait error + // (EINTR, ECHILD) must not leak a live process. if let Some(mut child) = self.child.take() - && matches!(child.try_wait(), Ok(None)) + && !matches!(child.try_wait(), Ok(Some(_))) { let _ = child.kill(); let _ = child.wait(); @@ -354,6 +703,26 @@ pub fn build_daemon_command( effects_dir: Option<&Path>, ) -> DaemonCommand { let mut args = vec!["--bind".to_owned(), bind.to_owned()]; + let mut environment = Vec::new(); + + // Arms the daemon's parent-death watch (the daemon's + // SUPERVISED_PARENT_PID_ENV): if this app dies without reaping its + // child, the daemon observes the reparent and shuts itself down + // instead of orphaning on the port and the ownership guard. + #[cfg(unix)] + environment.push(( + "HYPERCOLOR_SUPERVISED_PARENT_PID".to_owned(), + std::process::id().to_string(), + )); + + #[cfg(target_os = "macos")] + { + args.extend(["--macos-owner".to_owned(), "app-sidecar".to_owned()]); + environment.push(( + "HYPERCOLOR_MACOS_OWNER".to_owned(), + "app-sidecar".to_owned(), + )); + } if let Some(ui_dir) = ui_dir { args.push("--ui-dir".to_owned()); @@ -368,6 +737,7 @@ pub fn build_daemon_command( DaemonCommand { program: program.into(), args, + environment, } } @@ -391,6 +761,331 @@ 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")] +fn server_identity_url(base: &Url) -> Url { + base.join("/api/v1/server") + .expect("static server-identity endpoint path should be valid") +} + +#[cfg(any(not(target_os = "macos"), test))] +fn health_verified_daemon_connection(base: &Url) -> VerifiedDaemonConnection { + VerifiedDaemonConnection { + base_url: base.as_str().trim_end_matches('/').to_owned(), + server_session_id: None, + protected_control_credential: None, + } +} + +#[cfg(target_os = "macos")] +fn canonical_daemon_guard_path() -> PathBuf { + std::env::temp_dir().join("hypercolor-daemon.lock") +} + +#[cfg(target_os = "macos")] +fn daemon_guard_is_contended(path: &Path) -> bool { + try_acquire_macos_daemon_guard(&path.to_string_lossy()).is_ok_and(|guard| guard.is_none()) +} + +#[cfg(target_os = "macos")] +fn daemon_base_is_loopback(base: &Url) -> bool { + if !matches!(base.scheme(), "http" | "https") { + return false; + } + let Some(host) = base.host_str() else { + return false; + }; + let host = host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .unwrap_or(host); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +#[cfg(target_os = "macos")] +#[derive(serde::Deserialize)] +struct ServerIdentityEnvelope { + data: ServerIdentityData, +} + +#[cfg(target_os = "macos")] +#[derive(serde::Deserialize)] +struct ServerIdentityData { + server_session_id: Option, +} + +#[cfg(target_os = "macos")] +fn attestation_matches_record( + attestation: &MacosDaemonSessionAttestation, + record: &hypercolor_macos_owner::MacosOwnerRecord, + expected_owner: MacosDaemonOwner, +) -> bool { + record.active_owner == expected_owner && attestation.owner_incarnation() == record.incarnation() +} + +#[cfg(target_os = "macos")] +async fn verify_macos_daemon_connection( + client: &reqwest::Client, + base: &Url, + store: &MacosOwnerStore, + guard_path: &Path, + state: &SupervisorState, + expected_owner: MacosDaemonOwner, +) -> Option { + if !daemon_base_is_loopback(base) { + tracing::warn!("macOS daemon verification rejected a non-loopback endpoint"); + return None; + } + let record = match store.load_owner_record() { + Ok(Some(record)) => record, + Ok(None) => { + tracing::warn!("macOS daemon verification found no owner record"); + return None; + } + Err(error) => { + tracing::warn!(%error, "macOS daemon verification could not load the owner record"); + return None; + } + }; + let attestation = match store.load_daemon_session_attestation() { + Ok(Some(attestation)) => attestation, + Ok(None) => { + tracing::warn!("macOS daemon verification found no session attestation"); + return None; + } + Err(error) => { + tracing::warn!(%error, "macOS daemon verification could not load the session attestation"); + return None; + } + }; + if !attestation_matches_record(&attestation, &record, expected_owner) { + tracing::warn!("macOS daemon verification found mismatched owner artifacts"); + return None; + } + let incarnation = record.incarnation(); + if expected_owner == MacosDaemonOwner::AppSidecar && !state.app_sidecar_is_live(&incarnation) { + tracing::warn!("macOS daemon verification found no live retained sidecar child"); + return None; + } + if !daemon_guard_is_contended(guard_path) { + tracing::warn!("macOS daemon verification found an unclaimed daemon guard"); + return None; + } + + let response = client + .get(server_identity_url(base)) + .timeout(HEALTH_PROBE_TIMEOUT) + .send() + .await; + let Ok(response) = response else { + tracing::warn!("macOS daemon verification could not read the server identity"); + return None; + }; + if !response.status().is_success() { + tracing::warn!(status = %response.status(), "macOS daemon verification received a failed server identity response"); + return None; + } + let Ok(envelope) = response.json::().await else { + tracing::warn!("macOS daemon verification could not decode the server identity"); + return None; + }; + let Some(observed_session) = envelope.data.server_session_id else { + tracing::warn!("macOS daemon verification found no server session identifier"); + return None; + }; + + if !daemon_guard_is_contended(guard_path) { + tracing::warn!("macOS daemon verification lost the daemon guard during verification"); + return None; + } + let current_record = match store.load_owner_record() { + Ok(Some(record)) => record, + _ => { + tracing::warn!("macOS daemon verification lost the owner record during verification"); + return None; + } + }; + let current_attestation = match store.load_daemon_session_attestation() { + Ok(Some(attestation)) => attestation, + _ => { + tracing::warn!( + "macOS daemon verification lost the session attestation during verification" + ); + return None; + } + }; + if current_record != record + || current_attestation != attestation + || observed_session != attestation.server_session_id + { + tracing::warn!("macOS daemon verification observed session drift"); + return None; + } + if expected_owner == MacosDaemonOwner::AppSidecar && !state.app_sidecar_is_live(&incarnation) { + tracing::warn!("macOS daemon verification lost the retained sidecar child"); + return None; + } + + Some(VerifiedDaemonConnection { + base_url: base.as_str().trim_end_matches('/').to_owned(), + server_session_id: Some(attestation.server_session_id), + protected_control_credential: Some(attestation.protected_control_credential), + }) +} + +#[cfg(target_os = "macos")] +async fn monitor_external_macos_daemon_connection( + client: &reqwest::Client, + base: &Url, + state: &SupervisorState, + expected_owner: MacosDaemonOwner, +) { + let store = MacosOwnerStore::new(data_dir()); + let guard_path = canonical_daemon_guard_path(); + loop { + tokio::time::sleep(EXTERNAL_OWNER_VERIFY_INTERVAL).await; + let connection = verify_macos_daemon_connection( + client, + base, + &store, + &guard_path, + state, + expected_owner, + ) + .await; + state.replace_verified_connection(connection); + } +} + +#[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 +1169,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,16 +1234,74 @@ 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 event_app = app.clone(); + state.install_verified_connection_emitter(Arc::new(move |connection| { + if let Err(error) = event_app.emit(VERIFIED_DAEMON_CONNECTION_CHANGED_EVENT, connection) { + tracing::warn!(%error, "failed to publish verified daemon session change"); + } + })); + state.clear_verified_connection(); + 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 { + #[cfg(target_os = "macos")] + let authoritative_owner_online = if let Some(owner) = external_owner { + verify_macos_daemon_connection( + &client, + &daemon_url, + &MacosOwnerStore::new(data_dir()), + &canonical_daemon_guard_path(), + &state, + external_mode_owner(owner), + ) + .await + } else { + None + }; + #[cfg(not(target_os = "macos"))] + let authoritative_owner_online = probe_health(&client, &daemon_url, HEALTH_PROBE_TIMEOUT) + .await + .then(|| health_verified_daemon_connection(&daemon_url)); + if let Some(connection) = authoritative_owner_online { + state.replace_verified_connection(Some(connection)); + state.set_macos_owner_offline(None); tracing::info!(url = %daemon_url, "daemon already running; reusing existing instance"); + #[cfg(target_os = "macos")] + if let Some(owner) = external_owner { + monitor_external_macos_daemon_connection( + &client, + &daemon_url, + &state, + external_mode_owner(owner), + ) + .await; + } + 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 { + if probe_health(&client, &daemon_url, HEALTH_PROBE_TIMEOUT).await { + state.replace_verified_connection(Some(health_verified_daemon_connection( + &daemon_url, + ))); + } return; } @@ -540,6 +1341,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 +1436,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 +1465,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 +1482,75 @@ 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(); + drop(daemon); + // A conflict exit is the primary "someone else holds our + // guard" signal: when a same-owner contender loses guard + // arbitration it exits terminally within a few hundred + // milliseconds, often before the orphan even answers a + // health probe. Reclaim covers the orphaned-app-sidecar + // holder; a legitimate external owner declines inside. + // Reclaims charge the restart budget so a pathological + // reclaim loop (say, two live app instances fighting) + // still trips the circuit breaker instead of ping-ponging + // forever; the budget resets after stable uptime. + #[cfg(target_os = "macos")] + if reclaim_stale_app_sidecar(pid).await { + record_failure(&mut restart_count, &mut window_anchor); + continue; + } + 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; @@ -632,11 +1558,63 @@ async fn run_watchdog_loop( tracing::info!(pid, "supervisor: daemon healthy"); let spawned_at = Instant::now(); + let daemon = Arc::new(Mutex::new(daemon)); + #[cfg(target_os = "macos")] + if let Err(error) = bind_app_sidecar_child(&state, pid, Arc::clone(&daemon)) { + tracing::error!(pid, %error, "healthy app sidecar did not publish its exact owner identity"); + drop(daemon); + state.clear_child(); + // The classic cause: an orphaned daemon from a dead app + // instance still holds the guard and answered the health + // probe on behalf of our child. A successful reclaim skips + // the backoff but still charges the restart budget, so a + // pathological reclaim loop trips the circuit breaker. + record_failure(&mut restart_count, &mut window_anchor); + if reclaim_stale_app_sidecar(pid).await { + continue; + } + tokio::time::sleep(restart_backoff(restart_count)).await; + continue; + } + #[cfg(target_os = "macos")] + { + let verified = verify_macos_daemon_connection( + &client, + &daemon_url, + &MacosOwnerStore::new(data_dir()), + &canonical_daemon_guard_path(), + &state, + MacosDaemonOwner::AppSidecar, + ) + .await; + let Some(verified) = verified else { + tracing::error!(pid, "healthy app sidecar failed exact session verification"); + state.clear_child(); + drop(daemon); + record_failure(&mut restart_count, &mut window_anchor); + if reclaim_stale_app_sidecar(pid).await { + continue; + } + tokio::time::sleep(restart_backoff(restart_count)).await; + continue; + }; + state.replace_verified_connection(Some(verified)); + } + #[cfg(not(target_os = "macos"))] + state.replace_verified_connection(Some(health_verified_daemon_connection(&daemon_url))); let exit = wait_for_exit(daemon).await; let uptime = spawned_at.elapsed(); 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 +1629,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 +1648,160 @@ async fn run_watchdog_loop( } } +/// Attempt to reclaim ownership from an orphaned app-sidecar daemon. +/// +/// A previous app instance that died without reaping leaves its daemon +/// holding the port, the flock guard, and an owner record naming a pid this +/// supervisor never spawned. The single-instance plugin guarantees no other +/// live app owns that child, so after verifying the live process matches +/// the recorded identity the orphan is asked to terminate, and the guard +/// release is awaited so the next spawn can win it cleanly. Returns true +/// when the guard was reclaimed and a respawn should proceed immediately. +/// +/// Spec 77 invariant 8 (managed owners stop through the topology that +/// launched them) holds here: the recorded owner is AppSidecar, this app is +/// the single live instance of that topology, and the launching instance no +/// longer exists, so this is the owning topology reaping its own orphan, +/// not a handover signaling a foreign pid. +#[cfg(target_os = "macos")] +async fn reclaim_stale_app_sidecar(current_child_pid: u32) -> bool { + let record = match MacosOwnerStore::new(data_dir()).load_owner_record() { + Ok(Some(record)) => record, + Ok(None) => return false, + Err(error) => { + tracing::warn!(%error, "owner record unreadable during stale-sidecar reclaim"); + return false; + } + }; + if record.active_owner != MacosDaemonOwner::AppSidecar { + return false; + } + let stale_pid = record.active_identity.pid; + if stale_pid == current_child_pid || stale_pid == std::process::id() { + return false; + } + let guard_path = canonical_daemon_guard_path(); + if !daemon_guard_is_contended(&guard_path) { + // The flock dies with its holder: an uncontended guard means the + // recorded daemon is already gone and there is nothing to reclaim. + return false; + } + if !process_matches_identity(stale_pid, &record.active_identity.executable_path) { + tracing::warn!( + stale_pid, + "recorded app-sidecar owner does not match the live process; refusing to signal" + ); + return false; + } + tracing::warn!( + stale_pid, + "orphaned app-sidecar daemon holds the guard; requesting termination" + ); + if let Err(error) = hypercolor_macos_owner::request_macos_pid_termination(stale_pid) { + tracing::warn!(stale_pid, %error, "stale app-sidecar termination failed"); + return false; + } + let released = tokio::task::spawn_blocking(move || { + hypercolor_macos_owner::wait_for_macos_guard_release( + Duration::from_secs(10), + &guard_path.to_string_lossy(), + ) + }) + .await; + match released { + Ok(Ok(true)) => { + tracing::info!(stale_pid, "stale app-sidecar guard reclaimed"); + true + } + Ok(Ok(false)) => { + tracing::warn!( + stale_pid, + "stale app-sidecar ignored termination; guard still held" + ); + false + } + Ok(Err(error)) => { + tracing::warn!(stale_pid, %error, "guard release wait failed during reclaim"); + false + } + Err(error) => { + tracing::warn!(stale_pid, %error, "guard release task failed during reclaim"); + false + } + } +} + +#[cfg(target_os = "macos")] +fn process_matches_identity(pid: u32, executable: &Path) -> bool { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), + true, + ProcessRefreshKind::nothing().with_exe(UpdateKind::Always), + ); + system + .process(Pid::from_u32(pid)) + .and_then(sysinfo::Process::exe) + .is_some_and(|exe| { + // The live exe comes from proc_pidpath (resolved) while the + // record stores current_exe() (unresolved); canonicalize both + // so symlinked installs still match. Resolution failure means + // the identity cannot be attested, which declines the reclaim. + match (exe.canonicalize(), executable.canonicalize()) { + (Ok(live), Ok(recorded)) => live == recorded, + _ => false, + } + }) +} + +#[cfg(target_os = "macos")] +fn bind_app_sidecar_child( + state: &SupervisorState, + pid: u32, + daemon: SharedManagedDaemon, +) -> Result<(), MacosOwnerExecutionError> { + let store = MacosOwnerStore::new(data_dir()); + let record = store + .load_owner_record() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .filter(|record| { + record.active_owner == MacosDaemonOwner::AppSidecar && record.active_identity.pid == pid + }) + .ok_or_else(|| { + MacosOwnerExecutionError::new( + "healthy app sidecar has no matching authoritative owner publication", + ) + })?; + state.register_app_sidecar_child(record.incarnation(), daemon) +} + +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,21 +1809,617 @@ fn record_failure(count: &mut u32, anchor: &mut Option) { } } -/// 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 { - let join = tauri::async_runtime::spawn_blocking(move || { - let mut daemon = daemon; - let Some(mut child) = daemon.child.take() else { - return Err(std::io::Error::other("daemon child already taken")); +#[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")] + fn external_session_fixture() -> ( + tempfile::TempDir, + hypercolor_macos_owner::MacosOwnerStore, + hypercolor_macos_owner::MacosDaemonGuard, + hypercolor_macos_owner::MacosDaemonSessionAttestation, + std::path::PathBuf, + ) { + use hypercolor_macos_owner::{ + MacosOwnerIdentity, MacosOwnerStore, try_acquire_macos_daemon_guard, }; - let status = child.wait(); - // platform_guard (Job Object on Windows) drops here, AFTER the - // child has been waited on. The Drop above sees `child = None` - // and does nothing — child is already reaped. + + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard acquisition should succeed") + .expect("fixture should win the guard"); + let store = MacosOwnerStore::new(directory.path().join("store")); + let record = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + MacosOwnerIdentity::new( + "audit-launchd", + "/usr/local/bin/hypercolor-daemon", + "requirement-launchd", + std::process::id(), + ) + .expect("identity should build"), + ) + .expect("owner should publish"); + let attestation = store + .publish_daemon_session_attestation(&guard, &record.incarnation()) + .expect("session should publish"); + (directory, store, guard, attestation, guard_path) + } + + #[cfg(target_os = "macos")] + async fn server_identity_fixture(body: String) -> (url::Url, tokio::task::JoinHandle<()>) { + 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 { + 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("GET /api/v1/server 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"); + }); + ( + url::Url::parse(&format!("http://{address}")).expect("fixture daemon URL should parse"), + server, + ) + } + + #[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")] + #[tokio::test] + async fn verified_connection_requires_matching_endpoint_session_and_live_guard() { + let (_directory, store, guard, attestation, guard_path) = external_session_fixture(); + let body = serde_json::json!({ + "data": { "server_session_id": attestation.server_session_id } + }) + .to_string(); + let (base, server) = server_identity_fixture(body).await; + let state = super::SupervisorState::default(); + + let verified = super::verify_macos_daemon_connection( + &reqwest::Client::new(), + &base, + &store, + &guard_path, + &state, + MacosDaemonOwner::DirectLaunchd, + ) + .await; + server.await.expect("fixture server should finish"); + let verified = verified.expect("matching live session should verify"); + assert_eq!( + verified.server_session_id, + Some(attestation.server_session_id) + ); + assert_eq!( + verified.protected_control_credential, + Some(attestation.protected_control_credential) + ); + drop(guard); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn wrong_or_missing_endpoint_session_never_exposes_credential() { + let (_directory, store, _guard, _attestation, guard_path) = external_session_fixture(); + let state = super::SupervisorState::default(); + for body in [ + serde_json::json!({ "data": { "server_session_id": null } }).to_string(), + serde_json::json!({ + "data": { + "server_session_id": hypercolor_macos_owner::MacosServerSessionId::from_bytes([0x77; 16]) + } + }) + .to_string(), + ] { + let (base, server) = server_identity_fixture(body).await; + assert!( + super::verify_macos_daemon_connection( + &reqwest::Client::new(), + &base, + &store, + &guard_path, + &state, + MacosDaemonOwner::DirectLaunchd, + ) + .await + .is_none() + ); + server.await.expect("fixture server should finish"); + } + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn stale_crash_artifacts_and_replayed_session_fail_without_live_guard() { + let (_directory, store, guard, attestation, guard_path) = external_session_fixture(); + let replayed_session = attestation.server_session_id.clone(); + drop(guard); + let unreachable = + url::Url::parse("http://127.0.0.1:9").expect("fixture daemon URL should parse"); + + assert_eq!( + store + .load_daemon_session_attestation() + .expect("stale matching artifacts should still load") + .expect("stale attestation should remain") + .server_session_id, + replayed_session + ); + assert!( + super::verify_macos_daemon_connection( + &reqwest::Client::new(), + &unreachable, + &store, + &guard_path, + &super::SupervisorState::default(), + MacosDaemonOwner::DirectLaunchd, + ) + .await + .is_none() + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn nonloopback_daemon_base_never_enters_session_verification() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let base = + url::Url::parse("https://attacker.example:9420").expect("fixture URL should parse"); + assert!( + super::verify_macos_daemon_connection( + &reqwest::Client::new(), + &base, + &hypercolor_macos_owner::MacosOwnerStore::new(directory.path()), + &directory.path().join("daemon.lock"), + &super::SupervisorState::default(), + MacosDaemonOwner::DirectLaunchd, + ) + .await + .is_none() + ); + assert!(!super::daemon_base_is_loopback(&base)); + assert!(super::daemon_base_is_loopback( + &url::Url::parse("http://[::1]:9420").expect("loopback URL should parse") + )); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn prebound_unrelated_listener_cannot_become_the_app_sidecar_endpoint() { + use std::process::{Command, Stdio}; + use std::sync::{Arc, Mutex}; + + use hypercolor_macos_owner::{ + MacosOwnerIdentity, MacosOwnerStore, try_acquire_macos_daemon_guard, + }; + + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard acquisition should succeed") + .expect("fixture should win the guard"); + let child = Command::new("/bin/sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("fixture child should spawn"); + let pid = child.id(); + let daemon = Arc::new(Mutex::new(super::ManagedDaemon { + child: Some(child), + platform_guard: super::PlatformGuard, + })); + let store = MacosOwnerStore::new(directory.path().join("store")); + let record = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + MacosOwnerIdentity::new( + "audit-sidecar", + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "requirement-sidecar", + pid, + ) + .expect("identity should build"), + ) + .expect("owner should publish"); + let state = super::SupervisorState::default(); + state + .register_app_sidecar_child(record.incarnation(), Arc::clone(&daemon)) + .expect("matching child should register"); + assert!(state.app_sidecar_is_live(&record.incarnation())); + let unrelated_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("unrelated listener should pre-bind a loopback port"); + let unrelated_address = unrelated_listener + .local_addr() + .expect("unrelated listener address should resolve"); + let base = url::Url::parse(&format!("http://{unrelated_address}")) + .expect("unrelated listener URL should parse"); + + assert!( + super::verify_macos_daemon_connection( + &reqwest::Client::new(), + &base, + &store, + &guard_path, + &state, + MacosDaemonOwner::AppSidecar, + ) + .await + .is_none() + ); + assert!( + store + .load_daemon_session_attestation() + .expect("session state should load") + .is_none() + ); + drop(unrelated_listener); + state.clear_child(); drop(daemon); - status - }); + drop(guard); + } + + #[test] + fn clearing_verified_state_emits_offline_and_removes_old_credential() { + use std::sync::{Arc, Mutex, PoisonError}; + + let state = super::SupervisorState::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&events); + state.install_verified_connection_emitter(Arc::new(move |connection| { + recorded + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(connection); + })); + state.replace_verified_connection(Some(super::VerifiedDaemonConnection { + base_url: "http://127.0.0.1:9420".to_owned(), + server_session_id: Some(hypercolor_macos_owner::MacosServerSessionId::from_bytes( + [0x11; 16], + )), + protected_control_credential: Some( + hypercolor_macos_owner::MacosProtectedControlCredential::from_bytes([0x22; 32]), + ), + })); + state.clear_verified_connection(); + + assert!(state.verified_daemon_connection().connection.is_none()); + let events = events.lock().unwrap_or_else(PoisonError::into_inner); + assert_eq!(events.len(), 2); + assert_eq!(events[0].revision, 1); + assert!(events[0].connection.is_some()); + assert_eq!(events[1].revision, 2); + assert!(events[1].connection.is_none()); + } + + #[test] + fn native_health_proof_enables_base_without_macos_session_authority() { + let state = super::SupervisorState::default(); + assert!(state.verified_daemon_connection().connection.is_none()); + + let base = url::Url::parse("https://daemon.lan:19420/").expect("URL should parse"); + state.replace_verified_connection(Some(super::health_verified_daemon_connection(&base))); + let connection = state + .verified_daemon_connection() + .connection + .expect("health-proven native daemon should publish its route"); + assert_eq!(connection.base_url, "https://daemon.lan:19420"); + assert!(connection.server_session_id.is_none()); + assert!(connection.protected_control_credential.is_none()); + } + + #[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) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn stale_or_mismatched_sidecar_identity_never_stops_the_retained_child() { + use std::os::unix::process::ExitStatusExt; + use std::process::{Command, Stdio}; + use std::sync::{Arc, Mutex, PoisonError}; + + use hypercolor_macos_owner::{MacosOwnerIdentity, MacosOwnerIncarnation}; + + use super::{ManagedDaemon, PlatformGuard, SupervisorState}; + + let child = Command::new("/bin/sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("fixture child should spawn"); + let pid = child.id(); + let daemon = Arc::new(Mutex::new(ManagedDaemon { + child: Some(child), + platform_guard: PlatformGuard, + })); + let exact = MacosOwnerIncarnation { + owner: MacosDaemonOwner::AppSidecar, + owner_epoch: 9, + identity: MacosOwnerIdentity::new( + "audit-current", + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "requirement-current", + pid, + ) + .expect("exact identity should build"), + }; + let state = SupervisorState::default(); + state + .register_app_sidecar_child(exact.clone(), Arc::clone(&daemon)) + .expect("exact child should register"); + + let stale_pid_reuse = MacosOwnerIncarnation { + owner: MacosDaemonOwner::AppSidecar, + owner_epoch: 8, + identity: MacosOwnerIdentity::new( + "audit-stale", + "/Applications/Old Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "requirement-stale", + pid, + ) + .expect("stale identity should build"), + }; + assert!(state.preflight_app_sidecar_stop(&stale_pid_reuse).is_err()); + assert!(state.stop_app_sidecar(&stale_pid_reuse).is_err()); + assert!( + daemon + .lock() + .unwrap_or_else(PoisonError::into_inner) + .child + .as_mut() + .expect("child should remain retained") + .try_wait() + .expect("child state should read") + .is_none() + ); + + state + .preflight_app_sidecar_stop(&exact) + .expect("exact live retained child should pass preflight"); + state + .stop_app_sidecar(&exact) + .expect("exact retained child should stop"); + { + let mut daemon = daemon.lock().unwrap_or_else(PoisonError::into_inner); + let status = daemon + .child + .as_mut() + .expect("child should remain retained until reap") + .wait() + .expect("stopped child should reap"); + assert_eq!(status.signal(), Some(15), "handover stop must use SIGTERM"); + daemon.child.take(); + } + state.clear_child(); + assert!(state.preflight_app_sidecar_stop(&exact).is_err()); + state + .stop_app_sidecar(&exact) + .expect("replayed exact stop should be idempotent after the child is cleared"); + } +} + +/// Poll a retained `ManagedDaemon` child on a dedicated thread until it exits. +async fn wait_for_exit(daemon: SharedManagedDaemon) -> Result { + let join = tauri::async_runtime::spawn_blocking( + move || -> std::io::Result { + loop { + let status = { + let mut daemon = daemon.lock().unwrap_or_else(PoisonError::into_inner); + let child = daemon + .child + .as_mut() + .ok_or_else(|| std::io::Error::other("daemon child already taken"))?; + let status = child.try_wait()?; + if status.is_some() { + daemon.child.take(); + } + status + }; + if let Some(status) = status { + return Ok(status); + } + std::thread::sleep(Duration::from_millis(25)); + } + }, + ); match join.await { Ok(Ok(status)) => Ok(status), Ok(Err(error)) => Err(anyhow::Error::from(error)), @@ -844,6 +2580,7 @@ pub fn spawn_daemon(command: &DaemonCommand) -> Result { let mut process = Command::new(&command.program); process .args(&command.args) + .envs(command.environment.iter().map(|(key, value)| (key, value))) .stdin(Stdio::null()) .stdout(Stdio::from(daemon_log_file()?.try_clone()?)) .stderr(Stdio::from(daemon_log_file()?)); diff --git a/crates/hypercolor-app/src/window.rs b/crates/hypercolor-app/src/window.rs index ce22d395c..0cd37f23a 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( @@ -66,6 +108,61 @@ pub fn open_new_window_in_system_browser( NewWindowResponse::Deny } +/// Whether a main-frame navigation may proceed inside the trusted webview. +/// +/// The webview holds `window.__TAURI__` and the per-session protected +/// control credential, so only the bundled app origin may load in it: the +/// custom `tauri:` scheme on macOS and Linux, and the `tauri.localhost` +/// host on Windows. Everything else is denied; new-window requests +/// already route to the system browser. +#[must_use] +pub fn navigation_is_trusted(url: &Url) -> bool { + if url.scheme() == "tauri" { + return true; + } + matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost") +} + +/// Release a secure-input assertion retained by the embedded macOS webview. +/// +/// # Errors +/// +/// Returns a Tauri error when the webview cannot be accessed on its UI thread. +pub fn release_webview_secure_input(window: &WebviewWindow) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + window.with_webview(|platform_webview| { + use objc2::{msg_send, runtime::AnyObject, sel}; + + // SAFETY: Tauri documents this pointer as a live WKWebView for the + // duration of the closure. The private selector is checked before a + // no-argument, void-returning message is sent. + unsafe { + let webview = &*platform_webview.inner().cast::(); + let reset = sel!(_resetSecureInputState); + let responds: bool = msg_send![webview, respondsToSelector: reset]; + if responds { + let _: () = msg_send![webview, _resetSecureInputState]; + } else { + // The private selector is the only release mechanism; if + // WebKit renames it the workaround dies silently, so say + // so once instead of never. + static MISSING_SELECTOR_WARNED: std::sync::Once = std::sync::Once::new(); + MISSING_SELECTOR_WARNED.call_once(|| { + tracing::warn!( + "WKWebView no longer responds to _resetSecureInputState; \ + secure-input release after focus loss is inoperative" + ); + }); + } + } + })?; + + #[cfg(not(target_os = "macos"))] + let _ = window; + + Ok(()) +} + /// Build the JavaScript that mirrors native window visibility into the web UI. #[must_use] pub fn visibility_state_script(visible: bool) -> String { diff --git a/crates/hypercolor-app/tauri.bundle.conf.json b/crates/hypercolor-app/tauri.bundle.conf.json index df030fd6f..e3440aa3e 100644 --- a/crates/hypercolor-app/tauri.bundle.conf.json +++ b/crates/hypercolor-app/tauri.bundle.conf.json @@ -1,4 +1,7 @@ { + "build": { + "frontendDist": "../../target/bundle-stage/ui" + }, "bundle": { "externalBin": [ "../../target/bundle-stage/binaries/hypercolor-daemon", diff --git a/crates/hypercolor-app/tauri.conf.json b/crates/hypercolor-app/tauri.conf.json index ecb62e082..f13dcfd02 100644 --- a/crates/hypercolor-app/tauri.conf.json +++ b/crates/hypercolor-app/tauri.conf.json @@ -2,10 +2,15 @@ "productName": "Hypercolor", "version": "0.3.2", "identifier": "tech.hyperbliss.hypercolor", + "build": { + "frontendDist": "../hypercolor-ui/dist" + }, "app": { "windows": [], "withGlobalTauri": true, - "security": {} + "security": { + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; img-src 'self' data: blob: http: https:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src 'self' data: https://fonts.bunny.net; object-src 'none'; base-uri 'none'; frame-src 'none'; frame-ancestors 'none'; form-action 'none'" + } }, "bundle": { "active": true, @@ -41,7 +46,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/tauri.macos.conf.json b/crates/hypercolor-app/tauri.macos.conf.json new file mode 100644 index 000000000..a4fb6dc02 --- /dev/null +++ b/crates/hypercolor-app/tauri.macos.conf.json @@ -0,0 +1,7 @@ +{ + "app": { + "security": { + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http://[::1]:* https://127.0.0.1:* https://localhost:* https://[::1]:* ws://127.0.0.1:* ws://localhost:* ws://[::1]:* wss://127.0.0.1:* wss://localhost:* wss://[::1]:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:* http://[::1]:* https://127.0.0.1:* https://localhost:* https://[::1]:*; media-src 'self' data: blob: http://127.0.0.1:* http://localhost:* http://[::1]:* https://127.0.0.1:* https://localhost:* https://[::1]:*; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src 'self' data: https://fonts.bunny.net; object-src 'none'; base-uri 'none'; frame-src 'none'; frame-ancestors 'none'; form-action 'none'" + } + } +} diff --git a/crates/hypercolor-app/tests/config_tests.rs b/crates/hypercolor-app/tests/config_tests.rs index 6a5f9170d..49fad11d4 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,85 @@ 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 + .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() +} + +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(); @@ -59,20 +139,12 @@ fn default_capability_grants_window_and_autostart_permissions() { } #[test] -fn default_capability_allows_local_daemon_remote_ipc() { +fn default_capability_rejects_every_remote_ipc_origin() { let capability = default_capability(); - let urls = capability - .get("remote") - .and_then(|remote| remote.get("urls")) - .and_then(serde_json::Value::as_array) - .expect("remote.urls should be configured"); - - for expected in ["http://127.0.0.1:9420/*", "http://localhost:9420/*"] { - assert!( - urls.iter().any(|value| value == expected), - "capability should allow IPC from {expected}" - ); - } + assert!( + capability.get("remote").is_none(), + "bundled-origin commands must not be authorized for any remote document" + ); } #[test] @@ -173,6 +245,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(); @@ -195,12 +279,91 @@ 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] +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] @@ -398,7 +561,7 @@ fn tauri_config_has_app_section() { } #[test] -fn tauri_config_exposes_global_tauri_api_for_local_remote_ui() { +fn tauri_config_exposes_global_tauri_api_for_bundled_ui_bridge() { let config = tauri_config(); let with_global_tauri = config .get("app") diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index 08981ab55..15186941c 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"); @@ -12,11 +18,13 @@ const CARGO_TARGET_GC_SERVICE: &str = include_str!("../../../packaging/systemd/user/hypercolor-cargo-target-gc.service"); const CARGO_TARGET_GC_TIMER: &str = include_str!("../../../packaging/systemd/user/hypercolor-cargo-target-gc.timer"); +const RUN_MACOS_TCC_CANARY_SH: &str = include_str!("../../../scripts/run-macos-tcc-canary-row.sh"); const BRAND_BUILD_PY: &str = include_str!("../../../assets/brand/build.py"); const DIAGNOSE_WINDOWS_PS1: &str = include_str!("../../../scripts/diagnose-windows.ps1"); 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 = @@ -25,6 +33,22 @@ 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"); +const MACOS_SIGNING_KEYCHAIN_C: &str = include_str!("../../../scripts/macos-signing-keychain.c"); +const MACOS_SIGNING_MANIFEST: &str = include_str!("../../../packaging/macos/signing-manifest.tsv"); +const TAURI_CONFIG: &str = include_str!("../tauri.conf.json"); +const TAURI_MACOS_CONFIG: &str = include_str!("../tauri.macos.conf.json"); +const TAURI_BUNDLE_CONFIG: &str = include_str!("../tauri.bundle.conf.json"); +const TAURI_DEFAULT_CAPABILITY: &str = include_str!("../capabilities/default.json"); +const TAURI_BUILD_RS: &str = include_str!("../build.rs"); +const APP_MAIN_RS: &str = include_str!("../src/main.rs"); +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"); @@ -49,6 +73,585 @@ const REQUIRED_PAWNIO_MODULES: &[&str] = &[ "AMDFamily17.bin", ]; +fn csp_directives( + csp: &str, +) -> std::collections::BTreeMap> { + csp.split(';') + .filter_map(|directive| { + let mut fields = directive.split_whitespace(); + let name = fields.next()?; + Some(( + name.to_owned(), + fields + .map(str::to_owned) + .collect::>(), + )) + }) + .collect() +} + +fn csp_sources(values: &[&str]) -> std::collections::BTreeSet { + values.iter().map(|value| (*value).to_owned()).collect() +} + +#[test] +fn app_window_is_bundled_and_never_accepts_daemon_document_bytes() { + let config: serde_json::Value = + serde_json::from_str(TAURI_CONFIG).expect("Tauri config should parse"); + let bundle_config: serde_json::Value = + serde_json::from_str(TAURI_BUNDLE_CONFIG).expect("bundle config should parse"); + + assert_eq!(config["build"]["frontendDist"], "../hypercolor-ui/dist"); + assert_eq!( + bundle_config["build"]["frontendDist"], + "../../target/bundle-stage/ui" + ); + assert!(APP_MAIN_RS.contains("WebviewUrl::App(\"index.html\".into())")); + assert!(!APP_MAIN_RS.contains("WebviewUrl::External")); + assert!(!APP_MAIN_RS.contains("window.navigate")); + assert!(!APP_MAIN_RS.contains("__HYPERCOLOR_DAEMON_BASE_URL__")); + assert!(!APP_MAIN_RS.contains("initialization_script(daemon")); +} + +#[test] +fn bundled_origin_capability_allows_exact_registered_commands_only() { + let capability: serde_json::Value = + serde_json::from_str(TAURI_DEFAULT_CAPABILITY).expect("default capability should parse"); + assert!(capability.get("remote").is_none()); + assert_eq!(capability["windows"], serde_json::json!(["main"])); + + let (_, build_commands) = TAURI_BUILD_RS + .split_once(".commands(&[") + .expect("build manifest should enumerate app commands"); + let (build_commands, _) = build_commands + .split_once("]);") + .expect("build manifest command list should close"); + let build_commands = build_commands + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix('"')) + .filter_map(|line| line.strip_suffix("\",")) + .collect::>(); + + let (_, handlers) = APP_MAIN_RS + .split_once("tauri::generate_handler![") + .expect("app should register a command handler"); + let (handlers, _) = handlers + .split_once("])") + .expect("command handler list should close"); + let handlers = handlers + .split(',') + .filter_map(|entry| entry.trim().rsplit("::").next()) + .filter(|entry| !entry.is_empty()) + .collect::>(); + assert_eq!(build_commands, handlers); + + let command_permissions = capability["permissions"] + .as_array() + .expect("permissions should be an array") + .iter() + .filter_map(serde_json::Value::as_str) + .filter_map(|permission| permission.strip_prefix("allow-")) + .map(|permission| permission.replace('-', "_")) + .collect::>(); + let build_commands = build_commands + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!(command_permissions, build_commands); +} + +#[test] +fn bundled_origin_csp_is_exact_and_macos_network_access_is_loopback_only() { + let config: serde_json::Value = + serde_json::from_str(TAURI_CONFIG).expect("Tauri config should parse"); + let macos: serde_json::Value = + serde_json::from_str(TAURI_MACOS_CONFIG).expect("macOS Tauri config should parse"); + let security = &config["app"]["security"]; + let macos_security = &macos["app"]["security"]; + assert!( + security + .get("dangerousDisableAssetCspModification") + .is_none() + ); + assert!( + macos_security + .get("dangerousDisableAssetCspModification") + .is_none() + ); + + let base = csp_directives( + security["csp"] + .as_str() + .expect("base CSP should be a string"), + ); + let macos = csp_directives( + macos_security["csp"] + .as_str() + .expect("macOS CSP should be a string"), + ); + let directive_names = csp_sources(&[ + "base-uri", + "connect-src", + "default-src", + "font-src", + "form-action", + "frame-ancestors", + "frame-src", + "img-src", + "media-src", + "object-src", + "script-src", + "style-src", + "worker-src", + ]); + assert_eq!( + base.keys() + .cloned() + .collect::>(), + directive_names + ); + assert_eq!( + macos + .keys() + .cloned() + .collect::>(), + directive_names + ); + + for directives in [&base, &macos] { + assert_eq!(directives["default-src"], csp_sources(&["'self'"])); + assert_eq!( + directives["script-src"], + csp_sources(&["'self'", "'wasm-unsafe-eval'"]) + ); + assert_eq!(directives["worker-src"], csp_sources(&["'self'", "blob:"])); + assert_eq!( + directives["style-src"], + csp_sources(&["'self'", "'unsafe-inline'", "https://fonts.bunny.net"]) + ); + assert_eq!( + directives["font-src"], + csp_sources(&["'self'", "data:", "https://fonts.bunny.net"]) + ); + for denied in [ + "object-src", + "base-uri", + "frame-src", + "frame-ancestors", + "form-action", + ] { + assert_eq!(directives[denied], csp_sources(&["'none'"])); + } + } + + assert_eq!( + base["connect-src"], + csp_sources(&[ + "'self'", + "ipc:", + "http://ipc.localhost", + "http:", + "https:", + "ws:", + "wss:", + ]) + ); + assert_eq!( + base["img-src"], + csp_sources(&["'self'", "data:", "blob:", "http:", "https:"]) + ); + assert_eq!(base["media-src"], base["img-src"]); + + let loopback_http = [ + "http://127.0.0.1:*", + "http://localhost:*", + "http://[::1]:*", + "https://127.0.0.1:*", + "https://localhost:*", + "https://[::1]:*", + ]; + let mut macos_connect = csp_sources(&["'self'", "ipc:", "http://ipc.localhost"]); + macos_connect.extend(loopback_http.iter().map(|source| (*source).to_owned())); + macos_connect.extend( + [ + "ws://127.0.0.1:*", + "ws://localhost:*", + "ws://[::1]:*", + "wss://127.0.0.1:*", + "wss://localhost:*", + "wss://[::1]:*", + ] + .into_iter() + .map(str::to_owned), + ); + assert_eq!(macos["connect-src"], macos_connect); + let mut macos_media = csp_sources(&["'self'", "data:", "blob:"]); + macos_media.extend(loopback_http.into_iter().map(str::to_owned)); + assert_eq!(macos["img-src"], macos_media); + assert_eq!(macos["media-src"], macos["img-src"]); +} + +#[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 macos_packaging_and_installers_cover_both_architectures() { + assert!(CI_WORKFLOW.contains("target: macos-arm64")); + assert!(CI_WORKFLOW.contains("target: macos-x64")); + assert!(CI_WORKFLOW.contains("rust-target: aarch64-apple-darwin")); + assert!(CI_WORKFLOW.contains("rust-target: x86_64-apple-darwin")); + + for expected in ["macos-arm64", "macos-amd64"] { + 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!(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() { + let (_, launchd_arguments) = MACOS_LAUNCHD_PLIST + .split_once("ProgramArguments") + .expect("launchd plist should declare program arguments"); + let (launchd_arguments, _) = launchd_arguments + .split_once("") + .expect("launchd argument array should close"); + assert_eq!( + launchd_arguments + .lines() + .filter_map(|line| line.trim().strip_prefix("")) + .filter_map(|line| line.strip_suffix("")) + .collect::>(), + [ + "@BIN_DIR@/hypercolor-daemon", + "--macos-owner", + "direct-launchd", + "--ui-dir", + "@UI_DIR@", + ] + ); + + let (_, launchd_environment) = MACOS_LAUNCHD_PLIST + .split_once("EnvironmentVariables") + .expect("launchd plist should declare environment variables"); + let (launchd_environment, _) = launchd_environment + .split_once("") + .expect("launchd environment dictionary should close"); + let launchd_environment = launchd_environment + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "") + .collect::>(); + assert_eq!( + launchd_environment, + [ + "HYPERCOLOR_MACOS_OWNER", + "direct-launchd", + "HYPERCOLOR_LOG", + "info", + "PATH", + "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:@BIN_DIR@", + ] + ); + + let (_, homebrew_service) = HOMEBREW_FORMULA + .split_once(" service do\n") + .expect("Homebrew formula should declare a service"); + let (homebrew_service, _) = homebrew_service + .split_once("\n end") + .expect("Homebrew service block should close"); + assert_eq!( + homebrew_service, + concat!( + " run [opt_bin/\"hypercolor-daemon\", \"--macos-owner\", \"homebrew\", ", + "\"--ui-dir\", share/\"hypercolor/ui\"]\n", + " keep_alive successful_exit: false\n", + " log_path var/\"log/hypercolor/hypercolor.log\"\n", + " error_log_path var/\"log/hypercolor/hypercolor.log\"\n", + " environment_variables HYPERCOLOR_LOG: \"info\", ", + "HYPERCOLOR_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 = + 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] +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_installs_nasm_before_intel_macos_compilation() { + let (_, macos_job) = CI_WORKFLOW + .split_once("\n rust-check-macos:\n") + .expect("CI should define the macOS check job"); + let (macos_job, _) = macos_job + .split_once("\n generated-effects:\n") + .expect("generated effects should follow the macOS check job"); + let install = macos_job + .find("- name: Install NASM") + .expect("Intel macOS checks should install NASM"); + let (_, install_and_after) = macos_job + .split_once("- name: Install NASM\n") + .expect("Intel macOS checks should install NASM"); + let (install_step, _) = install_and_after + .split_once("\n\n - ") + .expect("another macOS step should follow NASM installation"); + let qualification = macos_job + .find("- name: Qualify Intel Metal fixture") + .expect("Intel macOS checks should qualify native Metal import"); + let workspace = macos_job + .find("- name: Check macOS workspace") + .expect("macOS checks should compile the workspace"); + + assert!(install_step.contains("if: matrix.expected-arch == 'x86_64'")); + assert!(install_step.contains("run: brew install nasm")); + assert!(install < qualification); + assert!(install < workspace); +} + +#[test] +fn ci_runs_inline_and_integration_macos_capture_fixtures() { + let (_, fixture_steps) = CI_WORKFLOW + .split_once("- name: Run macOS capture fixtures") + .expect("CI should run macOS capture fixtures"); + let (fixture_steps, _) = fixture_steps + .split_once("- name: Run macOS host input and ownership fixtures") + .expect("host input fixtures should follow capture fixtures"); + let (_, first_command_and_after) = fixture_steps + .split_once("./scripts/cargo-cache-build.sh") + .expect("capture fixtures should invoke the Cargo wrapper"); + let (capture_command, _) = first_command_and_after + .split_once("\n ./scripts/cargo-cache-build.sh") + .expect("core capture fixtures should follow crate capture fixtures"); + + assert!(capture_command.contains( + "cargo nextest run --locked \\\n -p hypercolor-macos-capture --features capture-fixtures" + )); + for selector in ["--lib", "--tests", "--test", "-E"] { + assert!( + !capture_command.contains(selector), + "capture crate command must not select a target with {selector}" + ); + } + assert!(fixture_steps.contains("--test macos_screen_capture_tests")); +} + +#[test] +fn ci_builds_pr_docs_without_widening_deployment_permissions() { + let (_, docs_jobs) = CI_WORKFLOW + .split_once("\n docs-build:\n") + .expect("CI should define an unprivileged docs build job"); + let (build_job, deploy_and_after) = docs_jobs + .split_once("\n docs-deploy:\n") + .expect("CI should define a separate docs deployment job"); + let (deploy_job, _) = deploy_and_after + .split_once("\n web-assets:\n") + .expect("web assets should follow the docs jobs"); + let (_, build_condition_and_after) = build_job + .split_once(" if: >-\n") + .expect("docs build should define a job condition"); + let (build_condition, _) = build_condition_and_after + .split_once(" runs-on:") + .expect("docs build condition should precede its runner"); + let (_, upload_and_after) = build_job + .split_once(" - name: Upload Pages artifact\n") + .expect("docs build should upload its Pages artifact"); + let upload_step = upload_and_after; + let (_, upload_condition_and_after) = upload_step + .split_once(" if: >-\n") + .expect("Pages upload should define a condition"); + let (upload_condition, _) = upload_condition_and_after + .split_once(" uses:") + .expect("Pages upload condition should precede its action"); + let (_, deploy_condition_and_after) = deploy_job + .split_once(" if: >-\n") + .expect("docs deploy should define a job condition"); + let (deploy_condition, _) = deploy_condition_and_after + .split_once(" runs-on:") + .expect("docs deploy condition should precede its runner"); + let normalize = |condition: &str| condition.split_whitespace().collect::>().join(" "); + let expected_build = normalize( + "(github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true') || + (github.ref == 'refs/heads/main' && ( + (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) + ))", + ); + let expected_deploy = normalize( + "github.ref == 'refs/heads/main' && ( + (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) + )", + ); + + assert_eq!(normalize(build_condition), expected_build); + assert!(build_job.contains("permissions:\n contents: read")); + assert!(build_job.contains("working-directory: docs\n run: zola build")); + assert!(!build_job.contains("pages: write")); + assert!(!build_job.contains("id-token: write")); + assert!(!build_job.contains("actions/deploy-pages")); + assert!(upload_step.contains("uses: actions/upload-pages-artifact@v5")); + assert!(upload_step.contains("path: docs/public")); + + assert!(deploy_job.contains("needs: [changes, docs-build]")); + assert_eq!(normalize(upload_condition), expected_deploy); + assert_eq!(normalize(deploy_condition), expected_deploy); + assert!(deploy_job.contains("pages: write")); + assert!(deploy_job.contains("id-token: write")); + assert!(deploy_job.contains("actions/deploy-pages@v5")); +} + +#[test] +fn public_ci_audits_pr_and_unsigned_app_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(), + 2 + ); +} + +#[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")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("tauri build --bundles app --no-sign")); +} + +#[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"#)); @@ -64,21 +667,160 @@ 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 public_ci_builds_unsigned_macos_packaging_fixtures_only() { 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("artifact-kind: unsigned-app")); + assert!(CI_WORKFLOW.contains("--no-sign")); + assert!(CI_WORKFLOW.contains(r#"@("--target", $env:RUST_TARGET)"#)); + assert!(CI_WORKFLOW.contains("Upload unsigned macOS packaging fixture")); + assert!(CI_WORKFLOW.contains("name: oss-ci-${{ steps.version.outputs.version }}")); + assert!(!CI_WORKFLOW.contains("Build signed and notarized macOS artifacts")); + assert!(!CI_WORKFLOW.contains("APPLE_SIGNING_IDENTITY")); + assert!(!CI_WORKFLOW.contains("-name '*.dmg'")); + assert!(!CI_WORKFLOW.contains("-name '*.notarization.json'")); +} + +#[test] +fn proprietary_macos_release_tools_use_the_manifest_signing_actor() { + assert!(!CI_WORKFLOW.contains(r#"APPLE_SIGNING_IDENTITY: "-""#)); + assert!(!CI_WORKFLOW.contains("./scripts/sign-macos-artifacts.sh")); + 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_signing_secrets_stay_out_of_process_arguments() { + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("printf '%s\\0%s\\0'")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("env -u APPLE_CERTIFICATE_PASSWORD")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("APPLE_NOTARY_KEYCHAIN_PROFILE")); + assert!(SIGN_MACOS_ARTIFACTS_SH.contains("--keychain-profile")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("security create-keychain -p")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("security unlock-keychain -p")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("security import")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("set-key-partition-list")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("--apple-id")); + assert!(!SIGN_MACOS_ARTIFACTS_SH.contains("--password")); + + assert!(MACOS_SIGNING_KEYCHAIN_C.contains("read_secret_frame")); + assert!(MACOS_SIGNING_KEYCHAIN_C.contains("SecItemImport")); + assert!(MACOS_SIGNING_KEYCHAIN_C.contains("SecKeychainItemSetAccessWithPassword")); + assert!(MACOS_SIGNING_KEYCHAIN_C.contains("memset_s")); + + assert!(!CI_WORKFLOW.contains("APPLE_")); + assert!(!CI_WORKFLOW.contains("notarytool")); + + assert!(BUILD_MAC_INSTALLER_SH.contains("APPLE_NOTARY_KEYCHAIN_PROFILE")); + assert!(BUILD_MAC_INSTALLER_SH.contains("raw Apple ID credentials are unsupported")); } #[test] -fn ci_publishes_homebrew_formula_and_cask() { - assert!(CI_WORKFLOW.contains("packaging/homebrew/hypercolor.rb > hypercolor.rb")); - assert!(CI_WORKFLOW.contains("packaging/homebrew/hypercolor-app.rb > hypercolor-app.rb")); - assert!(CI_WORKFLOW.contains("sha256_macos_app_arm64")); - assert!(CI_WORKFLOW.contains("sha256_macos_app_x86_64")); - assert!(CI_WORKFLOW.contains("tap/Casks")); - assert!(CI_WORKFLOW.contains("Casks/hypercolor-app.rb")); +fn signed_macos_builds_can_enable_the_physical_tcc_canary_explicitly() { + assert!(BUILD_MAC_INSTALLER_SH.contains("--tcc-canary")); + assert!(BUILD_MAC_INSTALLER_SH.contains("--tcc-canary requires --notarize")); + assert!( + BUILD_MAC_INSTALLER_SH.contains(r#"daemon_features="${daemon_features},macos-tcc-canary""#) + ); + assert!(DIST_SH.contains("--tcc-canary requires a macOS target")); + assert!(DIST_SH.contains("DAEMON_FEATURE_FLAG=(--features macos-tcc-canary)")); + assert!(DIST_SH.contains(r#""${MACOS_SIGNING_ACTOR}" standalone"#)); +} + +#[test] +fn tcc_canary_runner_uses_the_daemon_canonical_data_directory() { + assert!( + RUN_MACOS_TCC_CANARY_SH + .contains(r#"${HOME:?HOME must be set}/Library/Application Support/hypercolor"#) + ); + assert!(!RUN_MACOS_TCC_CANARY_SH.contains("--data-dir")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("--execute-protected-actions")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("--macos-tcc-canary-check-request")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("regular non-symlink witness")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("request process_replacement_witness_id is invalid")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("login_arbitration_witness_id")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("armed request does not exactly match")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("identifier_is_safe")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#"$1" != "." && "$1" != "..""#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("installed_row_artifacts")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("install_new_artifact")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("--macos-tcc-canary-publish")); + assert!(!RUN_MACOS_TCC_CANARY_SH.contains(r#"/bin/ln "${source}" "${destination}""#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("require_real_path_ancestors")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("path has a symlink ancestor")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("row_committed=true")); + assert!(!RUN_MACOS_TCC_CANARY_SH.contains("kill -TERM \"${predecessor_pid}\"")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("--cli PATH")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${cli}" service enable"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${cli}" service stop"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${cli}" service start"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${brew}" services start hypercolor"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${brew}" services stop hypercolor"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(r#""${brew}" services start hypercolor"#)); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("app_supervisor_daemon_restart")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("app_quit_then_minimized_launch")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("launchd_login_start")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("brew_services_login_start")); + assert!(!RUN_MACOS_TCC_CANARY_SH.contains("launchctl kickstart")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("operation_timeout_ms + 999")); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("minimum_timeout_seconds")); + assert!( + RUN_MACOS_TCC_CANARY_SH + .contains(r#""${daemon}" --macos-owner standalone >/dev/null 2>&1 &"#) + ); + assert!(RUN_MACOS_TCC_CANARY_SH.contains("ensure_descendant_directory")); + let predecessor_stop = RUN_MACOS_TCC_CANARY_SH + .find(r#""${cli}" service stop"#) + .expect("runner should stop the direct service before replacement"); + let exit_observation = RUN_MACOS_TCC_CANARY_SH + .find("action_observed_unix_ms=$(( $(date +%s) * 1000 ))") + .expect("runner should timestamp predecessor exit after waiting"); + let replacement_witness = RUN_MACOS_TCC_CANARY_SH + .find(r#"kind: "process_replacement""#) + .expect("runner should record a replacement witness"); + let successor_start = RUN_MACOS_TCC_CANARY_SH + .rfind(r#""${cli}" service start"#) + .expect("runner should start the direct successor after its witness"); + assert!(predecessor_stop < exit_observation); + assert!(exit_observation < replacement_witness); + assert!(replacement_witness < successor_start); + let pending_receipt_wait = RUN_MACOS_TCC_CANARY_SH + .find("a regular atomic pending receipt did not arrive") + .expect("runner should wait for an atomic pending receipt"); + let receipt_wait = RUN_MACOS_TCC_CANARY_SH + .find("a regular atomic receipt did not arrive") + .expect("runner should wait for an atomic receipt"); + let settings_install = RUN_MACOS_TCC_CANARY_SH + .find(r#"install_witness "${settings_witness_id}" system_settings_identity"#) + .expect("runner should install the settings witness"); + assert!(pending_receipt_wait < settings_install); + assert!(settings_install < receipt_wait); + assert!(RUN_MACOS_TCC_CANARY_SH.contains(".signing.audit_token_bound_valid == true")); + assert!( + RUN_MACOS_TCC_CANARY_SH + .contains(".launcher.parent_signing.audit_token_bound_valid == true") + ); +} + +#[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] +fn public_ci_leaves_homebrew_promotion_to_the_proprietary_release_pipeline() { + assert!(!CI_WORKFLOW.contains("update-homebrew:")); + assert!(!CI_WORKFLOW.contains("HOMEBREW_TAP_TOKEN")); + assert!(!CI_WORKFLOW.contains("tap/Casks")); + assert!(HOMEBREW_FORMULA.contains("SHA256_MACOS_ARM64")); + assert!(HOMEBREW_CASK.contains("SHA256_MACOS_APP_ARM64")); } #[test] diff --git a/crates/hypercolor-app/tests/supervisor_tests.rs b/crates/hypercolor-app/tests/supervisor_tests.rs index 62fdf7d1f..75f3a2509 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,36 @@ 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::>() + ); + assert_eq!( + command.environment, + [ + #[cfg(unix)] + ( + "HYPERCOLOR_SUPERVISED_PARENT_PID".to_owned(), + std::process::id().to_string(), + ), + #[cfg(target_os = "macos")] + ( + "HYPERCOLOR_MACOS_OWNER".to_owned(), + "app-sidecar".to_owned(), + ), + ] ); } @@ -192,7 +226,33 @@ 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); + assert_eq!( + command.environment, + [ + #[cfg(unix)] + ( + "HYPERCOLOR_SUPERVISED_PARENT_PID".to_owned(), + std::process::id().to_string(), + ), + #[cfg(target_os = "macos")] + ( + "HYPERCOLOR_MACOS_OWNER".to_owned(), + "app-sidecar".to_owned(), + ), + ] + ); } #[test] diff --git a/crates/hypercolor-app/tests/window_tests.rs b/crates/hypercolor-app/tests/window_tests.rs index fdad78a08..6b444ae8f 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, + navigation_is_trusted, route_navigation_script, should_open_in_system_browser, + system_browser_url, visibility_state_script, }; #[test] @@ -35,9 +36,56 @@ fn system_browser_handoff_allows_only_web_urls() { assert!(!should_open_in_system_browser(&file)); } +#[test] +fn webview_navigation_allows_only_the_bundled_app_origin() { + for allowed in [ + "tauri://localhost/index.html", + "tauri://localhost/settings", + "http://tauri.localhost/index.html", + "https://tauri.localhost/", + ] { + let url = allowed.parse().expect("fixture URL should parse"); + assert!(navigation_is_trusted(&url), "{allowed}"); + } + for denied in [ + "https://github.com/hyperb1iss", + "http://127.0.0.1:9420/api/v1/devices", + "http://localhost/index.html", + "file:///etc/passwd", + "https://tauri.localhost.attacker.example/", + "javascript:alert(1)", + "about:blank", + ] { + let url = denied.parse().expect("fixture URL should parse"); + assert!(!navigation_is_trusted(&url), "{denied}"); + } +} + #[test] 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-cli/Cargo.toml b/crates/hypercolor-cli/Cargo.toml index 6c2e19733..6b5c1ab97 100644 --- a/crates/hypercolor-cli/Cargo.toml +++ b/crates/hypercolor-cli/Cargo.toml @@ -21,10 +21,13 @@ tui = ["dep:hypercolor-tui"] [dependencies] hypercolor-core = { workspace = true } +hypercolor-macos-owner = { workspace = true } 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/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/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 &'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", + } + } + + 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. +/// +/// # 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.human_success_message(&response)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{AccessCommand, grant_owner_label}; + 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); + } + } + + #[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-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-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/service.rs b/crates/hypercolor-cli/src/commands/service.rs index 8d033f92f..56587ca74 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,430 @@ 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")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum CliOwnerStopAuthority { + AppSupervisor, + LaunchctlService(String), + HomebrewService(&'static str), + UserDirected, +} + +#[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)?)) + } + + fn stop_authority( + &self, + owner: hypercolor_macos_owner::MacosDaemonOwner, + ) -> Result { + use hypercolor_macos_owner::MacosDaemonOwner; + + Ok(match owner { + MacosDaemonOwner::AppSidecar => CliOwnerStopAuthority::AppSupervisor, + MacosDaemonOwner::DirectLaunchd => { + CliOwnerStopAuthority::LaunchctlService(self.target(owner)?) + } + MacosDaemonOwner::Homebrew => CliOwnerStopAuthority::HomebrewService("hypercolor"), + MacosDaemonOwner::Standalone => CliOwnerStopAuthority::UserDirected, + }) + } +} + +#[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 preflight_stop_authority( + &mut self, + incarnation: &hypercolor_macos_owner::MacosOwnerIncarnation, + ) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + use hypercolor_macos_owner::MacosOwnerExecutionError; + + match self.stop_authority(incarnation.owner)? { + CliOwnerStopAuthority::AppSupervisor => Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires the app supervisor's retained child handle", + )), + CliOwnerStopAuthority::LaunchctlService(_) + | CliOwnerStopAuthority::HomebrewService(_) => Ok(()), + CliOwnerStopAuthority::UserDirected => Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )), + } + } + + fn flush_and_stop( + &mut self, + incarnation: &hypercolor_macos_owner::MacosOwnerIncarnation, + ) -> Result<(), hypercolor_macos_owner::MacosOwnerExecutionError> { + use hypercolor_macos_owner::MacosOwnerExecutionError; + + match self.stop_authority(incarnation.owner)? { + CliOwnerStopAuthority::AppSupervisor => Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires the app supervisor's retained child handle", + )), + CliOwnerStopAuthority::LaunchctlService(target) => { + let output = owner_command_output("/bin/launchctl", &["print", &target])?; + if !output.status.success() { + return Ok(()); + } + owner_run_command("/bin/launchctl", &["kill", "SIGTERM", &target]) + } + CliOwnerStopAuthority::HomebrewService(formula) => { + let brew = homebrew_binary()?; + owner_run_command(&brew.to_string_lossy(), &["services", "stop", formula]) + } + CliOwnerStopAuthority::UserDirected => Err(MacosOwnerExecutionError::new( + "standalone termination requires its terminal user", + )), + } + } + + 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, + timeout: std::time::Duration, + ) -> Result { + hypercolor_macos_owner::wait_for_macos_guard_release( + 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 +1013,146 @@ 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, MacosOwnerExecutor, MacosOwnerIdentity, 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) + ); + assert_eq!( + executor + .stop_authority(MacosDaemonOwner::AppSidecar) + .expect("app-sidecar authority should resolve"), + super::CliOwnerStopAuthority::AppSupervisor + ); + + let record = executor + .store + .publish_owner( + MacosDaemonOwner::AppSidecar, + MacosOwnerIdentity::new( + "audit-sidecar", + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "requirement-sidecar", + 4_242, + ) + .expect("identity should build"), + ) + .expect("owner should publish"); + let mut executor = executor; + let preflight_error = executor + .preflight_stop_authority(&record.incarnation()) + .expect_err("CLI must reject app-owned stop authority before handover"); + assert!( + preflight_error + .to_string() + .contains("retained child handle") + ); + let error = executor + .flush_and_stop(&record.incarnation()) + .expect_err("CLI must not stop an app-owned child"); + assert!(error.to_string().contains("retained child handle")); + } + + #[cfg(target_os = "macos")] + #[test] + fn cli_external_owner_stops_use_only_exact_launcher_identities() { + 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!( + executor + .stop_authority(MacosDaemonOwner::DirectLaunchd) + .expect("launchd authority should resolve"), + super::CliOwnerStopAuthority::LaunchctlService( + "gui/501/tech.hyperbliss.hypercolor".to_owned() + ) + ); + assert_eq!( + executor + .stop_authority(MacosDaemonOwner::Homebrew) + .expect("Homebrew authority should resolve"), + super::CliOwnerStopAuthority::HomebrewService("hypercolor") + ); + } +} diff --git a/crates/hypercolor-cli/src/commands/status.rs b/crates/hypercolor-cli/src/commands/status.rs index 94da65d8a..8a471aea1 100644 --- a/crates/hypercolor-cli/src/commands/status.rs +++ b/crates/hypercolor-cli/src/commands/status.rs @@ -1,9 +1,12 @@ //! `hyper status` -- display current system state. +use std::future::Future; +use std::time::Duration; + use anyhow::Result; use clap::Args; -use crate::client::DaemonClient; +use crate::client::{DaemonClient, DaemonEventSubscription}; use crate::output::{OutputContext, OutputFormat, Painter}; /// Show current system state: running effect, devices, FPS, audio capture. @@ -13,11 +16,85 @@ pub struct StatusArgs { #[arg(long)] pub watch: bool, - /// Update interval for --watch mode in seconds. + /// Minimum render interval for --watch mode in seconds. #[arg(long, default_value = "1")] pub interval: f64, } +trait StatusWatchClient { + type Events: StatusWatchEvents; + + async fn subscribe_status_events(&self) -> 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 +} + +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 sleep = tokio::time::sleep(std::time::Duration::from_secs_f64(interval)); - tokio::pin!(sleep); + 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<()> { @@ -102,6 +263,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 +295,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 +654,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") @@ -460,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"); @@ -506,6 +1016,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 +1059,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 +1092,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..cec016829 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, @@ -293,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(()) diff --git a/crates/hypercolor-core/Cargo.toml b/crates/hypercolor-core/Cargo.toml index 25a642d2d..9ca4421f4 100644 --- a/crates/hypercolor-core/Cargo.toml +++ b/crates/hypercolor-core/Cargo.toml @@ -16,6 +16,8 @@ default = [] 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 = [ @@ -30,6 +32,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", @@ -45,6 +48,8 @@ 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-macos-capture = { workspace = true } hypercolor-driver-api = { workspace = true } hypercolor-hal = { workspace = true } hypercolor-platform-fs = { workspace = true } @@ -145,12 +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 - -[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/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/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/effect/lightscript/frame_payload_adapter.js b/crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js index f90fe2216..3361c5c9d 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; } @@ -170,7 +174,14 @@ 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), + 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 = []; @@ -194,7 +205,14 @@ 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); + 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); } } 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/effect/servo/renderer.rs b/crates/hypercolor-core/src/effect/servo/renderer.rs index 672c6ce8c..c4a5c0d0c 100644 --- a/crates/hypercolor-core/src/effect/servo/renderer.rs +++ b/crates/hypercolor-core/src/effect/servo/renderer.rs @@ -366,8 +366,8 @@ fn effect_has_tag(metadata: &EffectMetadata, name: &str) -> bool { fn host_driven_animation(metadata: &EffectMetadata) -> bool { matches!(metadata.source, EffectSource::Html { .. }) - && !effect_has_tag(metadata, "webgl") - && !effect_has_tag(metadata, "canvas2d") + && (cfg!(target_os = "macos") + || (!effect_has_tag(metadata, "webgl") && !effect_has_tag(metadata, "canvas2d"))) } #[cfg(feature = "servo-gpu-import")] 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 { .. } diff --git a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs index 9c9299931..a6a215ec0 100644 --- a/crates/hypercolor-core/src/effect/servo/renderer/tests.rs +++ b/crates/hypercolor-core/src/effect/servo/renderer/tests.rs @@ -857,7 +857,7 @@ fn display_frame_payloads_keep_fixed_animation_cap() { } #[test] -fn html_effects_use_host_driven_animation() { +fn html_effects_use_the_platform_animation_driver() { let html = html_metadata(PathBuf::from("effect.html")); let display = display_html_metadata(PathBuf::from("display.html")); let mut webgl = html_metadata(PathBuf::from("webgl.html")); @@ -867,8 +867,8 @@ fn html_effects_use_host_driven_animation() { assert!(host_driven_animation(&html)); assert!(host_driven_animation(&display)); - assert!(!host_driven_animation(&webgl)); - assert!(!host_driven_animation(&canvas2d)); + assert_eq!(host_driven_animation(&webgl), cfg!(target_os = "macos")); + assert_eq!(host_driven_animation(&canvas2d), cfg!(target_os = "macos")); } #[test] @@ -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/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/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(); 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 23fcfdf70..000000000 --- a/crates/hypercolor-core/src/input/interaction/mod.rs +++ /dev/null @@ -1,697 +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::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/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/src/input/macos.rs b/crates/hypercolor-core/src/input/macos.rs new file mode 100644 index 000000000..2fe425887 --- /dev/null +++ b/crates/hypercolor-core/src/input/macos.rs @@ -0,0 +1,1589 @@ +//! macOS host input folded from Core Graphics event-tap batches. + +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, + MacosInputPublicationOutcome, 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, + MacosProtectedSourceState, MacosTimingStatus, SourceIssue, SourceKind, SourcePlatformStatus, + 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; +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)] +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, + 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>, +} + +#[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 { + let keyboard_tcc = if !capture_keyboard { + MacosAuthorizationState::Unknown + } else if input_monitoring_granted() { + MacosAuthorizationState::Authorized + } else { + MacosAuthorizationState::NotDetermined + }; + let (host_architecture, executable_architecture, translated_process) = + native_process_architecture(); + let mut source = 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(), + 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, + }; + source + .refresh_platform_status() + .expect("new macOS input status is not retired"); + source + } + + #[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 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), + 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 const fn capture_kinds(&self) -> (bool, bool) { + (self.capture_keyboard, self.capture_pointer) + } + + pub fn set_capability_owner(&mut self, owner: MacosCapabilityOwner) -> anyhow::Result<()> { + self.owner = owner; + self.refresh_platform_status() + } + + fn set_daemon_ownership( + &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() + } + + #[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; + } + 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 + }; + 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 { + keyboard, + pointer, + keyboard_tcc: self.keyboard_tcc, + // Read from the session's health-tick cache: probing + // Carbon here would put an FFI call on the render + // thread at frame rate. + secure_input_active: native + .is_some_and(|diagnostics| diagnostics.secure_input_active), + 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)), + callback_to_publication_timing: native.map(|diagnostics| MacosTimingStatus { + sample_count: diagnostics.callback_to_publication_sample_count, + total_ns: diagnostics.callback_to_publication_total_ns, + max_ns: diagnostics.callback_to_publication_max_ns, + p95_ns: diagnostics.callback_to_publication_p95_ns, + p99_ns: diagnostics.callback_to_publication_p99_ns, + }), + }, + )))?; + 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 + .swap(AUTHORIZATION_NONE, Ordering::AcqRel) + { + AUTHORIZATION_NONE => return Ok(()), + AUTHORIZATION_GRANTED => { + self.set_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.set_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(); + 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| { + if publish_macos_batch(&shared, batch, event_limit) { + MacosInputPublicationOutcome::Published + } else { + MacosInputPublicationOutcome::Rejected + } + }) { + 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.set_keyboard_tcc(MacosAuthorizationState::Denied); + 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 set_macos_daemon_ownership( + &mut self, + owner: MacosCapabilityOwner, + conflict: Option, + designated_requirement_hash: Option>, + ) -> anyhow::Result<()> { + self.set_daemon_ownership(owner, conflict, designated_requirement_hash) + } + + 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; + self.refresh_platform_status()?; + Ok(()) + } + + fn stop(&mut self) { + self.status_session.clear(); + 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.apply_pending_authorization()?; + self.refresh_worker_health(); + self.refresh_platform_status()?; + 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) { + 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()); + } + 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 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(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 { + AUTHORIZATION_DENIED + }, + Ordering::Release, + ); + Ok(granted) + }), + )) + } + + 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 { + self.refresh_platform_status()?; + return Ok(()); + } + self.capture_active = active; + if !self.running { + self.refresh_platform_status()?; + 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(); + } + self.refresh_platform_status()?; + 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 active != held { + active + } else if key == "CapsLock" { + // The CapsLock flag carries the lock state rather than key travel: + // the physical release re-reports the unchanged flag. Not an edge + // and not an anomaly, so it neither emits nor counts. + return; + } 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/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 diff --git a/crates/hypercolor-core/src/input/mod.rs b/crates/hypercolor-core/src/input/mod.rs index a18a4a243..d6b27b052 100644 --- a/crates/hypercolor-core/src/input/mod.rs +++ b/crates/hypercolor-core/src/input/mod.rs @@ -9,13 +9,13 @@ 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; pub mod net; pub mod routing; pub mod screen; +mod scroll; pub mod sensor; mod status; mod traits; @@ -34,24 +34,34 @@ 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}; 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, - SourceIssue, SourceKind, SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosScreenTimingStatus, MacosSelectionState, MacosTahoeCapabilities, + MacosTahoeSelectionCapabilities, MacosTimingStatus, ScreenCaptureDiagnostics, + ScreenCaptureReductionPath, SourceDiagnostics, SourceFreshness, SourceIssue, SourceKind, + SourcePlatformStatus, SourceResourceScanHealth, SourceSessionSlot, SourceSessionWriter, SourceState, SourceStatus, SourceStatusAvailability, SourceStatusError, SourceStatusHandle, SourceStatusRegistry, SourceStatusRegistrySnapshot, SourceStatusReporter, 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, ScreenData, - ScreenZoneColors, + InteractionDiagnostics, KeyboardData, MotionAggregate, MouseData, PointerMode, + ProtectedSourceActionExecutor, ProtectedSourceActionOwner, ProtectedSourceAuthorizationAction, + ResolvedProtectedSourceAction, ScreenData, ScreenSourcePickerAction, ScreenZoneColors, + ScrollAggregate, }; pub use windows::WindowsHostInput; #[cfg(all(target_os = "windows", feature = "windows-capture-fixtures"))] @@ -66,6 +76,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}; @@ -224,6 +235,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 { @@ -235,6 +278,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"); @@ -306,6 +350,10 @@ pub struct InputManager { source_status_registry: SourceStatusRegistry, event_scratch: Vec, 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, screen_publication_source_snapshot: Vec<(u64, u64)>, @@ -379,6 +427,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); @@ -387,6 +446,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, @@ -550,6 +620,10 @@ 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_owner_designated_requirement_hash: None, + macos_metal4: false, screen_capture_demand: None, screen_publication_demand: None, screen_publication_source_snapshot: Vec::new(), @@ -611,6 +685,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"); @@ -1409,7 +1486,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() { @@ -1442,7 +1519,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 { @@ -1450,15 +1530,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 = @@ -1492,8 +1583,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() @@ -1548,6 +1639,7 @@ impl InputManager { workers, demand, source_resolution_revision, + active_consumer_counts, ))) } @@ -1569,6 +1661,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 { @@ -1608,6 +1701,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); @@ -1719,6 +1813,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, @@ -1794,6 +1891,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 @@ -1831,6 +1991,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"); @@ -1841,7 +2002,7 @@ impl InputManager { true } }); - self.screen_capture_demand = None; + self.invalidate_capture_domains((false, true, false)); self.publish_source_status_registry(); } @@ -1880,6 +2041,160 @@ 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, + 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(), + designated_requirement_hash.clone(), + )?; + } + self.publish_source_status_registry(); + 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] + pub fn input_authorization_action(&self) -> Option { + self.sources + .iter() + .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] + pub fn screen_authorization_action(&self) -> Option { + self.sources + .iter() + .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] + pub fn screen_source_picker_action(&self) -> Option { + self.sources + .iter() + .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 { + self.sources + .iter() + .find_map(|source| source.macos_screenshot_reference_action()) + } + /// Ask screen sources to discard their persisted selection and re-prompt. /// /// # Errors @@ -1927,9 +2242,19 @@ 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(), + self.macos_owner_designated_requirement_hash.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 @@ -2121,12 +2446,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 @@ -2225,3 +2579,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-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/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/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/screen/fanout.rs b/crates/hypercolor-core/src/input/screen/fanout.rs index 3132d3556..d64265e56 100644 --- a/crates/hypercolor-core/src/input/screen/fanout.rs +++ b/crates/hypercolor-core/src/input/screen/fanout.rs @@ -9,13 +9,14 @@ use thiserror::Error; 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, + CpuReductionError, CpuReductionExecutor, CpuScalarSource, CpuSurfaceMaterializationError, + 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(all(test, feature = "macos-capture-fixtures"))] + 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 { @@ -579,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 @@ -638,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)?; @@ -649,16 +786,10 @@ 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(); self.publications.clear(); self.direct_batch_indices.clear(); @@ -727,26 +858,99 @@ 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); } } - if let Err(error) = executor.execute_aligned_publications( - &self.batch, - frame, - workspace, - &self.workspace_schedule, - &self.direct_batch_indices, - &mut self.publications, - ) { - clear_pending_publications( - &mut self.reservations, + 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() + .expect("attached fanout retains its executor"); + let workspace = self + .workspace + .as_mut() + .expect("attached fanout retains its workspace"); + 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, @@ -782,6 +986,7 @@ impl PreparedCpuPublicationFanout { pixels, frame, plan_generation, + self.suppress_scene_cut_bypass[physical_index], &mut publications[reservation_index], ); if let Err(error) = result { @@ -831,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 @@ -935,6 +1140,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 +1150,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 +1209,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 +1314,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 +1340,7 @@ fn stage_workspace_publication( physical_descriptor, physical_pixels, frame.metadata().captured_at, + suppress_scene_cut_bypass, publication, )?; } @@ -1072,6 +1354,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 +1373,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 +1399,7 @@ fn stage_prereduced_publication( physical_descriptor, physical_pixels, captured_at, + suppress_scene_cut_bypass, publication, )?; } @@ -1128,6 +1413,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 +1622,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) } @@ -1633,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..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; @@ -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)?; @@ -1247,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 { @@ -1257,7 +1292,9 @@ pub struct PlatformGpuSurface { owner: Arc, retained_owner: Option>, 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. @@ -1265,6 +1302,7 @@ pub struct PlatformGpuSurface { pub struct PlatformGpuSurfaceOwner { owner: Arc, _target_resource_lifetime: Option, + _shared_target_resource_lifetime: Option, _capture_resource_lifetime: Option, } @@ -1272,11 +1310,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, } } @@ -1324,18 +1364,32 @@ impl PlatformGpuSurface { owner, retained_owner: None, 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, 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 } @@ -1381,6 +1435,7 @@ impl PlatformGpuSurface { PlatformGpuSurfaceOwner::new( owner, self.target_resource_lifetime.clone(), + self.shared_target_resource_lifetime.clone(), self.capture_resource_lifetime.clone(), ) }) @@ -1399,6 +1454,7 @@ impl PlatformGpuSurface { PlatformGpuSurfaceOwner::new( owner, self.target_resource_lifetime.clone(), + self.shared_target_resource_lifetime.clone(), self.capture_resource_lifetime.clone(), ) }) @@ -1410,11 +1466,23 @@ 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> { 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 { @@ -1853,6 +1921,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/hub.rs b/crates/hypercolor-core/src/input/screen/hub.rs index f24974455..0107e1348 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()) + } } } } @@ -378,12 +418,13 @@ pub enum ScreenBranchDeliveryLifecycle { Retired, } -/// Orthogonal lock-free delivery diagnostics for one exact branch. +/// One coherent delivery observation for an exact branch. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ScreenBranchDeliveryState { lifecycle: ScreenBranchDeliveryLifecycle, freshness: Option, source_health: Option, + invalidation_epoch: u64, last_publish_was_pressured: bool, pressure_events: u64, } @@ -407,6 +448,12 @@ impl ScreenBranchDeliveryState { self.source_health } + /// Monotonic terminal invalidation epoch for this branch authority. + #[must_use] + pub const fn invalidation_epoch(self) -> u64 { + self.invalidation_epoch + } + /// Whether the most recent publish attempt failed because every slot was held. /// /// This lock-free historical diagnostic clears after the next successful @@ -523,6 +570,12 @@ impl ScreenPublicationMetadata { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ScreenStoredGpuPayloadKind { + OutputSurface, + NativeWork, +} + #[derive(Debug)] enum ScreenPublicationStorage { CpuSurface { @@ -534,6 +587,7 @@ enum ScreenPublicationStorage { GpuSurface { api: PlatformGpuApi, colorimetry: ScreenPublicationColorimetry, + payload_kind: ScreenStoredGpuPayloadKind, surface: Option, }, Zones { @@ -571,6 +625,7 @@ impl ScreenPublicationStorage { Ok(Self::GpuSurface { api, colorimetry: descriptor_colorimetry(descriptor), + payload_kind: ScreenStoredGpuPayloadKind::OutputSurface, surface: None, }) } @@ -592,9 +647,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 +779,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, @@ -896,6 +988,7 @@ struct ScreenBranchRuntime { slots: Vec>>, last_native_sequence: Option, next_branch_sequence: u64, + invalidation_epoch: u64, } struct ScreenBranchEntry { @@ -946,6 +1039,7 @@ impl ScreenBranchEntry { slots, last_native_sequence: None, next_branch_sequence: 0, + invalidation_epoch: 0, }), latest: ArcSwapOption::empty(), last_publish_was_pressured: AtomicBool::new(false), @@ -985,56 +1079,68 @@ impl ScreenBranchEntry { self.latest.load_full() } - fn read(&self) -> Option> { - if self.is_retired() { - return None; - } - let publication = self.raw_latest()?; - if self.is_retired() { - return None; - } - Some(publication) - } - - fn delivery_state(&self, now: Instant) -> ScreenBranchDeliveryState { + fn observe( + &self, + now: Instant, + ) -> ( + Option>, + ScreenBranchDeliveryState, + ) { + let runtime = self.lock_runtime(); if self.is_retired() { - return ScreenBranchDeliveryState { - lifecycle: ScreenBranchDeliveryLifecycle::Retired, - freshness: None, - source_health: None, - last_publish_was_pressured: false, - pressure_events: self.pressure_events.load(Ordering::Acquire), - }; + return ( + None, + ScreenBranchDeliveryState { + lifecycle: ScreenBranchDeliveryLifecycle::Retired, + freshness: None, + source_health: None, + invalidation_epoch: runtime.invalidation_epoch, + last_publish_was_pressured: false, + pressure_events: self.pressure_events.load(Ordering::Acquire), + }, + ); } let Some(publication) = self.latest.load_full() else { - return ScreenBranchDeliveryState { - lifecycle: ScreenBranchDeliveryLifecycle::Pending, - freshness: None, - source_health: ScreenPublicationHealth::decode( - self.delivery_health.load(Ordering::Acquire), - ), - last_publish_was_pressured: self.last_publish_was_pressured.load(Ordering::Acquire), - pressure_events: self.pressure_events.load(Ordering::Acquire), - }; + return ( + None, + ScreenBranchDeliveryState { + lifecycle: ScreenBranchDeliveryLifecycle::Pending, + freshness: None, + source_health: ScreenPublicationHealth::decode( + self.delivery_health.load(Ordering::Acquire), + ), + invalidation_epoch: runtime.invalidation_epoch, + last_publish_was_pressured: self + .last_publish_was_pressured + .load(Ordering::Acquire), + pressure_events: self.pressure_events.load(Ordering::Acquire), + }, + ); }; if self.is_retired() { - return ScreenBranchDeliveryState { - lifecycle: ScreenBranchDeliveryLifecycle::Retired, - freshness: None, - source_health: None, - last_publish_was_pressured: false, - pressure_events: self.pressure_events.load(Ordering::Acquire), - }; + return ( + None, + ScreenBranchDeliveryState { + lifecycle: ScreenBranchDeliveryLifecycle::Retired, + freshness: None, + source_health: None, + invalidation_epoch: runtime.invalidation_epoch, + last_publish_was_pressured: false, + pressure_events: self.pressure_events.load(Ordering::Acquire), + }, + ); } - ScreenBranchDeliveryState { + let delivery = ScreenBranchDeliveryState { lifecycle: ScreenBranchDeliveryLifecycle::Live, freshness: Some(publication.freshness_at(now)), source_health: ScreenPublicationHealth::decode( self.delivery_health.load(Ordering::Acquire), ), + invalidation_epoch: runtime.invalidation_epoch, last_publish_was_pressured: self.last_publish_was_pressured.load(Ordering::Acquire), pressure_events: self.pressure_events.load(Ordering::Acquire), - } + }; + (Some(publication), delivery) } fn record_pressure(&self) { @@ -1627,6 +1733,7 @@ impl ScreenCommitActivation { pub struct ScreenPublicationHub { state: Arc>, pending_retired_bytes: Arc, + next_invalidation_epoch: AtomicU64, } impl ScreenPublicationHub { @@ -1638,6 +1745,7 @@ impl ScreenPublicationHub { Arc::clone(&pending_retired_bytes), ))), pending_retired_bytes, + next_invalidation_epoch: AtomicU64::new(1), } } @@ -1765,7 +1873,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()?; @@ -1808,6 +1916,7 @@ impl ScreenPublicationHub { admitted_slots: u32::try_from(runtime.slots.len()).unwrap_or(u32::MAX), }); }; + let invalidation_epoch = runtime.invalidation_epoch; drop(runtime); if Arc::get_mut(&mut publication).is_none() { let reserved = PreparedScreenPublication { @@ -1816,6 +1925,7 @@ impl ScreenPublicationHub { slot_index, publication: Some(publication), metadata: metadata.clone(), + invalidation_epoch, }; drop(reserved); publisher.branch.record_pressure(); @@ -1833,6 +1943,7 @@ impl ScreenPublicationHub { slot_index, publication: Some(publication), metadata: metadata.clone(), + invalidation_epoch, }) } @@ -1952,6 +2063,105 @@ impl ScreenPublicationHub { Ok(()) } + /// Update every branch owned by one current worker without replacing last-good. + /// + /// # Errors + /// + /// Rejects a worker binding that no longer owns current runtime authority. + pub(crate) fn report_worker_delivery_health( + &self, + binding: &ScreenWorkerBinding, + health: ScreenPublicationHealth, + ) -> Result<(), ScreenPublicationHubError> { + let _finalization = binding.lock_finalization(); + let state = self.state.load_full(); + if !state.owns_runtime_binding(binding) { + return Err(ScreenPublicationHubError::WorkerAuthorityStale { + expected: state.plan.generation(), + observed: binding.plan_generation(), + }); + } + let entries = state + .branches + .iter() + .filter(|branch| { + branch.binding.source_id() == binding.source_id() + && branch.binding.shares_finalization_gate(binding) + }) + .map(|branch| Arc::clone(&branch.entry)) + .collect::>(); + let guards = entries + .iter() + .map(|entry| entry.lock_runtime()) + .collect::>(); + if !Arc::ptr_eq(&state, &self.state.load_full()) || !state.owns_runtime_binding(binding) { + return Err(ScreenPublicationHubError::WorkerAuthorityStale { + expected: self.state.load().plan.generation(), + observed: binding.plan_generation(), + }); + } + for entry in &entries { + entry.report_health(health); + } + drop(guards); + Ok(()) + } + + /// Clear every branch owned by one current worker under one invalidation epoch. + /// + /// Prepared publications from before the invalidation cannot finalize after + /// the operation completes. A later preparation may publish fresh output + /// under the same still-current worker authority. + /// + /// # Errors + /// + /// Rejects stale worker authority or exhausted invalidation sequence space. + pub(crate) fn invalidate_worker( + &self, + binding: &ScreenWorkerBinding, + ) -> Result { + let _finalization = binding.lock_finalization(); + let state = self.state.load_full(); + if !state.owns_runtime_binding(binding) { + return Err(ScreenPublicationHubError::WorkerAuthorityStale { + expected: state.plan.generation(), + observed: binding.plan_generation(), + }); + } + let entries = state + .branches + .iter() + .filter(|branch| { + branch.binding.source_id() == binding.source_id() + && branch.binding.shares_finalization_gate(binding) + }) + .map(|branch| Arc::clone(&branch.entry)) + .collect::>(); + let mut guards = entries + .iter() + .map(|entry| entry.lock_runtime()) + .collect::>(); + if !Arc::ptr_eq(&state, &self.state.load_full()) || !state.owns_runtime_binding(binding) { + return Err(ScreenPublicationHubError::WorkerAuthorityStale { + expected: self.state.load().plan.generation(), + observed: binding.plan_generation(), + }); + } + let epoch = self + .next_invalidation_epoch + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |epoch| { + epoch.checked_add(1) + }) + .map_err(|_| ScreenPublicationHubError::InvalidationEpochExhausted)?; + for (entry, runtime) in entries.iter().zip(guards.iter_mut()) { + entry.report_health(ScreenPublicationHealth::Failed); + entry.latest.store(None); + runtime.invalidation_epoch = epoch; + } + drop(guards); + Ok(epoch) + } + /// Acquire continuity only from an exactly live committed branch. /// /// # Errors @@ -2063,6 +2273,9 @@ fn preflight_publication( descriptor: Arc::new(prepared.branch.descriptor.clone()), }); } + if runtime.invalidation_epoch != prepared.invalidation_epoch { + return Err(ScreenPublicationHubError::PublicationInvalidated); + } validate_metadata(&prepared.branch, &prepared.binding, &prepared.metadata)?; if runtime .last_native_sequence @@ -2172,37 +2385,45 @@ impl ScreenBranchLease { /// Latest live publication, or `None` while pending or after retirement. #[must_use] pub fn read(&self) -> Option> { + self.observe(Instant::now()).0 + } + + /// Publication and delivery state from one coherent authority observation. + #[must_use] + pub fn observe( + &self, + now: Instant, + ) -> ( + Option>, + ScreenBranchDeliveryState, + ) { loop { let state = self.authority.load_full(); if !state.contains_entry(&self.branch) { - return None; + let runtime = self.branch.lock_runtime(); + return ( + None, + ScreenBranchDeliveryState { + lifecycle: ScreenBranchDeliveryLifecycle::Retired, + freshness: None, + source_health: None, + invalidation_epoch: runtime.invalidation_epoch, + last_publish_was_pressured: false, + pressure_events: self.branch.pressure_events.load(Ordering::Acquire), + }, + ); } - let publication = self.branch.read(); + let observation = self.branch.observe(now); if Arc::ptr_eq(&state, &self.authority.load_full()) { - return publication; + return observation; } } } - /// Lock-free delivery state at one caller-selected observation instant. + /// Delivery state at one caller-selected observation instant. #[must_use] pub fn delivery_state(&self, now: Instant) -> ScreenBranchDeliveryState { - loop { - let state = self.authority.load_full(); - if !state.contains_entry(&self.branch) { - return ScreenBranchDeliveryState { - lifecycle: ScreenBranchDeliveryLifecycle::Retired, - freshness: None, - source_health: None, - last_publish_was_pressured: false, - pressure_events: self.branch.pressure_events.load(Ordering::Acquire), - }; - } - let delivery = self.branch.delivery_state(now); - if Arc::ptr_eq(&state, &self.authority.load_full()) { - return delivery; - } - } + self.observe(now).1 } } @@ -2251,6 +2472,7 @@ pub struct PreparedScreenPublication { slot_index: usize, publication: Option>, metadata: ScreenPublicationMetadata, + invalidation_epoch: u64, } impl PreparedScreenPublication { @@ -2361,6 +2583,7 @@ impl fmt::Debug for PreparedScreenPublication { .field("descriptor", &self.branch.descriptor) .field("worker_plan_generation", &self.binding.plan_generation()) .field("slot_index", &self.slot_index) + .field("invalidation_epoch", &self.invalidation_epoch) .finish_non_exhaustive() } } @@ -2644,6 +2867,14 @@ pub enum ScreenPublicationHubError { /// Publisher worker generation. observed: ScreenPlanGeneration, }, + /// Worker no longer owns the current source runtime. + #[error("screen worker authority is stale: expected {expected:?}, observed {observed:?}")] + WorkerAuthorityStale { + /// Current committed plan generation. + expected: ScreenPlanGeneration, + /// Worker binding generation. + observed: ScreenPlanGeneration, + }, /// Caller substituted another opaque worker binding. #[error("worker binding does not own the requested publication branch")] WorkerBindingMismatch { @@ -2690,7 +2921,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 +2929,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}" @@ -2790,13 +3046,20 @@ pub enum ScreenPublicationHubError { /// Reserved slot no longer occupies its exact pool position. #[error("prepared publication slot reservation was lost")] PublicationReservationLost, + /// Terminal invalidation occurred after this publication was prepared. + #[error("prepared publication predates the latest terminal invalidation")] + PublicationInvalidated, /// Branch-local accepted sequence space is exhausted. #[error("screen publication branch sequence exhausted")] BranchSequenceExhausted, + /// Hub-wide terminal invalidation sequence space is exhausted. + #[error("screen publication invalidation epoch exhausted")] + InvalidationEpochExhausted, } fn validate_payload( descriptor: &ResolvedScreenPublicationDescriptor, + binding: &ScreenWorkerBinding, payload: ScreenBranchPayload<'_>, ) -> Result<(), ScreenPublicationHubError> { let expected_residency = descriptor.required_residency(); @@ -2841,6 +3104,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 +3163,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 +3243,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/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 new file mode 100644 index 000000000..9b12f50cf --- /dev/null +++ b/crates/hypercolor-core/src/input/screen/macos.rs @@ -0,0 +1,5852 @@ +use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize}; +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::{Context, anyhow}; +use hypercolor_macos_capture::{ + MacosCaptureCadence, MacosCaptureCallbackDiagnostics, + MacosCaptureCapabilities as NativeCaptureCapabilities, MacosCaptureContentStyle, + MacosCaptureDynamicRange, MacosCaptureFrame, MacosCapturePixelFormat, MacosCaptureSelection, + MacosColorPrimaries, MacosCpuSourceView, MacosDisplayClock, MacosFrameDropReason, + MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosHostArchitecture as NativeHostArchitecture, + MacosProtectedSourceState as NativeProtectedSourceState, MacosStreamRequest, + MacosTahoeSelectionCapabilities as NativeTahoeSelectionCapabilities, MacosTransferFunction, +}; +#[cfg(feature = "macos-capture-fixtures")] +use hypercolor_macos_capture::{MacosRuntimeCapability, MacosTahoeRuntimeProbes}; +use tokio::sync::oneshot; + +#[cfg(target_os = "macos")] +use hypercolor_macos_capture::{ + MacosCaptureSelector, MacosScreenCaptureSession, MacosScreenshotReferenceCapture, +}; + +use super::{ + AdmittedScreenNativeTargetPreparation, BoundScreenNativeTargetPreparation, CaptureCadence, + CaptureColorSpace, CaptureColorimetry, CaptureConfig, CaptureCursor, CaptureCursorContent, + CaptureDamage, CaptureDynamicRange, CaptureEpoch, CaptureFrame, CaptureFrameMetadata, + CaptureLuminanceContext, CapturePixelFormat, CapturePlanePool, CapturePositiveScalar, + CaptureRotation, CaptureSourceId, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, + CpuExactReductionWorkPlan, CpuPublicationFanoutError, CpuReductionExecutor, CpuSamplingError, + CpuScalarSource, KnownCaptureColorimetry, LedToneMapCalibration, PixelExtent, PixelRect, + PlatformGpuApi, PlatformGpuSurface, PlatformGpuSurfaceTimingSink, PreparedCpuPublicationFanout, + PreparedCpuPublicationFanoutCandidate, PreparedLedToneMap, RawCaptureSurface, + RegisteredScreenBranchDemand, ResolvedScreenBranchDemand, ResolvedScreenPublicationDescriptor, + ResolvedScreenSource, ResolvedScreenSourceConfig, ScreenAnalysisComputeCapacity, + ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, ScreenBackendResourceIdentity, + ScreenBranchPayload, ScreenBranchPublisher, ScreenByteAdmissionCoordinator, + ScreenCaptureBackend, ScreenCaptureCadence, ScreenCaptureDemand, ScreenCaptureInput, + ScreenComputeCapacityPolicy, ScreenCursorCapabilities, ScreenCursorPolicy, + 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, +}; +use crate::input::status::SourceSessionSlot; +#[cfg(target_os = "macos")] +use crate::input::traits::MacosScreenshotReferenceAction; +use crate::input::traits::{ + InputData, InputSource, ProtectedSourceAuthorizationAction, ScreenSourcePickerAction, +}; +use crate::input::{ + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosProtectedSourceState, + MacosScreenPlatformStatus, MacosScreenTimingStatus, MacosSelectionState, + MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, MacosTimingStatus, SourceKind, + SourcePlatformStatus, SourceStatusHandle, SourceStatusReporter, +}; + +#[cfg(target_os = "macos")] +mod surface_pool; + +#[cfg(target_os = "macos")] +use surface_pool::MacosSurfacePool; + +const WORKER_WAIT: Duration = Duration::from_millis(100); + +// BT.2408 diffuse white; identical to the target LED calibration default so +// unsignalled HDR content maps through the tone map at unity. +const DEFAULT_HDR_SOURCE_REFERENCE_WHITE_NITS: f32 = 203.0; +// One stop of assumed highlight headroom for HDR frames whose surfaces carry +// no IOSurfaceContentHeadroom, mirroring the stop the tone map reserves on +// the output side. +const DEFAULT_HDR_SOURCE_CONTENT_HEADROOM: f32 = 2.0; + +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; +const TIMING_BUCKET_WIDTH_NS: u64 = 100_000; +const TIMING_BUCKET_COUNT: usize = 4096; + +#[derive(Debug, Default)] +struct MacosScreenRuntimeTelemetry { + publication_path: AtomicU8, + fallback_reason: Mutex>>, + publication_plan_generation: AtomicU64, + stale_frames: AtomicU64, + cpu_reduction_timing: AtomicTimingHistogram, + native_import_timing: AtomicTimingHistogram, + native_reduction_submit_timing: AtomicTimingHistogram, + capture_to_native_publication_timing: AtomicTimingHistogram, + capture_to_converted_publication_timing: AtomicTimingHistogram, + admitted_native_bytes: AtomicU64, + pinned_generations: AtomicUsize, +} + +#[derive(Debug)] +struct AtomicTimingHistogram { + buckets: Box<[AtomicU64]>, + generation: AtomicU64, + sample_count: AtomicU64, + total_ns: AtomicU64, + max_ns: AtomicU64, +} + +impl Default for AtomicTimingHistogram { + fn default() -> Self { + Self { + buckets: (0..=TIMING_BUCKET_COUNT) + .map(|_| AtomicU64::new(0)) + .collect(), + generation: AtomicU64::new(0), + sample_count: AtomicU64::new(0), + total_ns: AtomicU64::new(0), + max_ns: AtomicU64::new(0), + } + } +} + +impl AtomicTimingHistogram { + fn record(&self, elapsed: Duration) { + self.record_with_hook(elapsed, || {}); + } + + fn record_with_hook(&self, elapsed: Duration, before_complete: impl FnOnce()) { + let generation = self.begin_write(); + let nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let bucket = usize::try_from(nanos / TIMING_BUCKET_WIDTH_NS) + .unwrap_or(usize::MAX) + .min(TIMING_BUCKET_COUNT); + self.buckets[bucket].fetch_add(1, Ordering::Relaxed); + let _ = self + .total_ns + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| { + Some(total.saturating_add(nanos)) + }); + self.max_ns.fetch_max(nanos, Ordering::Relaxed); + before_complete(); + self.sample_count.fetch_add(1, Ordering::Relaxed); + self.generation + .store(generation.wrapping_add(1), Ordering::Release); + } + + fn begin_write(&self) -> u64 { + let mut generation = self.generation.load(Ordering::Relaxed); + loop { + if generation & 1 == 1 { + std::hint::spin_loop(); + generation = self.generation.load(Ordering::Acquire); + continue; + } + let started = generation.wrapping_add(1); + match self.generation.compare_exchange_weak( + generation, + started, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return started, + Err(observed) => generation = observed, + } + } + } + + fn percentile_upper_bound_ns(&self, percentile: u64, sample_count: u64, maximum: u64) -> u64 { + if sample_count == 0 { + return 0; + } + let rank = sample_count.saturating_mul(percentile).saturating_add(99) / 100; + let mut observed = 0_u64; + for (index, count) in self.buckets.iter().enumerate() { + observed = observed.saturating_add(count.load(Ordering::Relaxed)); + if observed >= rank { + if index == TIMING_BUCKET_COUNT { + return maximum; + } + return u64::try_from(index.saturating_add(1)) + .unwrap_or(u64::MAX) + .saturating_mul(TIMING_BUCKET_WIDTH_NS) + .min(maximum); + } + } + maximum + } + + fn snapshot(&self) -> MacosTimingStatus { + self.snapshot_with_hooks(|| {}, || {}) + } + + fn snapshot_with_hooks( + &self, + mut retrying: impl FnMut(), + mut after_p95: impl FnMut(), + ) -> MacosTimingStatus { + loop { + let generation = self.generation.load(Ordering::Acquire); + if generation & 1 == 1 { + retrying(); + std::hint::spin_loop(); + continue; + } + let sample_count = self.sample_count.load(Ordering::Relaxed); + let total_ns = self.total_ns.load(Ordering::Relaxed); + let max_ns = self.max_ns.load(Ordering::Relaxed); + let p95_ns = self.percentile_upper_bound_ns(95, sample_count, max_ns); + after_p95(); + let p99_ns = self.percentile_upper_bound_ns(99, sample_count, max_ns); + std::sync::atomic::fence(Ordering::Acquire); + if self.generation.load(Ordering::Relaxed) == generation { + return MacosTimingStatus { + sample_count, + total_ns, + max_ns, + p95_ns, + p99_ns, + }; + } + retrying(); + } + } +} + +impl PlatformGpuSurfaceTimingSink for MacosScreenRuntimeTelemetry { + fn record_import(&self, elapsed: Duration) { + self.native_import_timing.record(elapsed); + } + + fn record_native_reduction_submission(&self, elapsed: Duration) { + self.native_reduction_submit_timing.record(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) { + self.cpu_reduction_timing.record(elapsed); + } + + fn record_native_publication(&self, captured_at: Instant) { + self.capture_to_native_publication_timing + .record(Instant::now().saturating_duration_since(captured_at)); + } + + fn record_converted_publication(&self, captured_at: Instant) { + self.capture_to_converted_publication_timing + .record(Instant::now().saturating_duration_since(captured_at)); + } +} + +/// 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); + fn present_picker(&self) -> anyhow::Result<()>; + fn request_authorization(&self) -> NativeProtectedSourceState; + fn status(&self) -> NativeProtectedSourceState; + fn selection(&self) -> MacosCaptureSelection; + fn selection_revision(&self) -> u64; + fn begin_stream_request(&self, request: MacosStreamRequest) -> anyhow::Result; + 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")] + fn capture_screenshot_reference( + &self, + ) -> anyhow::Result< + mpsc::Receiver< + Result, + >, + > { + anyhow::bail!("macOS screenshot references are unavailable for this capture control") + } +} + +struct StreamRequest { + generation: u64, + completion: Box anyhow::Result<()> + Send>, +} + +impl StreamRequest { + #[cfg(feature = "macos-capture-fixtures")] + fn completed(generation: u64, result: anyhow::Result<()>) -> Self { + Self { + generation, + completion: Box::new(|| result), + } + } + + fn wait(self) -> anyhow::Result<()> { + (self.completion)().with_context(|| { + format!( + "macOS stream request generation {} did not commit", + self.generation + ) + }) + } +} + +#[cfg(target_os = "macos")] +struct NativeCaptureControl { + session: MacosScreenCaptureSession, + clock: MacosDisplayClock, + host_capabilities: NativeCaptureCapabilities, +} + +#[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) -> NativeProtectedSourceState { + self.session.request_authorization() + } + + fn status(&self) -> NativeProtectedSourceState { + self.session.status() + } + + fn selection(&self) -> MacosCaptureSelection { + self.session.selection() + } + + fn selection_revision(&self) -> u64 { + self.session.selection_revision() + } + + fn begin_stream_request(&self, request: MacosStreamRequest) -> anyhow::Result { + let transaction = self.session.begin_stream_request(request)?; + let generation = transaction.generation(); + Ok(StreamRequest { + generation, + completion: Box::new(move || transaction.wait().map_err(anyhow::Error::from)), + }) + } + + fn tahoe_selection_capabilities(&self) -> Option { + self.session.tahoe_selection_capabilities() + } + + fn host_capabilities(&self) -> NativeCaptureCapabilities { + self.host_capabilities + } + + fn authorization(&self) -> MacosAuthorizationState { + if MacosScreenCaptureSession::screen_authorized() { + MacosAuthorizationState::Authorized + } else if self.session.status() == NativeProtectedSourceState::PermissionDenied { + MacosAuthorizationState::Denied + } else { + MacosAuthorizationState::NotDetermined + } + } + + fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + self.session.diagnostics() + } + + fn captured_at(&self, display_time: u64) -> anyhow::Result { + self.clock + .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)] +struct MacosPublication { + worker_generation: u64, + 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, + display_scale_bits: 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)?, + 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, + }) + } + + 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, + capture_pixel_format(self.pixel_format), + self.colorimetry, + 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 = capture_pixel_format(self.pixel_format); + 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>>, + compute_capacity_policy: ScreenComputeCapacityPolicy, + resolution_revision: AtomicU64, +} + +impl MacosExactPublicationShared { + fn with_compute_capacity_policy(policy: ScreenComputeCapacityPolicy) -> Self { + Self { + compute_capacity_policy: policy, + ..Self::default() + } + } + + 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; + } + tracing::debug!( + shared = ?std::ptr::from_ref(self), + installed = next.is_some(), + "macOS exact publication source changed" + ); + *source = next; + self.advance_resolution_revision(); + } + + 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>>, + }, + ReconfigureProcessing { + calibration: LedToneMapCalibration, + completion: mpsc::SyncSender>, + }, +} + +struct PreparedWorker { + analyzer: ScreenCaptureInput, + plane_pool: CapturePlanePool, + target_fps: u32, +} + +struct CaptureWorker { + stop: Arc, + mailbox: MacosFrameMailbox, + command_tx: mpsc::Sender, + exit_rx: mpsc::Receiver>, + join: Option>, +} + +struct StagedCaptureWorker { + generation: u64, + worker: Option, + start: Arc, +} + +impl StagedCaptureWorker { + fn commit(mut self) -> CaptureWorker { + self.worker + .take() + .expect("staged capture worker commits exactly once") + } +} + +impl Drop for StagedCaptureWorker { + fn drop(&mut self) { + let Some(mut worker) = self.worker.take() else { + return; + }; + worker.stop.store(true, Ordering::Release); + self.start.store(true, Ordering::Release); + if let Some(join) = worker.join.take() { + join.thread().unpark(); + let _ = join.join(); + } + } +} + +fn production_stream_request( + config: &CaptureConfig, + demand: ScreenCaptureDemand, + capabilities: NativeCaptureCapabilities, +) -> anyhow::Result { + let cadence = demand + .cadence() + .unwrap_or(ScreenCaptureCadence::Configured) + .resolve(config.acquisition_cadence); + let cadence = match cadence { + ScreenCaptureCadence::Configured => MacosCaptureCadence::FramesPerSecond(config.target_fps), + ScreenCaptureCadence::NativeRefresh => MacosCaptureCadence::NativeRefresh, + ScreenCaptureCadence::FramesPerSecond(frames_per_second) => { + MacosCaptureCadence::FramesPerSecond(frames_per_second.get()) + } + }; + let cursor_composed = matches!(demand.cursor(), Some(ScreenCursorPolicy::Include)); + Ok(MacosStreamRequest::for_capabilities( + cadence, + cursor_composed, + capabilities, + )?) +} + +pub struct MacosScreenCaptureInput { + config: CaptureConfig, + control: Arc, + admission: ScreenByteAdmissionCoordinator, + compute_capacity_policy: ScreenComputeCapacityPolicy, + publication: Arc>, + exact: Arc, + telemetry: Arc, + worker: Option, + worker_generation: u64, + demand: ScreenCaptureDemand, + running: bool, + status: SourceStatusReporter, + status_session: SourceSessionSlot, + owner: MacosCapabilityOwner, + owner_conflict: Option>, + owner_designated_requirement_hash: Option>, + authorization: MacosAuthorizationState, + authorization_last_transition_at: Option, + metal4: bool, +} + +impl MacosScreenCaptureInput { + #[cfg(target_os = "macos")] + pub fn new( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + ) -> anyhow::Result { + Self::with_admission_and_compute_capacity( + config, + admission, + ScreenComputeCapacityPolicy::UNBOUNDED, + ) + } + + /// Create a production source with shared memory and calibrated CPU fences. + #[cfg(target_os = "macos")] + pub fn with_admission_and_compute_capacity( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + compute_capacity_policy: ScreenComputeCapacityPolicy, + ) -> anyhow::Result { + let selector = MacosCaptureSelector::parse(&config.source)?; + let host_capabilities = MacosScreenCaptureSession::capabilities()?; + let request = + production_stream_request(&config, ScreenCaptureDemand::Inactive, host_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 = MacosSurfacePool::reserve( + &pool_coordinator, + Arc::clone(&pool_telemetry), + conservative_surface_bytes, + native_metadata_bytes, + )?; + Ok(move |iosurface_id, allocation_bytes| { + let token = pool.observe(iosurface_id, allocation_bytes)?; + Ok(token as Arc) + }) + }, + )?; + let clock = MacosDisplayClock::system()?; + Ok(Self::with_control_and_telemetry( + config, + admission, + compute_capacity_policy, + Arc::new(NativeCaptureControl { + session, + clock, + host_capabilities, + }), + telemetry, + )) + } + + fn with_control_and_telemetry( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + compute_capacity_policy: ScreenComputeCapacityPolicy, + control: Arc, + telemetry: Arc, + ) -> Self { + let consented = control.authorization() == MacosAuthorizationState::Authorized; + let authorization = control.authorization(); + let mut source = Self { + config, + control, + admission, + compute_capacity_policy, + publication: Arc::new(Mutex::new(MacosPublication::default())), + exact: Arc::new(MacosExactPublicationShared::with_compute_capacity_policy( + compute_capacity_policy, + )), + telemetry, + 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(), + owner: MacosCapabilityOwner::Standalone, + owner_conflict: None, + owner_designated_requirement_hash: None, + authorization, + authorization_last_transition_at: None, + metal4: false, + }; + source + .refresh_platform_status() + .expect("new macOS screen status is not retired"); + source + } + + 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(&mut self) -> anyhow::Result<()> { + let result = self.control.present_picker(); + self.refresh_platform_status()?; + result + } + + 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(); + 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(); + let timing = MacosScreenTimingStatus { + callback: timing_status( + diagnostics.callback_sample_count, + diagnostics.callback_total_ns, + diagnostics.callback_max_ns, + diagnostics.callback_p95_ns, + diagnostics.callback_p99_ns, + ), + retain: timing_status( + diagnostics.retain_sample_count, + diagnostics.retain_total_ns, + diagnostics.retain_max_ns, + diagnostics.retain_p95_ns, + diagnostics.retain_p99_ns, + ), + enqueue: timing_status( + diagnostics.enqueue_sample_count, + diagnostics.enqueue_total_ns, + diagnostics.enqueue_max_ns, + diagnostics.enqueue_p95_ns, + diagnostics.enqueue_p99_ns, + ), + conversion: timing_status( + diagnostics.conversion_sample_count, + diagnostics.conversion_total_ns, + diagnostics.conversion_max_ns, + diagnostics.conversion_p95_ns, + diagnostics.conversion_p99_ns, + ), + cpu_reduction: self.telemetry.cpu_reduction_timing.snapshot(), + native_import: self.telemetry.native_import_timing.snapshot(), + native_reduction_submit: self.telemetry.native_reduction_submit_timing.snapshot(), + publication: timing_status( + diagnostics.publication_sample_count, + diagnostics.publication_total_ns, + diagnostics.publication_max_ns, + diagnostics.publication_p95_ns, + diagnostics.publication_p99_ns, + ), + capture_to_native_publication: self + .telemetry + .capture_to_native_publication_timing + .snapshot(), + capture_to_converted_publication: self + .telemetry + .capture_to_converted_publication_timing + .snapshot(), + }; + self.status + .set_platform(Some(SourcePlatformStatus::MacosScreen( + MacosScreenPlatformStatus { + state: map_protected_state(state), + tcc: authorization, + owner: self.owner, + selection: map_selection(self.control.selection()), + selection_diagnostic_label: selection_diagnostic_label( + self.control.selection(), + ), + selection_revision: self.control.selection_revision(), + 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(), + timing, + callback_total_ns: timing.callback.total_ns, + callback_max_ns: timing.callback.max_ns, + retain_total_ns: timing.retain.total_ns, + retain_max_ns: timing.retain.max_ns, + conversion_total_ns: timing.conversion.total_ns, + conversion_max_ns: timing.conversion.max_ns, + cpu_reduction_total_ns: timing.cpu_reduction.total_ns, + cpu_reduction_max_ns: timing.cpu_reduction.max_ns, + native_import_total_ns: timing.native_import.total_ns, + native_import_max_ns: timing.native_import.max_ns, + native_reduction_submit_total_ns: timing.native_reduction_submit.total_ns, + native_reduction_submit_max_ns: timing.native_reduction_submit.max_ns, + publication_total_ns: timing.publication.total_ns, + publication_max_ns: timing.publication.max_ns, + }, + )))?; + 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 = self.control.authorization() == MacosAuthorizationState::Authorized; + self.status + .set_policy(true, consented, demand.is_active())?; + Ok(()) + } + + fn prepare_worker(&self, extent: PixelExtent) -> anyhow::Result { + let mut analyzer = match self.compute_capacity_policy.analysis() { + Some(capacity) => { + ScreenCaptureInput::with_requested_extent_admission_and_compute_capacity( + self.config.clone(), + extent, + self.admission.clone(), + capacity, + )? + } + None => 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 stage_worker(&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 worker_mailbox = mailbox.clone(); + 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)); + 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 (command_tx, command_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, + exact, + telemetry, + worker_generation, + target_fps, + status_session, + worker_stop, + control, + command_rx, + ) + }; + let _ = exit_tx.send(result); + })?; + Ok(StagedCaptureWorker { + generation: worker_generation, + worker: Some(CaptureWorker { + stop, + mailbox: worker_mailbox, + command_tx, + exit_rx, + join: Some(join), + }), + start, + }) + } + + fn install_worker(&mut self, staged: StagedCaptureWorker) { + let generation = staged.generation; + let start = Arc::clone(&staged.start); + let worker = staged.commit(); + let previous_latest = lock(&self.publication).latest.clone(); + self.stop_worker(); + self.worker_generation = generation; + { + let mut publication = lock(&self.publication); + publication.worker_generation = generation; + publication.latest = previous_latest; + } + self.worker = Some(worker); + 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(); + } + + fn stop_worker(&mut self) { + let Some(mut worker) = self.worker.take() else { + return; + }; + worker.stop.store(true, Ordering::Release); + worker.mailbox.wake(); + if let Some(join) = worker.join.take() { + let _ = join.join(); + } + lock(&self.publication).latest = None; + self.exact.replace_source(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 set_macos_daemon_ownership( + &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() + } + + 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(()); + } + self.refresh_policy()?; + if let Some(extent) = self.demand.requested_extent() { + let prepared = self.stage_worker(self.prepare_worker(extent)?)?; + let session = self.status.begin_session()?; + self.install_worker(prepared); + if let Some(session) = session { + self.status_session.store(session); + } + 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(); + self.demand = ScreenCaptureDemand::Inactive; + self.running = false; + } + + 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); + } + 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.refresh_platform_status()?; + 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 { + self.compute_capacity_policy.analysis() + } + + fn set_screen_capture_demand(&mut self, demand: ScreenCaptureDemand) -> anyhow::Result<()> { + let was_active = self.demand.is_active(); + if !demand.is_active() { + self.refresh_policy_for(demand)?; + if self.running { + self.control.set_active(false); + self.status_session.clear(); + self.stop_worker(); + } + self.demand = demand; + self.refresh_platform_status()?; + return Ok(()); + } + let request = + production_stream_request(&self.config, demand, self.control.host_capabilities())?; + let prepared = demand + .requested_extent() + .map(|extent| self.prepare_worker(extent)) + .transpose()? + .map(|prepared| self.stage_worker(prepared)) + .transpose()?; + if !self.running { + self.control.begin_stream_request(request)?.wait()?; + self.refresh_policy_for(demand)?; + self.demand = demand; + return Ok(()); + } + let request = self.control.begin_stream_request(request)?; + 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) = request.wait() { + if !was_active { + self.refresh_policy_for(self.demand)?; + } + return Err(error); + } + self.install_worker(prepared); + if let Some(session) = session { + self.status_session.store(session); + } + self.control.set_active(true); + } else { + request.wait()?; + } + self.demand = demand; + self.refresh_platform_status()?; + 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 { + tracing::debug!( + shared = ?std::ptr::from_ref(self.exact.as_ref()), + "exact branch unresolvable: no publication source installed" + ); + return Ok(None); + }; + 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_with_telemetry(&source, &calibrated, &self.telemetry) + } + + 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<()> { + if !self.demand.is_active() { + self.config.clone_from(config); + return Ok(()); + } + let request = + production_stream_request(config, self.demand, self.control.host_capabilities())?; + let prepared = self + .demand + .requested_extent() + .map(|extent| { + let mut analyzer = match self.compute_capacity_policy.analysis() { + Some(capacity) => { + ScreenCaptureInput::with_requested_extent_admission_and_compute_capacity( + config.clone(), + extent, + self.admission.clone(), + capacity, + )? + } + None => 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()? + .map(|prepared| self.stage_worker(prepared)) + .transpose()?; + let request = self.control.begin_stream_request(request)?; + request.wait()?; + if self.running + && let Some(prepared) = prepared + { + self.install_worker(prepared); + } + self.config.clone_from(config); + 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(()); + } + if let Some(worker) = self.worker.as_ref() { + let (completion_tx, completion_rx) = mpsc::sync_channel(1); + worker + .command_tx + .send(WorkerCommand::ReconfigureProcessing { + calibration: next, + completion: completion_tx, + }) + .map_err(|_| anyhow!("macOS capture worker rejected processing reconfiguration"))?; + worker.mailbox.wake(); + completion_rx.recv().map_err(|_| { + anyhow!("macOS capture worker exited during processing reconfiguration") + })??; + } + self.config.target_led_white_x = next.target_white_x(); + self.config.target_led_white_y = next.target_white_y(); + self.config.target_led_reference_white_nits = next.target_reference_white_nits(); + self.config.target_led_peak_nits = next.target_peak_nits(); + self.config.exposure_ev = next.exposure_ev(); + self.exact.advance_resolution_revision(); + Ok(()) + } + + fn reselect_screen_source(&mut self) -> anyhow::Result<()> { + self.present_picker() + } + + fn screen_authorization_action(&self) -> Option { + let control = Arc::clone(&self.control); + 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(ScreenSourcePickerAction::current_macos_process(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())) + } +} + +#[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) { + return Ok(None); + } + let selector = selector.clone(); + let capabilities = CpuReductionExecutor::supported_color_capabilities(); + if matches!( + demand.request().executor(), + ScreenPublicationExecutorRequest::Cpu + ) { + telemetry.set_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 { + 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()), + ) { + 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( + &source.cpu_source(selector), + capabilities, + )?)) +} + +fn macos_native_descriptor_is_identity(descriptor: &ResolvedScreenPublicationDescriptor) -> bool { + 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() + && descriptor.physical().target_pixel_format() == descriptor.source_pixel_format() + && matches!( + descriptor.physical().color_pipeline().transform(), + super::ResolvedScreenColorTransform::PreserveEncodedSamples + ) +} + +impl Drop for MacosScreenCaptureInput { + fn drop(&mut self) { + self.control.set_active(false); + self.stop_worker(); + } +} + +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 executor = exact.cpu_executor()?; + let compute_plan = + CpuExactReductionWorkPlan::try_for_source(&candidate, ticket.source_id(), |_| true)?; + let compute_plan = match exact.compute_capacity_policy.exact(executor.worker_count()) { + Some(capacity) => compute_plan.admit(capacity)?, + None => compute_plan, + }; + 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 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)?; + 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 shared_resource_name = pending.target.shared_resource_name().cloned(); + 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"))?; + 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_with_shared(lifetime, shared_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, + 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 Some(current_index) = runtimes + .iter_mut() + .position(|runtime| runtime.source == *source && runtime.binding.is_same(current_binding)) + else { + return Ok(None); + }; + 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_worker_commands( + command_rx: &mpsc::Receiver, + prepared: &mut PreparedWorker, + 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(())); + } + } + WorkerCommand::ReconfigureProcessing { + calibration, + completion, + } => { + prepared.analyzer.set_led_tone_map_calibration(calibration); + let _ = completion.send(Ok(())); + } + } + } +} + +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 with_current_macos_worker_authority( + exact: &MacosExactPublicationShared, + runtimes: &[MacosExactRuntime], + operation: impl FnOnce( + &ScreenPublicationHub, + &ScreenWorkerBinding, + ) -> Result, +) -> anyhow::Result> { + let Some(hub) = exact.hub() else { + return Ok(None); + }; + let authority = hub.committed_state(); + let Some(binding) = runtimes + .iter() + .map(|runtime| &runtime.binding) + .find(|binding| authority.owns_runtime_binding(binding)) + else { + return Ok(None); + }; + match operation(&hub, binding) { + Ok(value) => Ok(Some(value)), + Err(ScreenPublicationHubError::WorkerAuthorityStale { .. }) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn report_macos_worker_health( + exact: &MacosExactPublicationShared, + runtimes: &[MacosExactRuntime], + health: ScreenPublicationHealth, +) -> anyhow::Result<()> { + with_current_macos_worker_authority(exact, runtimes, |hub, binding| { + hub.report_worker_delivery_health(binding, health) + })?; + Ok(()) +} + +fn invalidate_macos_worker( + exact: &MacosExactPublicationShared, + runtimes: &[MacosExactRuntime], +) -> anyhow::Result<()> { + with_current_macos_worker_authority(exact, runtimes, ScreenPublicationHub::invalidate_worker)?; + Ok(()) +} + +fn synchronize_macos_invalidation_generation( + observed: &mut u64, + delivered: u64, + publication: &Arc>, + exact: &MacosExactPublicationShared, + runtimes: &[MacosExactRuntime], +) -> anyhow::Result { + if delivered < *observed { + return Ok(false); + } + if delivered > *observed { + lock(publication).latest = None; + invalidate_macos_worker(exact, runtimes)?; + *observed = delivered; + } + Ok(true) +} + +fn run_worker( + mut prepared: PreparedWorker, + mailbox: MacosFrameMailbox, + publication: Arc>, + exact: Arc, + telemetry: Arc, + worker_generation: u64, + target_fps: u32, + status_session: SourceSessionSlot, + stop: Arc, + control: Arc, + command_rx: mpsc::Receiver, +) -> anyhow::Result<()> { + let mut topology = TopologyState::default(); + let mut resources = ResourceState::default(); + let mut exact_runtimes = Vec::new(); + let mut invalidation_generation = 0; + let result: anyhow::Result<()> = (|| { + while !stop.load(Ordering::Acquire) { + handle_worker_commands(&command_rx, &mut prepared, &mut exact_runtimes, &exact); + update_pinned_generations(&exact_runtimes, &telemetry); + let Some((_, delivery_invalidation_generation, delivery)) = mailbox + .wait_latest_with_generation_while(WORKER_WAIT, || !stop.load(Ordering::Acquire)) + else { + continue; + }; + if !synchronize_macos_invalidation_generation( + &mut invalidation_generation, + delivery_invalidation_generation, + &publication, + &exact, + &exact_runtimes, + )? { + continue; + } + match delivery { + Ok(MacosFrameEvent::Frame(frame)) => { + publish_frame( + &mut prepared, + Arc::from(frame), + capture_source_id(control.selection())?, + &mut topology, + &mut resources, + &publication, + &exact, + &telemetry, + &mut exact_runtimes, + worker_generation, + target_fps, + &status_session, + &control, + )?; + } + Ok(MacosFrameEvent::Lifecycle( + MacosFrameStatus::Suspended | MacosFrameStatus::Stopped, + )) + | Err(_) => {} + Ok(MacosFrameEvent::RecoverableError(_)) => { + report_macos_worker_health( + &exact, + &exact_runtimes, + ScreenPublicationHealth::Recovering, + )?; + } + Ok(MacosFrameEvent::Lifecycle(_)) => {} + } + } + Ok(()) + })(); + let invalidation = invalidate_macos_worker(&exact, &exact_runtimes); + exact.replace_source(None); + exact.clear_owned_sources(); + exact_runtimes.clear(); + telemetry.pinned_generations.store(0, Ordering::Release); + prepared.analyzer.stop(); + result?; + invalidation +} + +#[allow(clippy::too_many_arguments)] +fn publish_frame( + prepared: &mut PreparedWorker, + frame: Arc, + source_id: CaptureSourceId, + topology: &mut TopologyState, + resources: &mut ResourceState, + publication: &Mutex, + exact: &MacosExactPublicationShared, + telemetry: &Arc, + exact_runtimes: &mut [MacosExactRuntime], + worker_generation: u64, + target_fps: u32, + status_session: &SourceSessionSlot, + control: &Arc, +) -> anyhow::Result<()> { + 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"))?; + 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( + source_id.clone(), + topology_generation, + resource_generation, + &frame, + )?; + exact.replace_source(Some(source.clone())); + 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 capture = + native_cpu_capture_frame(&frame, captured_at, fresh_until, &source, source_id.clone())?; + 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)?; + } + if !needs_legacy_cpu_publication(exact_delivery) { + 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 { + publication.latest = None; + } + return Ok(()); + } + let capture = legacy_cpu_capture_frame( + prepared, + &frame, + captured_at, + fresh_until, + &source, + source_id, + topology_generation, + )?; + if Instant::now() > fresh_until { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + if frame.pixel_format == MacosCapturePixelFormat::Bgra8 { + 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(()); + } + 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())); + 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 { + return Ok(()); + } + publication.latest = Some(data); + } + telemetry.record_converted_publication(captured_at); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn legacy_cpu_capture_frame( + prepared: &mut PreparedWorker, + frame: &MacosCaptureFrame, + captured_at: Instant, + fresh_until: Instant, + source: &MacosPublicationSource, + source_id: CaptureSourceId, + topology_generation: u64, +) -> anyhow::Result> { + let extent = source.geometry.storage_extent(); + 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); + let (pixel_format, colorimetry) = if frame.pixel_format == MacosCapturePixelFormat::Bgra8 { + frame.copy_bgra8_to(&mut plane, row_stride)?; + (CapturePixelFormat::Bgra8, source.colorimetry) + } else { + let calibration = LedToneMapCalibration::try_new( + prepared.analyzer.config().target_led_white_x, + prepared.analyzer.config().target_led_white_y, + prepared.analyzer.config().target_led_reference_white_nits, + prepared.analyzer.config().target_led_peak_nits, + prepared.analyzer.config().exposure_ev, + )?; + let tone_map = PreparedLedToneMap::prepare( + source.colorimetry.try_known()?, + KnownCaptureColorimetry::SRGB, + calibration, + )?; + frame.with_cpu_source(|samples| -> anyhow::Result<()> { + for y in 0..extent.height() { + let row_start = usize::try_from(y)? + .checked_mul(row_stride) + .ok_or_else(|| anyhow!("macOS legacy row offset overflow"))?; + for x in 0..extent.width() { + let pixel_start = usize::try_from(x)? + .checked_mul(4) + .and_then(|offset| row_start.checked_add(offset)) + .ok_or_else(|| anyhow!("macOS legacy pixel offset overflow"))?; + let pixel_end = pixel_start + .checked_add(4) + .ok_or_else(|| anyhow!("macOS legacy pixel end overflow"))?; + let source_pixel = samples.sample_rgba32f(x, y)?; + plane[pixel_start..pixel_end].copy_from_slice( + &tone_map.encode(tone_map.decode_and_map_source(source_pixel)), + ); + } + } + Ok(()) + })??; + (CapturePixelFormat::Rgba8, CaptureColorimetry::SRGB) + }; + let sequence = frame + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; + Ok(CaptureFrame::::new( + CaptureFrameMetadata { + source_id, + topology_generation, + session_generation: frame.epoch, + sequence, + captured_at, + fresh_until, + geometry: source.geometry, + 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::Cpu(CpuCaptureStorage::from_owner( + plane.freeze(), + pixel_format, + i64::try_from(row_stride)?, + 0, + )), + 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(), + ), + )?) +} + +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, + cpu: bool, + stale: bool, +} + +const fn needs_legacy_cpu_publication(delivery: MacosExactDelivery) -> bool { + !delivery.native && !delivery.cpu +} + +#[cfg(all(test, feature = "macos-capture-fixtures"))] +fn publish_macos_native_exact( + frame: &Arc, + captured_at: Instant, + fresh_until: Instant, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], +) -> anyhow::Result<(MacosExactDelivery, Arc)> { + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let delivery = publish_macos_native_exact_with_telemetry( + frame, + captured_at, + fresh_until, + source, + exact, + runtimes, + &telemetry, + )?; + Ok((delivery, 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()); + }; + let Some(runtime) = bind_current_macos_exact_runtime(runtimes, source, &hub, captured_at)? + else { + return Ok(MacosExactDelivery::default()); + }; + 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 { + telemetry.stale_frames.fetch_add(1, Ordering::Relaxed); + return Ok(MacosExactDelivery { + stale: true, + ..delivery + }); + } + let native_sequence = frame + .sequence + .checked_add(1) + .and_then(NonZeroU64::new) + .ok_or_else(|| anyhow!("macOS capture sequence exhausted"))?; + let mut native_published = false; + 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), + 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())?; + let metadata = ScreenPublicationMetadata::try_new( + source.epoch.clone(), + publisher.plan_generation(), + native_sequence, + captured_at, + published_at, + fresh_until, + ScreenPublicationHealth::Healthy, + )?; + 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(_) => { + native_published = true; + telemetry + .publication_plan_generation + .store(publisher.plan_generation().get(), Ordering::Release); + 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()), + } + } + if native_published { + telemetry.record_native_publication(captured_at); + } + Ok(delivery) +} + +fn publish_macos_cpu_exact( + frame: &CaptureFrame, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + telemetry: &MacosScreenRuntimeTelemetry, +) -> 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() { + telemetry + .publication_plan_generation + .store(fanout.plan_generation().get(), Ordering::Release); + let report = fanout.publish_due( + &hub, + Some(frame), + Instant::now(), + ScreenPublicationHealth::Healthy, + )?; + if report.published() > 0 { + telemetry.record_converted_publication(frame.metadata().captured_at); + } + } + Ok(()) +} + +fn publish_macos_scalar_exact( + native_frame: &MacosCaptureFrame, + frame: &CaptureFrame, + source: &MacosPublicationSource, + exact: &MacosExactPublicationShared, + runtimes: &mut [MacosExactRuntime], + telemetry: &MacosScreenRuntimeTelemetry, +) -> 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() { + let reduction_started = Instant::now(); + telemetry + .publication_plan_generation + .store(fanout.plan_generation().get(), Ordering::Release); + let report = 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()) + })? + }, + )?; + if report.published() > 0 { + telemetry.record_cpu_reduction(reduction_started.elapsed()); + telemetry.record_converted_publication(frame.metadata().captured_at); + } + } + 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(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, + 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_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(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 = match color.transfer { + MacosTransferFunction::Srgb => CaptureTransferFunction::Srgb, + MacosTransferFunction::Rec709 => CaptureTransferFunction::Rec709, + MacosTransferFunction::Rec2020 => CaptureTransferFunction::Rec2020, + MacosTransferFunction::Linear => CaptureTransferFunction::Linear, + MacosTransferFunction::Pq => CaptureTransferFunction::Pq, + MacosTransferFunction::Hlg => CaptureTransferFunction::Hlg, + }; + 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" + )); + } + // ScreenCaptureKit only sometimes attaches ContentLightLevelInfo and + // IOSurfaceContentHeadroom; spec 76 requires a source reference white + // regardless and treats headroom as best-effort. When the OS stays + // silent, anchor reference white at BT.2408 diffuse white (203 nits, + // the same default as the target LED calibration, so unsignalled + // content maps through at unity) and assume one stop of highlight + // headroom, matching the output headroom the tone map reserves. + let reference_white = delivered + .source_reference_white_nits + .unwrap_or(DEFAULT_HDR_SOURCE_REFERENCE_WHITE_NITS); + // EDR headroom is dynamic: macOS reports it relative to the current + // display brightness, and at high brightness it legitimately reaches + // 1.0 (no room above SDR white right now). No real pixel can exceed + // reference white in that state, so giving the tone map its default + // rolloff space is visually identity while keeping the luminance + // contract well-formed. + let headroom = delivered + .content_headroom + .filter(|headroom| *headroom > 1.0) + .unwrap_or(DEFAULT_HDR_SOURCE_CONTENT_HEADROOM); + 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), + luminance, + )?) +} + +const fn capture_pixel_format(format: MacosCapturePixelFormat) -> 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 + .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) +} + +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 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 { + 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 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, + } +} + +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 timing_status( + sample_count: u64, + total_ns: u64, + max_ns: u64, + p95_ns: u64, + p99_ns: u64, +) -> MacosTimingStatus { + MacosTimingStatus { + sample_count, + total_ns, + max_ns, + p95_ns, + p99_ns, + } +} + +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() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(feature = "macos-capture-fixtures")] +struct FixtureControl { + mailbox: MacosFrameMailbox, + active: AtomicBool, + active_transitions: AtomicU64, + status: Mutex, + selection: Mutex, + selection_revision: AtomicU64, + stream_request: Mutex, + pending_stream_request: Mutex>, + stream_request_transitions: AtomicU64, + reject_next_stream_request: AtomicBool, + defer_next_stream_request: AtomicBool, + tahoe_selection: Mutex>, + host_capabilities: Mutex, + captured_at: Mutex>, + diagnostics: Mutex, +} + +#[cfg(feature = "macos-capture-fixtures")] +struct FixturePendingStreamRequest { + generation: u64, + request: MacosStreamRequest, + completion: mpsc::SyncSender>, +} + +#[cfg(feature = "macos-capture-fixtures")] +impl Default for FixtureControl { + fn default() -> Self { + Self { + mailbox: MacosFrameMailbox::default(), + active: AtomicBool::new(false), + active_transitions: AtomicU64::new(0), + status: Mutex::new(NativeProtectedSourceState::ReadyIdle), + selection: Mutex::new(MacosCaptureSelection::None), + selection_revision: AtomicU64::new(0), + stream_request: Mutex::new(MacosStreamRequest::default()), + pending_stream_request: Mutex::new(None), + stream_request_transitions: AtomicU64::new(0), + reject_next_stream_request: AtomicBool::new(false), + defer_next_stream_request: AtomicBool::new(false), + 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), + diagnostics: Mutex::new(MacosCaptureCallbackDiagnostics::default()), + } + } +} + +#[cfg(feature = "macos-capture-fixtures")] +impl MacosCaptureControl for FixtureControl { + fn mailbox(&self) -> MacosFrameMailbox { + self.mailbox.clone() + } + + fn set_active(&self, active: bool) { + let previous = self.active.swap(active, Ordering::AcqRel); + if previous == active { + return; + } + self.active_transitions.fetch_add(1, Ordering::AcqRel); + if !active { + *lock(&self.tahoe_selection) = None; + self.selection_revision.fetch_add(1, Ordering::AcqRel); + } + *lock(&self.status) = if active { + NativeProtectedSourceState::Starting + } else { + NativeProtectedSourceState::ReadyIdle + }; + } + + fn present_picker(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn request_authorization(&self) -> NativeProtectedSourceState { + *lock(&self.status) = NativeProtectedSourceState::NeedsSelection; + NativeProtectedSourceState::NeedsSelection + } + + fn status(&self) -> NativeProtectedSourceState { + *lock(&self.status) + } + + fn selection(&self) -> MacosCaptureSelection { + lock(&self.selection).clone() + } + + fn selection_revision(&self) -> u64 { + self.selection_revision.load(Ordering::Acquire) + } + + fn begin_stream_request(&self, request: MacosStreamRequest) -> anyhow::Result { + if self + .reject_next_stream_request + .swap(false, Ordering::AcqRel) + { + anyhow::bail!("fixture rejected macOS stream request"); + } + let generation = self + .stream_request_transitions + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + if self.defer_next_stream_request.swap(false, Ordering::AcqRel) { + let (completion, receiver) = mpsc::sync_channel(1); + *lock(&self.pending_stream_request) = Some(FixturePendingStreamRequest { + generation, + request, + completion, + }); + return Ok(StreamRequest { + generation, + completion: Box::new(move || { + receiver + .recv() + .map_err(|_| anyhow!("fixture stream request completion was lost"))? + }), + }); + } + *lock(&self.stream_request) = request; + Ok(StreamRequest::completed(generation, Ok(()))) + } + + fn tahoe_selection_capabilities(&self) -> Option { + 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 => { + MacosAuthorizationState::Denied + } + NativeProtectedSourceState::NeedsUserAction => MacosAuthorizationState::NotDetermined, + NativeProtectedSourceState::Disabled => MacosAuthorizationState::Unknown, + _ => MacosAuthorizationState::Authorized, + } + } + + 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)) + } +} + +#[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) { + Self::source_with_compute_capacity_policy( + config, + admission, + ScreenComputeCapacityPolicy::UNBOUNDED, + ) + } + + pub fn source_with_compute_capacity_policy( + config: CaptureConfig, + admission: ScreenByteAdmissionCoordinator, + compute_capacity_policy: ScreenComputeCapacityPolicy, + ) -> (MacosScreenCaptureInput, Self) { + let control = Arc::new(FixtureControl { + status: Mutex::new(NativeProtectedSourceState::ReadyIdle), + ..FixtureControl::default() + }); + let source = MacosScreenCaptureInput::with_control_and_telemetry( + config, + admission, + compute_capacity_policy, + control.clone(), + Arc::new(MacosScreenRuntimeTelemetry::default()), + ); + (source, Self { control }) + } + + 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)))); + } + + pub fn publish_at(&self, frame: MacosCaptureFrame, captured_at: Instant) { + *lock(&self.control.captured_at) = Some(captured_at); + 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) + } + + pub fn set_selection(&self, selection: MacosCaptureSelection) { + *lock(&self.control.tahoe_selection) = None; + let mut current = lock(&self.control.selection); + if *current != selection { + *current = selection; + self.control + .selection_revision + .fetch_add(1, Ordering::AcqRel); + } + } + + pub fn selection_revision(&self) -> u64 { + self.control.selection_revision.load(Ordering::Acquire) + } + + pub fn stream_request(&self) -> MacosStreamRequest { + *lock(&self.control.stream_request) + } + + pub fn stream_request_transitions(&self) -> u64 { + self.control + .stream_request_transitions + .load(Ordering::Acquire) + } + + pub fn active_transitions(&self) -> u64 { + self.control.active_transitions.load(Ordering::Acquire) + } + + pub fn reject_next_stream_request(&self) { + self.control + .reject_next_stream_request + .store(true, Ordering::Release); + } + + pub fn defer_next_stream_request(&self) { + self.control + .defer_next_stream_request + .store(true, Ordering::Release); + } + + pub fn pending_stream_request(&self) -> Option { + lock(&self.control.pending_stream_request) + .as_ref() + .map(|pending| pending.request) + } + + pub fn commit_pending_stream_request(&self) { + let pending = lock(&self.control.pending_stream_request) + .take() + .expect("fixture stream request should be pending"); + *lock(&self.control.stream_request) = pending.request; + let _ = pending.completion.send(Ok(())); + } + + pub fn fail_pending_stream_request(&self) { + let pending = lock(&self.control.pending_stream_request) + .take() + .expect("fixture stream request should be pending"); + let _ = pending.completion.send(Err(anyhow!( + "fixture stream request generation {} failed asynchronously", + pending.generation + ))); + } + + pub fn set_tahoe_selection_capabilities( + &self, + capabilities: Option, + ) { + *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"))] +mod tests { + use super::*; + use crate::input::screen::{ + CpuReductionLayout, CpuReductionRequest, InputPublicationDemandRevision, + PreparedLedToneMap, ResolvedScreenColorTransform, ScreenAdmissionCapacity, + ScreenAspectPolicy, ScreenBranchDeliveryLifecycle, ScreenBranchPublication, + ScreenExtentRequest, ScreenHdrPolicy, ScreenInputGraphGeneration, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativeTargetPreparation, + ScreenNativeTargetPreparer, ScreenPayloadKind, ScreenPlanBuilder, ScreenProcessingProfile, + ScreenProcessingProfileConfig, ScreenProfileScalar, ScreenPublicationFreshness, + ScreenPublicationKind, ScreenPublicationRequest, ScreenReductionFilter, + ScreenSceneCutPolicy, ScreenSmoothingPolicy, ScreenToneMapOperator, ScreenToneMapPolicy, + }; + use hypercolor_macos_capture::{ + MacosAttachment, MacosCaptureColorimetry, MacosCaptureSurface, MacosColorRange, + MacosDeliveredFrameMetadata, MacosFrameDecoder, MacosPixelExtent, MacosPointRect, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, + MacosRawFrameAttachments, + }; + + 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; + + #[test] + fn runtime_timing_percentiles_are_bounded_by_the_exact_maximum() { + let timing = AtomicTimingHistogram::default(); + timing.record(Duration::from_nanos(1)); + timing.record(Duration::from_micros(250)); + + let snapshot = timing.snapshot(); + assert_eq!(snapshot.sample_count, 2); + assert_eq!(snapshot.total_ns, 250_001); + assert_eq!(snapshot.max_ns, 250_000); + assert_eq!(snapshot.p95_ns, 250_000); + assert_eq!(snapshot.p99_ns, 250_000); + } + + #[test] + fn runtime_timing_snapshot_retries_when_population_changes() { + let timing = AtomicTimingHistogram::default(); + timing.record(Duration::from_nanos(40)); + let mut injected = false; + + let snapshot = timing.snapshot_with_hooks( + || {}, + || { + if !injected { + timing.record(Duration::from_nanos(70)); + injected = true; + } + }, + ); + + assert_eq!(snapshot.sample_count, 2); + assert_eq!(snapshot.total_ns, 110); + assert_eq!(snapshot.max_ns, 70); + assert_eq!(snapshot.p95_ns, 70); + assert_eq!(snapshot.p99_ns, 70); + } + + #[test] + fn worker_invalidation_is_atomic_across_branches_and_stale_safe() { + let mut builder = ScreenPlanBuilder::new(); + let exact = MacosExactPublicationShared::default(); + let hub = builder.publication_hub(); + *lock(&exact.hub) = Some(Arc::clone(&hub)); + let mut runtimes = Vec::new(); + let source = source(&frame()); + exact.replace_source(Some(source.clone())); + let surface = resolve_macos_publication_branch( + &source, + &cpu_demand_for_kind( + ScreenProcessingProfile::default(), + ScreenPublicationKind::Surface, + ), + ) + .expect("surface demand resolves") + .expect("configured source owns surface demand"); + let zones = resolve_macos_publication_branch( + &source, + &cpu_demand_for_kind( + ScreenProcessingProfile::default(), + ScreenPublicationKind::Zones { + columns: NonZeroU32::new(2).expect("nonzero columns"), + rows: NonZeroU32::new(1).expect("nonzero rows"), + }, + ), + ) + .expect("zone demand resolves") + .expect("configured source owns zone demand"); + let descriptors = commit_cpu_runtimes( + &mut builder, + &exact, + &source, + [surface, zones], + &mut runtimes, + ); + let bound_at = Instant::now(); + bind_current_macos_exact_runtime(&mut runtimes, &source, &hub, bound_at) + .expect("current runtime binds") + .expect("current runtime exists"); + let captured_at = bound_at + Duration::from_millis(20); + let first = cpu_capture_frame(&source, 1, captured_at, [32, 64, 96, 255]); + publish_cpu_frame(&exact, &mut runtimes, &source, &first); + let leases = descriptors + .iter() + .map(|descriptor| hub.lease(descriptor).expect("branch lease remains live")) + .collect::>(); + let initial = leases + .iter() + .map(|lease| lease.observe(captured_at)) + .collect::>(); + assert!(initial.iter().all(|(publication, delivery)| { + publication.is_some() + && delivery.lifecycle() == ScreenBranchDeliveryLifecycle::Live + && delivery.freshness() == Some(ScreenPublicationFreshness::Fresh) + && delivery.source_health() == Some(ScreenPublicationHealth::Healthy) + && delivery.invalidation_epoch() == 0 + })); + + report_macos_worker_health(&exact, &runtimes, ScreenPublicationHealth::Recovering) + .expect("recoverable health report succeeds"); + for (lease, (publication, _)) in leases.iter().zip(&initial) { + let (retained, delivery) = lease.observe(captured_at); + assert!( + retained + .as_ref() + .zip(publication.as_ref()) + .is_some_and(|(retained, publication)| Arc::ptr_eq(retained, publication)) + ); + assert_eq!(delivery.lifecycle(), ScreenBranchDeliveryLifecycle::Live); + assert_eq!( + delivery.source_health(), + Some(ScreenPublicationHealth::Recovering) + ); + assert_eq!(delivery.invalidation_epoch(), 0); + } + + let old_binding = runtimes + .last() + .expect("committed runtime exists") + .binding + .clone(); + let publisher = hub + .publisher(&descriptors[0], &old_binding) + .expect("current worker owns surface publisher"); + let prepared_at = captured_at + Duration::from_millis(10); + let intent = ScreenPublicationMetadata::try_intent( + source.epoch.clone(), + publisher.plan_generation(), + NonZeroU64::new(2).expect("nonzero sequence"), + prepared_at, + prepared_at + Duration::from_secs(1), + ) + .expect("pre-invalidation intent is valid"); + let prepared = hub + .prepare_writable_publication(&publisher, ScreenPayloadKind::Surface, &intent) + .expect("pre-invalidation publication reserves"); + let worker_publication = Arc::new(Mutex::new(MacosPublication::default())); + let mut observed_invalidation_generation = 0; + assert!( + synchronize_macos_invalidation_generation( + &mut observed_invalidation_generation, + 1, + &worker_publication, + &exact, + &runtimes, + ) + .expect("new terminal generation invalidates") + ); + assert!(matches!( + hub.finalize_writable_publication( + prepared, + prepared_at + Duration::from_millis(1), + ScreenPublicationHealth::Healthy, + ), + Err(ScreenPublicationHubError::PublicationInvalidated) + )); + let invalidated = leases + .iter() + .map(|lease| lease.observe(captured_at)) + .collect::>(); + let invalidation_epoch = invalidated[0].1.invalidation_epoch(); + assert_ne!(invalidation_epoch, 0); + assert!(invalidated.iter().all(|(publication, delivery)| { + publication.is_none() + && delivery.lifecycle() == ScreenBranchDeliveryLifecycle::Pending + && delivery.freshness().is_none() + && delivery.source_health() == Some(ScreenPublicationHealth::Failed) + && delivery.invalidation_epoch() == invalidation_epoch + })); + assert!( + synchronize_macos_invalidation_generation( + &mut observed_invalidation_generation, + 1, + &worker_publication, + &exact, + &runtimes, + ) + .expect("duplicate terminal generation is accepted without reinvalidation") + ); + assert!(leases.iter().all(|lease| { + lease.observe(captured_at).1.invalidation_epoch() == invalidation_epoch + })); + assert!( + !synchronize_macos_invalidation_generation( + &mut observed_invalidation_generation, + 0, + &worker_publication, + &exact, + &runtimes, + ) + .expect("pre-terminal frame generation is rejected") + ); + + let recovered_at = captured_at + Duration::from_millis(20); + let recovered = cpu_capture_frame(&source, 2, recovered_at, [96, 64, 32, 255]); + publish_cpu_frame(&exact, &mut runtimes, &source, &recovered); + assert!(leases.iter().all(|lease| { + let (publication, delivery) = lease.observe(recovered_at); + publication.is_some() + && delivery.lifecycle() == ScreenBranchDeliveryLifecycle::Live + && delivery.source_health() == Some(ScreenPublicationHealth::Healthy) + && delivery.invalidation_epoch() == invalidation_epoch + })); + + let replacement = + resolve_macos_publication_branch(&source, &cpu_demand(transition_profile(false))) + .expect("replacement demand resolves") + .expect("configured source owns replacement demand"); + let replacement_descriptor = + commit_cpu_runtime(&mut builder, &exact, &source, replacement, &mut runtimes); + let replacement_bound_at = recovered_at + Duration::from_millis(20); + bind_current_macos_exact_runtime(&mut runtimes, &source, &hub, replacement_bound_at) + .expect("replacement runtime binds") + .expect("replacement runtime exists"); + let replacement_at = replacement_bound_at + Duration::from_millis(20); + let replacement_frame = cpu_capture_frame(&source, 3, replacement_at, [48, 48, 48, 255]); + publish_cpu_frame(&exact, &mut runtimes, &source, &replacement_frame); + let replacement_lease = hub + .lease(&replacement_descriptor) + .expect("replacement lease is committed"); + let replacement_publication = replacement_lease + .read() + .expect("replacement branch published"); + assert!(matches!( + hub.invalidate_worker(&old_binding), + Err(ScreenPublicationHubError::WorkerAuthorityStale { .. }) + )); + let after_stale_invalidation = replacement_lease + .read() + .expect("stale worker cannot clear replacement publication"); + assert!(Arc::ptr_eq( + &replacement_publication, + &after_stale_invalidation + )); + } + + #[test] + fn cpu_reduction_timing_excludes_frames_when_branch_cadence_is_not_due() { + 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 demand = cpu_demand_for_kind_at_hz( + ScreenProcessingProfile::default(), + ScreenPublicationKind::Surface, + NonZeroU32::MIN, + ); + let resolved = resolve_macos_publication_branch(&native_source, &demand) + .expect("CPU demand resolves") + .expect("configured source owns CPU demand"); + let mut runtimes = Vec::new(); + commit_cpu_runtime( + &mut builder, + &exact, + &native_source, + resolved, + &mut runtimes, + ); + let telemetry = MacosScreenRuntimeTelemetry::default(); + let captured_at = Instant::now(); + let first = native_cpu_capture_frame( + &native_frame, + captured_at, + captured_at + Duration::from_secs(2), + &native_source, + native_source.epoch.source_id.clone(), + ) + .expect("first native scalar envelope is valid"); + publish_macos_scalar_exact( + &native_frame, + &first, + &native_source, + &exact, + &mut runtimes, + &telemetry, + ) + .expect("first due CPU branch publishes"); + assert_eq!(telemetry.cpu_reduction_timing.snapshot().sample_count, 1); + + let mut next_native_frame = (*native_frame).clone(); + next_native_frame.sequence = next_native_frame + .sequence + .checked_add(1) + .expect("fixture sequence advances"); + let next_native_frame = Arc::new(next_native_frame); + let next = native_cpu_capture_frame( + &next_native_frame, + captured_at, + captured_at + Duration::from_secs(2), + &native_source, + native_source.epoch.source_id.clone(), + ) + .expect("second native scalar envelope is valid"); + publish_macos_scalar_exact( + &next_native_frame, + &next, + &native_source, + &exact, + &mut runtimes, + &telemetry, + ) + .expect("not-due CPU branch is skipped"); + assert_eq!(telemetry.cpu_reduction_timing.snapshot().sample_count, 1); + } + + #[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 { + 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 byte_len = u64::try_from(encoded_pixel.len() * 8).expect("fixture length fits"); + let mut surface = MacosCaptureSurface::new_cpu_fixture( + 7, + byte_len, + 1, + 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: encoded_pixel.len() * 4, + length_bytes: byte_len, + }], + 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 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"), + 3, + 5, + frame, + ) + .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 { + cpu_demand_for_kind_at_hz(profile, kind, NonZeroU32::new(60).expect("nonzero cadence")) + } + + fn cpu_demand_for_kind_at_hz( + profile: ScreenProcessingProfile, + kind: ScreenPublicationKind, + requested_hz: NonZeroU32, + ) -> RegisteredScreenBranchDemand { + RegisteredScreenBranchDemand::new( + ScreenPublicationRequest::new( + ScreenSourceSelector::Configured, + kind, + ScreenPublicationExecutorRequest::Cpu, + ScreenExtentRequest::Native, + ScreenAspectPolicy::Cover, + Arc::new(profile), + ), + requested_hz, + ) + } + + 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 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], + 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_full_precision_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); + 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] + 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")), + PlatformGpuApi::Metal, + ScreenPhysicalGpuDeviceIdentity::MetalRegistryId(91), + NonZeroU32::new(16_384).expect("nonzero texture limit"), + Arc::new(TestTargetPreparer), + ) + } + + 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, + ScreenPublicationKind::Surface, + ScreenPublicationExecutorRequest::SourceNative(target.clone()), + ScreenExtentRequest::Native, + ScreenAspectPolicy::Contain, + Arc::new(ScreenProcessingProfile::new( + ScreenProcessingProfileConfig::exact_encoded_identity(format), + )), + ), + NonZeroU32::new(60).expect("nonzero cadence"), + ) + } + + 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, + Arc, + ) { + 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(); + let (_, telemetry) = 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"); + (publication, telemetry) + } + + #[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, telemetry) = publish_native_fixture(&frame, &source, resolved); + assert_eq!(publication.native_sequence(), NonZeroU64::MIN); + let ScreenBranchPayload::GpuSurface(payload) = publication.payload() else { + 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.timing_sink().is_some()); + assert!(surface.retained_owner::().is_some()); + assert!(surface.resource_lifetime().is_some()); + assert!(surface.capture_resource_lifetime().is_some()); + assert_eq!( + telemetry + .capture_to_native_publication_timing + .snapshot() + .sample_count, + 1 + ); + } + + #[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, + &MacosScreenRuntimeTelemetry::default(), + ) + .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(); + let source = source(&frame); + 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"); + assert!(matches!( + 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] + 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 exact_delivery_never_materializes_a_legacy_full_frame() { + assert!(!needs_legacy_cpu_publication(MacosExactDelivery { + native: true, + cpu: false, + stale: false, + })); + assert!(!needs_legacy_cpu_publication(MacosExactDelivery { + native: false, + cpu: true, + stale: false, + })); + assert!(needs_legacy_cpu_publication(MacosExactDelivery::default())); + } + + #[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/macos/surface_pool.rs b/crates/hypercolor-core/src/input/screen/macos/surface_pool.rs new file mode 100644 index 000000000..a626595c2 --- /dev/null +++ b/crates/hypercolor-core/src/input/screen/macos/surface_pool.rs @@ -0,0 +1,926 @@ +use std::alloc::Layout; +use std::mem::size_of; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use hypercolor_macos_capture::{MACOS_STREAM_QUEUE_DEPTH, MacosCaptureError}; + +use super::{MacosScreenRuntimeTelemetry, lock}; +use crate::input::screen::{ + ScreenByteAdmissionCoordinator, ScreenByteAdmissionError, ScreenByteLease, + ScreenByteReservation, +}; + +#[derive(Clone)] +pub(super) struct MacosSurfacePool { + inner: Arc, +} + +struct MacosSurfacePoolInner { + coordinator: ScreenByteAdmissionCoordinator, + telemetry: Arc, + metadata_lease: ScreenByteLease, + state: Mutex, +} + +struct MacosSurfacePoolState { + initial_surface_reserve: ScreenByteReservation, + live: Option>, + next_generation: u64, +} + +struct LiveSurface { + iosurface_id: u32, + generation: u64, + token: Weak, + next: Option>, +} + +#[cfg(test)] +std::thread_local! { + static POOL_DROP_EVENTS: std::cell::RefCell> = const { + std::cell::RefCell::new(Vec::new()) + }; + static TOP_UP_EVENTS: std::cell::RefCell> = const { + std::cell::RefCell::new(Vec::new()) + }; + static TOP_UP_PEAK_SNAPSHOTS: std::cell::RefCell> = const { + std::cell::RefCell::new(Vec::new()) + }; +} + +#[cfg(test)] +impl Drop for LiveSurface { + fn drop(&mut self) { + record_pool_drop_event("live_surface_drop"); + } +} + +#[cfg(test)] +fn record_pool_drop_event(event: &'static str) { + POOL_DROP_EVENTS.with(|events| events.borrow_mut().push(event)); +} + +#[cfg(test)] +fn record_top_up_event(event: &'static str) { + TOP_UP_EVENTS.with(|events| events.borrow_mut().push(event)); +} + +#[cfg(test)] +fn record_top_up_peak_snapshot( + phase: &'static str, + coordinator: &ScreenByteAdmissionCoordinator, + telemetry: &MacosScreenRuntimeTelemetry, +) { + TOP_UP_PEAK_SNAPSHOTS.with(|snapshots| { + snapshots.borrow_mut().push(( + phase, + coordinator.snapshot().reserved_bytes(), + telemetry.admitted_native_bytes.load(Ordering::Acquire), + )); + }); +} + +pub(super) struct MacosSurfaceAdmissionToken { + pool: Mutex>>, + telemetry: Arc, + iosurface_id: u32, + allocation_bytes: u64, + admitted_bytes: AtomicU64, + generation: u64, + lease: ScreenByteLease, +} + +impl MacosSurfacePool { + pub(super) fn reserve( + coordinator: &ScreenByteAdmissionCoordinator, + telemetry: Arc, + conservative_surface_bytes: u64, + native_metadata_bytes: u64, + ) -> Result { + let queue_depth = u64::try_from(MACOS_STREAM_QUEUE_DEPTH) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let tracking_bytes = pool_tracking_bytes()?; + let metadata_bytes = native_metadata_bytes + .checked_add(tracking_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let initial_surface_bytes = conservative_surface_bytes + .checked_add(live_surface_tracking_bytes()?) + .and_then(|bytes| bytes.checked_mul(queue_depth)) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let total_bytes = metadata_bytes + .checked_add(initial_surface_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let mut reservation = coordinator + .try_acquire(total_bytes) + .map_err(map_admission_error)?; + let metadata_lease = reservation + .split_off(metadata_bytes) + .expect("metadata is a checked subset of the pool quote") + .freeze(); + telemetry + .admitted_native_bytes + .fetch_add(total_bytes, Ordering::AcqRel); + Ok(Self { + inner: Arc::new(MacosSurfacePoolInner { + coordinator: coordinator.clone(), + telemetry, + metadata_lease, + state: Mutex::new(MacosSurfacePoolState { + initial_surface_reserve: reservation, + live: None, + next_generation: 1, + }), + }), + }) + } + + pub(super) fn observe( + &self, + iosurface_id: u32, + allocation_bytes: u64, + ) -> Result, MacosCaptureError> { + if iosurface_id == 0 || allocation_bytes == 0 { + return Err(MacosCaptureError::InvalidSurface); + } + + let mut state = lock(&self.inner.state); + let mut current = state.live.as_deref(); + let mut stale_generation = None; + while let Some(surface) = current { + if surface.iosurface_id == iosurface_id { + if let Some(token) = surface.token.upgrade() { + if token.allocation_bytes != allocation_bytes { + return Err(MacosCaptureError::InvalidSurface); + } + return Ok(token); + } + stale_generation = Some(surface.generation); + break; + } + current = surface.next.as_deref(); + } + if let Some(generation) = stale_generation { + remove_live_surface(&mut state.live, iosurface_id, generation); + } + + let generation = state.next_generation; + state.next_generation = state + .next_generation + .checked_add(1) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let admitted_bytes = allocation_bytes + .checked_add(live_surface_tracking_bytes()?) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let lease = acquire_surface_lease( + &self.inner.coordinator, + &self.inner.telemetry, + &mut state.initial_surface_reserve, + admitted_bytes, + )?; + let token = Arc::new(MacosSurfaceAdmissionToken { + pool: Mutex::new(Some(Arc::downgrade(&self.inner))), + telemetry: Arc::clone(&self.inner.telemetry), + iosurface_id, + allocation_bytes, + admitted_bytes: AtomicU64::new(admitted_bytes), + generation, + lease, + }); + let next = state.live.take(); + state.live = Some(Box::new(LiveSurface { + iosurface_id, + generation, + token: Arc::downgrade(&token), + next, + })); + Ok(token) + } +} + +impl Drop for MacosSurfacePoolInner { + fn drop(&mut self) { + let mut state = lock(&self.state); + let mut live = state.live.take(); + while let Some(mut surface) = live { + let next = surface.next.take(); + let token = surface.token.upgrade(); + if let Some(token) = &token { + token.detach_from_pool(); + } + drop(surface); + if let Some(token) = token { + token.release_index_tracking(); + } + live = next; + } + let remaining_surface_reserve = state.initial_surface_reserve.bytes(); + let pool_bytes = self + .metadata_lease + .bytes() + .checked_add(remaining_surface_reserve) + .expect("pool reservation bytes were checked during construction"); + self.telemetry + .admitted_native_bytes + .fetch_sub(pool_bytes, Ordering::AcqRel); + } +} + +impl Drop for MacosSurfaceAdmissionToken { + fn drop(&mut self) { + let pool = lock(&self.pool).take().and_then(|pool| pool.upgrade()); + if let Some(pool) = pool { + remove_live_surface( + &mut lock(&pool.state).live, + self.iosurface_id, + self.generation, + ); + } + self.telemetry.admitted_native_bytes.fetch_sub( + self.admitted_bytes.load(Ordering::Acquire), + Ordering::AcqRel, + ); + } +} + +impl MacosSurfaceAdmissionToken { + fn detach_from_pool(&self) { + lock(&self.pool).take(); + } + + fn release_index_tracking(&self) { + #[cfg(test)] + record_pool_drop_event("index_tracking_release"); + + let exact_bytes = self + .allocation_bytes + .checked_add(surface_token_tracking_bytes().expect("token tracking quote fits")) + .expect("surface admission bytes were checked during observation"); + self.lease + .try_reconcile_exact(exact_bytes) + .expect("dropping live-index tracking only reduces admission"); + let previous = self.admitted_bytes.swap(exact_bytes, Ordering::AcqRel); + self.telemetry + .admitted_native_bytes + .fetch_sub(previous - exact_bytes, Ordering::AcqRel); + } +} + +fn remove_live_surface(live: &mut Option>, iosurface_id: u32, generation: u64) { + let mut cursor = live; + while let Some(mut surface) = cursor.take() { + if surface.iosurface_id == iosurface_id && surface.generation == generation { + *cursor = surface.next.take(); + return; + } + *cursor = Some(surface); + cursor = &mut cursor + .as_mut() + .expect("the current surface was restored") + .next; + } +} + +fn pool_tracking_bytes() -> Result { + arc_allocation_bytes::()? + .checked_add( + screen_byte_lease_allocation_bytes()? + .checked_mul(2) + .ok_or(MacosCaptureError::ArithmeticOverflow)?, + ) + .ok_or(MacosCaptureError::ArithmeticOverflow) +} + +fn live_surface_tracking_bytes() -> Result { + u64::try_from(size_of::()) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)? + .checked_add(surface_token_tracking_bytes()?) + .ok_or(MacosCaptureError::ArithmeticOverflow) +} + +fn surface_token_tracking_bytes() -> Result { + arc_allocation_bytes::()? + .checked_add(screen_byte_lease_allocation_bytes()?) + .ok_or(MacosCaptureError::ArithmeticOverflow) +} + +fn arc_allocation_bytes() -> Result { + arc_allocation_bytes_for_layout(Layout::new::()) +} + +fn screen_byte_lease_allocation_bytes() -> Result { + let (payload, _) = Layout::new::>() + .extend(Layout::new::()) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + arc_allocation_bytes_for_layout(payload.pad_to_align()) +} + +fn arc_allocation_bytes_for_layout(payload: Layout) -> Result { + let header = + Layout::array::(2).map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + let (allocation, _) = header + .extend(payload) + .map_err(|_| MacosCaptureError::ArithmeticOverflow)?; + u64::try_from(allocation.pad_to_align().size()) + .map_err(|_| MacosCaptureError::ArithmeticOverflow) +} + +fn acquire_surface_lease( + coordinator: &ScreenByteAdmissionCoordinator, + telemetry: &MacosScreenRuntimeTelemetry, + initial_reserve: &mut ScreenByteReservation, + allocation_bytes: u64, +) -> Result { + let reserved_bytes = initial_reserve.bytes().min(allocation_bytes); + let added_bytes = allocation_bytes - reserved_bytes; + let temporary_lease_bytes = if added_bytes == 0 { + 0 + } else { + screen_byte_lease_allocation_bytes()? + }; + let peak_top_up_bytes = added_bytes + .checked_add(temporary_lease_bytes) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + let top_up = if added_bytes == 0 { + None + } else { + if telemetry + .admitted_native_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |admitted| { + admitted.checked_add(peak_top_up_bytes) + }) + .is_err() + { + return Err(MacosCaptureError::ArithmeticOverflow); + } + #[cfg(test)] + record_top_up_event("peak_precharged"); + let top_up = match coordinator.try_acquire(peak_top_up_bytes) { + Ok(top_up) => { + #[cfg(test)] + record_top_up_event("temporary_lease_admitted"); + top_up + } + Err(error) => { + #[cfg(test)] + record_top_up_event("coordinator_rejected"); + telemetry + .admitted_native_bytes + .fetch_sub(peak_top_up_bytes, Ordering::AcqRel); + #[cfg(test)] + record_top_up_event("peak_released"); + return Err(map_admission_error(error)); + } + }; + #[cfg(test)] + record_top_up_peak_snapshot("before_final_lease_split", coordinator, telemetry); + Some(top_up) + }; + let mut reservation = initial_reserve + .split_off(reserved_bytes) + .expect("surface reserve split is bounded by its current bytes"); + if let Some(top_up) = top_up { + #[cfg(test)] + record_top_up_event("final_lease_split"); + #[cfg(test)] + record_top_up_peak_snapshot("after_final_lease_split", coordinator, telemetry); + reservation + .absorb(top_up) + .expect("surface top-up shares the pool admission coordinator"); + #[cfg(test)] + record_top_up_event("temporary_lease_freed"); + reservation + .reconcile_down(allocation_bytes) + .expect("the temporary top-up lease is freed before its admission is released"); + telemetry + .admitted_native_bytes + .fetch_sub(temporary_lease_bytes, Ordering::AcqRel); + #[cfg(test)] + record_top_up_event("temporary_peak_released"); + #[cfg(test)] + record_top_up_peak_snapshot("steady_state", coordinator, telemetry); + } + Ok(reservation.freeze()) +} + +fn map_admission_error(error: ScreenByteAdmissionError) -> 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), + }; + MacosCaptureError::ScreenResourceExhausted { + requested_bytes, + available_bytes, + } +} + +#[cfg(test)] +mod tests { + use std::alloc::Layout; + use std::mem::size_of; + use std::sync::{Arc, Barrier}; + + use super::*; + use crate::input::screen::ScreenAdmissionCapacity; + + fn pool( + coordinator: &ScreenByteAdmissionCoordinator, + telemetry: Arc, + ) -> MacosSurfacePool { + MacosSurfacePool::reserve(coordinator, telemetry, 100, 32) + .expect("initial queue reserve should fit") + } + + fn metadata_bytes(pool: &MacosSurfacePool) -> u64 { + pool.inner.metadata_lease.bytes() + } + + fn initial_reserve_bytes(pool: &MacosSurfacePool) -> u64 { + lock(&pool.inner.state).initial_surface_reserve.bytes() + } + + fn live_surface_count(pool: &MacosSurfacePool) -> usize { + let state = lock(&pool.inner.state); + let mut count = 0; + let mut current = state.live.as_deref(); + while let Some(surface) = current { + count += 1; + current = surface.next.as_deref(); + } + count + } + + fn take_pool_drop_events() -> Vec<&'static str> { + POOL_DROP_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) + } + + fn take_top_up_events() -> Vec<&'static str> { + TOP_UP_EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut())) + } + + fn take_top_up_peak_snapshots() -> Vec<(&'static str, u64, u64)> { + TOP_UP_PEAK_SNAPSHOTS.with(|snapshots| std::mem::take(&mut *snapshots.borrow_mut())) + } + + fn arc_allocation_bytes_oracle() -> u64 { + arc_allocation_bytes_for_layout_oracle(Layout::new::()) + } + + fn lease_allocation_bytes_oracle() -> u64 { + let (payload, _) = Layout::new::>() + .extend(Layout::new::()) + .expect("lease payload layout should build"); + arc_allocation_bytes_for_layout_oracle(payload.pad_to_align()) + } + + fn arc_allocation_bytes_for_layout_oracle(payload: Layout) -> u64 { + let header = Layout::array::(2).expect("Arc header layout should build"); + let (allocation, _) = header + .extend(payload) + .expect("Arc allocation layout should build"); + u64::try_from(allocation.pad_to_align().size()).expect("Arc allocation size fits u64") + } + + fn pool_tracking_bytes_oracle() -> u64 { + arc_allocation_bytes_oracle::() + 2 * lease_allocation_bytes_oracle() + } + + fn surface_token_tracking_bytes_oracle() -> u64 { + arc_allocation_bytes_oracle::() + + lease_allocation_bytes_oracle() + } + + fn live_surface_tracking_bytes_oracle() -> u64 { + u64::try_from(size_of::()).expect("live index allocation size fits u64") + + surface_token_tracking_bytes_oracle() + } + + #[test] + fn byte_quotes_enumerate_every_pool_and_live_heap_allocation() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, Arc::clone(&telemetry)); + let pool_bytes = 32 + pool_tracking_bytes_oracle(); + let live_tracking_bytes = live_surface_tracking_bytes_oracle(); + + assert_eq!(metadata_bytes(&pool), pool_bytes); + assert_eq!( + initial_reserve_bytes(&pool), + u64::try_from(MACOS_STREAM_QUEUE_DEPTH).expect("queue depth fits u64") + * (100 + live_tracking_bytes) + ); + + let token = pool.observe(29, 120).expect("surface should fit"); + assert_eq!( + token.admitted_bytes.load(Ordering::Acquire), + 120 + live_tracking_bytes + ); + assert_eq!(token.lease.bytes(), 120 + live_tracking_bytes); + assert!(lock(&token.pool).is_some()); + + drop(pool); + let pinned_bytes = 120 + surface_token_tracking_bytes_oracle(); + assert!(lock(&token.pool).is_none()); + assert_eq!(coordinator.snapshot().reserved_bytes(), pinned_bytes); + assert_eq!( + telemetry.admitted_native_bytes.load(Ordering::Acquire), + pinned_bytes + ); + + drop(token); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); + assert_eq!(telemetry.admitted_native_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn pinned_generations_retain_tokens_without_retaining_pool_allocations() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pinned_surface_bytes = 120 + surface_token_tracking_bytes_oracle(); + let mut tokens = Vec::new(); + + for generation in 1..=9 { + let generation_pool = pool(&coordinator, Arc::clone(&telemetry)); + let token = generation_pool + .observe(31, 120) + .expect("generation surface should fit"); + drop(generation_pool); + + assert!(lock(&token.pool).is_none()); + tokens.push(token); + assert_eq!( + coordinator.snapshot().reserved_bytes(), + generation * pinned_surface_bytes + ); + assert_eq!( + telemetry.admitted_native_bytes.load(Ordering::Acquire), + generation * pinned_surface_bytes + ); + } + + drop(tokens); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); + assert_eq!(telemetry.admitted_native_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn pool_drop_frees_live_index_before_releasing_its_tracking() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let pool = pool( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + ); + let token = pool.observe(37, 120).expect("surface should fit"); + drop(take_pool_drop_events()); + + drop(pool); + + assert_eq!( + take_pool_drop_events(), + vec!["live_surface_drop", "index_tracking_release"] + ); + drop(token); + } + + #[test] + fn ninth_historical_surface_is_admitted_after_prior_tokens_drop() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, Arc::clone(&telemetry)); + let metadata_bytes = metadata_bytes(&pool); + + for iosurface_id in 1..=9 { + let token = pool + .observe(iosurface_id, 100) + .expect("historical identity must not consume a live slot"); + assert_eq!(live_surface_count(&pool), 1); + drop(token); + assert_eq!(live_surface_count(&pool), 0); + } + + assert_eq!(initial_reserve_bytes(&pool), 0); + assert_eq!(coordinator.snapshot().reserved_bytes(), metadata_bytes); + assert_eq!( + telemetry.admitted_native_bytes.load(Ordering::Acquire), + metadata_bytes + ); + } + + #[test] + fn ninth_simultaneous_surface_depends_only_on_byte_capacity() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, Arc::clone(&telemetry)); + let metadata_bytes = metadata_bytes(&pool); + let per_surface_bytes = 100 + live_surface_tracking_bytes_oracle(); + let tokens = (1..=9) + .map(|iosurface_id| { + pool.observe(iosurface_id, 100) + .expect("real byte capacity admits more than queue depth") + }) + .collect::>(); + + assert_eq!(live_surface_count(&pool), 9); + assert_eq!( + coordinator.snapshot().reserved_bytes(), + metadata_bytes + 9 * per_surface_bytes + ); + assert_eq!( + telemetry.admitted_native_bytes.load(Ordering::Acquire), + metadata_bytes + 9 * per_surface_bytes + ); + drop(tokens); + assert_eq!(coordinator.snapshot().reserved_bytes(), metadata_bytes); + } + + #[test] + fn ninth_simultaneous_surface_is_rejected_when_byte_capacity_is_full() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let pool = pool( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + ); + let initial_bytes = coordinator.snapshot().reserved_bytes(); + coordinator + .try_set_capacity(ScreenAdmissionCapacity::new(initial_bytes, initial_bytes)) + .expect("exact current capacity installs"); + let tokens = (1..=8) + .map(|iosurface_id| { + pool.observe(iosurface_id, 100) + .expect("initial queue reserve covers eight live surfaces") + }) + .collect::>(); + + assert!(matches!( + pool.observe(9, 100), + Err(MacosCaptureError::ScreenResourceExhausted { + requested_bytes, + available_bytes: 0, + }) if requested_bytes + == 100 + + live_surface_tracking_bytes_oracle() + + lease_allocation_bytes_oracle() + )); + assert_eq!(live_surface_count(&pool), 8); + assert_eq!(coordinator.snapshot().reserved_bytes(), initial_bytes); + drop(tokens); + } + + #[test] + fn top_up_peak_admits_the_temporary_lease_allocation() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let pool = pool( + &coordinator, + Arc::new(MacosScreenRuntimeTelemetry::default()), + ); + let tokens = (1..=8) + .map(|iosurface_id| { + pool.observe(iosurface_id, 100) + .expect("initial queue reserve covers eight live surfaces") + }) + .collect::>(); + let reserved_before = coordinator.snapshot().reserved_bytes(); + let final_surface_bytes = 100 + live_surface_tracking_bytes_oracle(); + let temporary_lease_bytes = lease_allocation_bytes_oracle(); + coordinator + .try_set_capacity(ScreenAdmissionCapacity::new( + reserved_before + final_surface_bytes, + reserved_before + final_surface_bytes, + )) + .expect("capacity for only the final surface installs"); + drop(take_top_up_events()); + drop(take_top_up_peak_snapshots()); + + assert!(matches!( + pool.observe(9, 100), + Err(MacosCaptureError::ScreenResourceExhausted { + requested_bytes, + available_bytes, + }) if requested_bytes == final_surface_bytes + temporary_lease_bytes + && available_bytes == final_surface_bytes + )); + assert_eq!(coordinator.snapshot().reserved_bytes(), reserved_before); + assert_eq!( + pool.inner + .telemetry + .admitted_native_bytes + .load(Ordering::Acquire), + reserved_before + ); + assert_eq!( + take_top_up_events(), + vec!["peak_precharged", "coordinator_rejected", "peak_released"] + ); + assert!(take_top_up_peak_snapshots().is_empty()); + + coordinator + .try_set_capacity(ScreenAdmissionCapacity::new( + reserved_before + final_surface_bytes + temporary_lease_bytes, + reserved_before + final_surface_bytes + temporary_lease_bytes, + )) + .expect("capacity for the exact top-up peak installs"); + let ninth = pool + .observe(9, 100) + .expect("exact temporary peak capacity admits the surface"); + assert_eq!( + coordinator.snapshot().reserved_bytes(), + reserved_before + final_surface_bytes + ); + assert_eq!( + take_top_up_peak_snapshots(), + vec![ + ( + "before_final_lease_split", + reserved_before + final_surface_bytes + temporary_lease_bytes, + reserved_before + final_surface_bytes + temporary_lease_bytes, + ), + ( + "after_final_lease_split", + reserved_before + final_surface_bytes + temporary_lease_bytes, + reserved_before + final_surface_bytes + temporary_lease_bytes, + ), + ( + "steady_state", + reserved_before + final_surface_bytes, + reserved_before + final_surface_bytes, + ), + ] + ); + assert_eq!( + take_top_up_events(), + vec![ + "peak_precharged", + "temporary_lease_admitted", + "final_lease_split", + "temporary_lease_freed", + "temporary_peak_released", + ] + ); + assert_eq!( + pool.inner + .telemetry + .admitted_native_bytes + .load(Ordering::Acquire), + reserved_before + final_surface_bytes + ); + + drop(ninth); + drop(tokens); + } + + #[test] + fn rejected_top_up_restores_the_unconsumed_initial_reserve() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, Arc::clone(&telemetry)); + let initial_bytes = coordinator.snapshot().reserved_bytes(); + let initial_surface_reserve = initial_reserve_bytes(&pool); + coordinator + .try_set_capacity(ScreenAdmissionCapacity::new(initial_bytes, initial_bytes)) + .expect("exact current capacity installs"); + + assert!(matches!( + pool.observe(1, initial_surface_reserve), + Err(MacosCaptureError::ScreenResourceExhausted { .. }) + )); + assert_eq!(live_surface_count(&pool), 0); + assert_eq!(initial_reserve_bytes(&pool), initial_surface_reserve); + assert_eq!(coordinator.snapshot().reserved_bytes(), initial_bytes); + assert_eq!( + telemetry.admitted_native_bytes.load(Ordering::Acquire), + initial_bytes + ); + } + + #[test] + fn repeated_live_observations_share_one_token_and_release_exactly_once() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, Arc::clone(&telemetry)); + let reserved_before = coordinator.snapshot().reserved_bytes(); + let tracking_bytes = live_surface_tracking_bytes_oracle(); + let first = pool.observe(7, 120).expect("first observation fits"); + let repeated = pool.observe(7, 120).expect("live reuse fits"); + + assert!(Arc::ptr_eq(&first, &repeated)); + assert_eq!(live_surface_count(&pool), 1); + assert_eq!( + initial_reserve_bytes(&pool), + 8 * (100 + tracking_bytes) - (120 + tracking_bytes) + ); + assert_eq!(coordinator.snapshot().reserved_bytes(), reserved_before); + drop(first); + assert_eq!(coordinator.snapshot().reserved_bytes(), reserved_before); + drop(repeated); + assert_eq!( + coordinator.snapshot().reserved_bytes(), + reserved_before - (120 + tracking_bytes) + ); + assert_eq!(live_surface_count(&pool), 0); + } + + #[test] + fn concurrent_live_observations_share_one_token() { + const OBSERVERS: usize = 16; + + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, telemetry); + let tracking_bytes = live_surface_tracking_bytes_oracle(); + let barrier = Arc::new(Barrier::new(OBSERVERS)); + let tokens = std::thread::scope(|scope| { + let handles = (0..OBSERVERS) + .map(|_| { + let pool = pool.clone(); + let barrier = Arc::clone(&barrier); + scope.spawn(move || { + barrier.wait(); + pool.observe(11, 144).expect("shared observation fits") + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().expect("observer thread succeeds")) + .collect::>() + }); + + assert!(tokens.iter().all(|token| Arc::ptr_eq(&tokens[0], token))); + assert_eq!(live_surface_count(&pool), 1); + assert_eq!( + initial_reserve_bytes(&pool), + 8 * (100 + tracking_bytes) - (144 + tracking_bytes) + ); + } + + #[test] + fn live_allocation_conflicts_fail_closed_and_recycled_ids_admit_fresh() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(1_000_000, 1_000_000)); + let telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let pool = pool(&coordinator, telemetry); + let first = pool.observe(19, 120).expect("first observation fits"); + let reserved = coordinator.snapshot().reserved_bytes(); + + assert!(matches!( + pool.observe(19, 121), + Err(MacosCaptureError::InvalidSurface) + )); + assert_eq!(coordinator.snapshot().reserved_bytes(), reserved); + drop(first); + + let recycled = pool + .observe(19, 121) + .expect("fully released identity is admitted fresh"); + assert_eq!(recycled.allocation_bytes, 121); + assert_eq!(live_surface_count(&pool), 1); + } + + #[test] + fn pinned_old_generation_retains_only_its_live_surface_bytes() { + let coordinator = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(10_000, 10_000)); + let pinned_bytes = 120 + surface_token_tracking_bytes_oracle(); + let old_telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let old_pool = pool(&coordinator, Arc::clone(&old_telemetry)); + let pinned = old_pool + .observe(23, 120) + .expect("old generation surface fits"); + drop(old_pool); + + assert_eq!(coordinator.snapshot().reserved_bytes(), pinned_bytes); + assert_eq!( + old_telemetry.admitted_native_bytes.load(Ordering::Acquire), + pinned_bytes + ); + + let candidate_telemetry = Arc::new(MacosScreenRuntimeTelemetry::default()); + let candidate = pool(&coordinator, Arc::clone(&candidate_telemetry)); + let candidate_bytes = coordinator.snapshot().reserved_bytes() - pinned_bytes; + drop(pinned); + assert_eq!(coordinator.snapshot().reserved_bytes(), candidate_bytes); + assert_eq!( + old_telemetry.admitted_native_bytes.load(Ordering::Acquire), + 0 + ); + drop(candidate); + assert_eq!(coordinator.snapshot().reserved_bytes(), 0); + } +} diff --git a/crates/hypercolor-core/src/input/screen/materialize.rs b/crates/hypercolor-core/src/input/screen/materialize.rs index 6ad897a64..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") + } } } @@ -510,6 +531,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,6 +623,7 @@ impl PreparedCpuSurfaceMaterializer { elapsed, self.committed_bars .is_some_and(|committed| committed != bars), + suppress_scene_cut_bypass, )?; for (pixel, color) in output .chunks_exact_mut(BYTES_PER_PIXEL) @@ -1002,6 +1025,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)?; @@ -1054,6 +1078,7 @@ impl PreparedCpuZoneMaterializer { self.transfer, elapsed, reset_history, + suppress_scene_cut_bypass, )?; self.apply_tuning(&mut output[..color_count]); output[color_count..].fill([0, 0, 0]); @@ -1320,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 0fcc1ea07..023a54cbd 100644 --- a/crates/hypercolor-core/src/input/screen/mod.rs +++ b/crates/hypercolor-core/src/input/screen/mod.rs @@ -24,15 +24,18 @@ 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; pub mod smooth; +mod tone_map; pub mod tune; #[cfg(target_os = "linux")] pub mod wayland; @@ -73,22 +76,26 @@ pub use frame::{ CapturePlanePool, CapturePositiveScalar, CaptureRotation, CaptureSourceId, CaptureStageKind, CaptureStorage, CaptureTransferFunction, CpuCaptureStorage, GeometryNormalizedCaptureSurface, KnownCaptureColorimetry, MoveRegion, PhysicalOrigin, PixelExtent, PixelRect, PlatformGpuApi, - PlatformGpuSurface, PooledCapturePlane, RawCaptureSurface, SourceScale, + PlatformGpuSurface, PlatformGpuSurfaceOwner, PlatformGpuSurfaceTimingSink, PooledCapturePlane, + RawCaptureSurface, SourceScale, }; pub use hub::{ PreparedScreenPublication, ScreenBranchDeliveryLifecycle, ScreenBranchDeliveryState, 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, }; +#[cfg(feature = "macos-capture-fixtures")] +pub use macos::MacosScreenCaptureFixture; +pub use macos::{MacosNativeTargetManifest, MacosScreenCaptureInput}; pub use materialize::{ CpuSurfaceMaterializationError, CpuZoneMaterializationError, PreparedCpuSurfaceMaterializer, PreparedCpuZoneMaterializer, StagedCpuZonePublication, @@ -115,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, @@ -132,13 +140,20 @@ 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, - CpuStorageCoordinate, + CpuScalarSamplingView, CpuScalarSource, CpuStorageCoordinate, }; 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; @@ -156,9 +171,63 @@ use crate::types::canvas::{ use crate::types::event::ZoneColors; use std::fmt::Write as _; use std::mem::size_of; +use std::num::NonZeroU32; use std::sync::Arc; use std::time::{Duration, Instant}; +/// Acquisition cadence requested from a native screen backend. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ScreenCaptureCadence { + /// Use the source's configured acquisition cadence. + #[default] + Configured, + /// Allow the native source to publish at the display's refresh cadence. + NativeRefresh, + /// Request an explicit nonzero acquisition rate. + FramesPerSecond(NonZeroU32), +} + +impl ScreenCaptureCadence { + /// Construct an explicit nonzero acquisition rate. + /// + /// # Errors + /// + /// Returns [`CaptureCadenceError::Zero`] when `frames_per_second` is zero. + pub const fn frames_per_second(frames_per_second: u32) -> Result { + let cadence = match CaptureCadence::new(frames_per_second) { + Ok(cadence) => cadence, + Err(error) => return Err(error), + }; + Ok(Self::FramesPerSecond( + NonZeroU32::new(cadence.frames_per_second()) + .expect("validated capture cadence is nonzero"), + )) + } + + /// Resolve a demand override against the configured acquisition cadence. + #[must_use] + pub const fn resolve(self, configured: Self) -> Self { + match self { + Self::Configured => configured, + explicit => explicit, + } + } + + const fn union(self, other: Self) -> Self { + match (self, other) { + (Self::NativeRefresh, _) | (_, Self::NativeRefresh) => Self::NativeRefresh, + (Self::FramesPerSecond(left), Self::FramesPerSecond(right)) => { + if left.get() >= right.get() { + Self::FramesPerSecond(left) + } else { + Self::FramesPerSecond(right) + } + } + (Self::Configured, cadence) | (cadence, Self::Configured) => cadence, + } + } +} + /// Requested screen publication state for downstream render consumers. /// /// The requested extent describes the analyzed surface published by the input @@ -172,6 +241,10 @@ pub enum ScreenCaptureDemand { Active { /// Maximum width and height requested by the current consumer union. requested_extent: PixelExtent, + /// Native acquisition cadence requested by the current consumer union. + cadence: ScreenCaptureCadence, + /// Cursor composition requested from the native source. + cursor: ScreenCursorPolicy, }, } @@ -179,7 +252,25 @@ impl ScreenCaptureDemand { /// Construct active demand from a validated extent. #[must_use] pub const fn active(requested_extent: PixelExtent) -> Self { - Self::Active { requested_extent } + Self::active_with_policy( + requested_extent, + ScreenCaptureCadence::Configured, + ScreenCursorPolicy::Exclude, + ) + } + + /// Construct active demand with an explicit acquisition and cursor policy. + #[must_use] + pub const fn active_with_policy( + requested_extent: PixelExtent, + cadence: ScreenCaptureCadence, + cursor: ScreenCursorPolicy, + ) -> Self { + Self::Active { + requested_extent, + cadence, + cursor, + } } /// Construct active demand from checked pixel dimensions. @@ -205,7 +296,27 @@ impl ScreenCaptureDemand { pub const fn requested_extent(self) -> Option { match self { Self::Inactive => None, - Self::Active { requested_extent } => Some(requested_extent), + Self::Active { + requested_extent, .. + } => Some(requested_extent), + } + } + + /// Requested native acquisition cadence, or `None` while inactive. + #[must_use] + pub const fn cadence(self) -> Option { + match self { + Self::Inactive => None, + Self::Active { cadence, .. } => Some(cadence), + } + } + + /// Requested cursor composition policy, or `None` while inactive. + #[must_use] + pub const fn cursor(self) -> Option { + match self { + Self::Inactive => None, + Self::Active { cursor, .. } => Some(cursor), } } @@ -217,11 +328,25 @@ impl ScreenCaptureDemand { ( Self::Active { requested_extent: left, + cadence: left_cadence, + cursor: left_cursor, }, Self::Active { requested_extent: right, + cadence: right_cadence, + cursor: right_cursor, }, - ) => Self::active(left.union(right)), + ) => Self::active_with_policy( + left.union(right), + left_cadence.union(right_cadence), + if matches!(left_cursor, ScreenCursorPolicy::Include) + || matches!(right_cursor, ScreenCursorPolicy::Include) + { + ScreenCursorPolicy::Include + } else { + ScreenCursorPolicy::Exclude + }, + ), } } } @@ -292,6 +417,9 @@ pub struct CaptureConfig { /// Target capture frames per second. Default: 30. pub target_fps: u32, + /// Cadence requested from the native acquisition backend. + pub acquisition_cadence: ScreenCaptureCadence, + /// Sector grid columns (horizontal divisions). Default: 8. pub grid_cols: u32, @@ -319,6 +447,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, @@ -331,6 +474,9 @@ impl Default for CaptureConfig { fn default() -> Self { Self { target_fps: 30, + acquisition_cadence: ScreenCaptureCadence::FramesPerSecond( + NonZeroU32::new(30).expect("default capture cadence is nonzero"), + ), grid_cols: 8, grid_rows: 6, analysis_memory_bytes: u64::MAX, @@ -339,6 +485,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(), } @@ -985,6 +1136,7 @@ impl ScreenCaptureInput { &mut self.policy_pixels, elapsed, reset_smoother, + false, &self.surface_resource_owner, )? else { @@ -1027,6 +1179,14 @@ impl ScreenCaptureInput { &self.config } + pub(crate) fn set_led_tone_map_calibration(&mut self, calibration: LedToneMapCalibration) { + self.config.target_led_white_x = calibration.target_white_x(); + self.config.target_led_white_y = calibration.target_white_y(); + self.config.target_led_reference_white_nits = calibration.target_reference_white_nits(); + self.config.target_led_peak_nits = calibration.target_peak_nits(); + self.config.exposure_ev = calibration.exposure_ev(); + } + /// Update the requested publication extent for the next analyzed frame. pub fn set_requested_extent( &mut self, @@ -1558,6 +1718,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 { @@ -1636,6 +1797,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/plan.rs b/crates/hypercolor-core/src/input/screen/plan.rs index 56702fd71..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)] @@ -551,6 +552,44 @@ impl ScreenNativeResourceBindingKey { pub(crate) const fn target_id(&self) -> NonZeroU64 { self.target_id } + + pub(crate) const fn descriptor(&self) -> &Arc { + &self.descriptor + } + + pub(crate) fn matches( + &self, + target_id: NonZeroU64, + descriptor: &ResolvedScreenPublicationDescriptor, + ) -> bool { + self.target_id == target_id && self.descriptor.as_ref() == descriptor + } +} + +#[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 { @@ -600,6 +639,7 @@ impl ScreenExactResource { resource, bytes, native_binding: None, + native_shared_binding: None, }) } @@ -619,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 { @@ -646,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)] @@ -656,6 +718,7 @@ struct ScreenResourceLifetimeInner { transaction_id: ScreenPlanTransactionId, worker_nonce: NonZeroU64, allocation_nonce: NonZeroU64, + finalization: Arc>, resource: ScreenExactResource, retirement_charge: Arc, admission_lease: OnceLock, @@ -708,6 +771,38 @@ 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 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 { @@ -1123,6 +1218,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), @@ -1241,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/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 64156d6db..9bfa909f6 100644 --- a/crates/hypercolor-core/src/input/screen/publication.rs +++ b/crates/hypercolor-core/src/input/screen/publication.rs @@ -9,7 +9,8 @@ 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, CaptureEpoch, CaptureGeometry, CaptureLuminanceContext, CapturePixelFormat, CaptureRotation, @@ -549,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 { @@ -575,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. @@ -613,7 +671,7 @@ impl ScreenNativeTargetPreparation { Ok(ScreenExactResource::try_new_native_target( name, accounting_scope, - self.retained_bytes, + self.retention.exclusive_bytes, binding, )?) } @@ -627,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 @@ -634,8 +700,9 @@ impl ScreenNativeTargetPreparation { BoundScreenNativeTargetPreparation::try_new( binding, self.platform, - self.retained_bytes, + self.retention, lifetime, + shared_lifetime, ) } } @@ -645,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, } } @@ -664,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 @@ -672,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) } } @@ -686,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 { @@ -701,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(), }); } @@ -713,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, }) } @@ -732,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, ) } @@ -762,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), )) } @@ -784,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, @@ -802,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. @@ -825,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. @@ -841,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 @@ -860,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()), + ) } } @@ -878,6 +1037,7 @@ pub struct ScreenNativeExecutionTarget { accepted_api: PlatformGpuApi, physical_gpu_device: ScreenPhysicalGpuDeviceIdentity, max_texture_dimension: NonZeroU32, + color_capabilities: ScreenColorTransformCapabilities, preparer: Arc, } @@ -896,10 +1056,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 { @@ -924,6 +1095,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, @@ -956,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, }) } @@ -985,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(), ); @@ -1015,6 +1201,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() } } @@ -1025,6 +1212,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 } } @@ -1037,6 +1225,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)) } } @@ -1353,7 +1542,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 +1551,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 +1560,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 +1586,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 +1600,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 +1672,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 +1704,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 +1743,7 @@ pub struct ResolvedScreenToneMap { source_luminance: CaptureLuminanceContext, target_luminance: CaptureLuminanceContext, gamut: ScreenGamutMapPolicy, + calibration: LedToneMapCalibration, } impl ResolvedScreenToneMap { @@ -1565,6 +1770,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 +1797,7 @@ pub struct ResolvedScreenColorPipeline { effective_source: Option, output: CaptureColorimetry, transform: ResolvedScreenColorTransform, + calibration: Option, } impl ResolvedScreenColorPipeline { @@ -1606,6 +1818,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 +1841,7 @@ pub struct ScreenProcessingProfile { unknown_color: ScreenUnknownColorPolicy, hdr: ScreenHdrPolicy, gamut: ScreenGamutMapPolicy, + led_tone_map: LedToneMapCalibration, algorithm_revision: NonZeroU32, } @@ -1697,7 +1916,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 +1938,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 +2028,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 +2065,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 +2973,7 @@ fn resolve_color_pipeline( effective_source: None, output: source, transform: ResolvedScreenColorTransform::PreserveEncodedSamples, + calibration: None, }); } ScreenUnknownColorPolicy::Assume(assumption) => { @@ -2804,6 +3045,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 +3072,7 @@ fn resolve_known_color_pipeline( gamut: profile.gamut, } }, + calibration: Some(profile.led_tone_map), }) } @@ -2843,34 +3086,46 @@ 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::Hlg + | 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), @@ -3172,5 +3427,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 1ffaa882a..9a478eb9d 100644 --- a/crates/hypercolor-core/src/input/screen/reducer.rs +++ b/crates/hypercolor-core/src/input/screen/reducer.rs @@ -11,11 +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, + CpuAxisInterpolation, CpuSamplingError, CpuSamplingTransform, CpuSamplingView, + CpuScalarSamplingView, CpuScalarSource, CpuStorageAxis, CpuStorageSpan, + PreparedCpuSamplingPlan, PreparedCpuSamplingRow, PreparedCpuSamplingSource, +}; +use super::tone_map::{ + LED_TONE_MAP_ALGORITHM_REVISION, PreparedLedToneMap, PreparedLedToneMapError, }; use super::{ @@ -30,8 +32,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 +276,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 { @@ -820,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, @@ -1023,7 +1054,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 +1250,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1226,6 +1264,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,18 +1306,83 @@ 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); - } - 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; @@ -1297,6 +1401,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 +1422,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 +1450,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, + view, reduction, + color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1358,9 +1472,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, + view, reduction, + color, self.inner.worker_count, self.inner.tile_rows, publication.output_mut(batch_index)?, @@ -1465,6 +1583,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, &mut plane.scratch, @@ -1551,6 +1670,7 @@ impl CpuReductionExecutor { reduce_prepared_in_pool( &view, reduction, + reduction.color, self.inner.worker_count, self.inner.tile_rows, destination.output_mut(index)?, @@ -1576,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 { @@ -1605,9 +1726,10 @@ fn prepare_physical_reduction( descriptor: &ScreenPhysicalReductionDescriptor, sampling_transform: CpuSamplingTransform, ) -> Result { - if descriptor.algorithm_revision() != CPU_REDUCTION_ALGORITHM_REVISION { + validate_reduced_format(descriptor.target_pixel_format(), true)?; + 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(), }); } @@ -1693,9 +1815,10 @@ 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, tile_rows: NonZeroU32, output: &mut [u8], @@ -1705,7 +1828,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, + ) }) } @@ -1766,9 +1896,10 @@ fn prepare_reduction_tiles( }) } -fn reduce_prepared_tile( - view: &CpuSamplingView<'_>, +fn reduce_prepared_tile( + view: &S, reduction: &PreparedCpuReduction, + color: ReductionColor, tile_index: usize, tile_pixels: usize, mut tile: &mut [u8], @@ -1791,7 +1922,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" })?; @@ -1800,29 +1931,31 @@ fn reduce_prepared_tile( Ok(()) } -fn reduce_prepared_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_row( + view: &S, 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) } } } -fn reduce_prepared_nearest_row( - view: &CpuSamplingView<'_>, +fn reduce_prepared_nearest_row( + view: &S, reduction: &PreparedCpuReduction, + color: ReductionColor, target_y: u32, first_target_x: usize, row: &mut [u8], @@ -1832,19 +1965,23 @@ 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_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))?; - Ok(source_row.read_rgba(fixed)?) + 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, first_target_x: usize, row: &mut [u8], @@ -1856,29 +1993,29 @@ 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) }), } } -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()); @@ -1892,9 +2029,10 @@ 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, first_target_x: usize, row: &mut [u8], @@ -1906,7 +2044,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 +2052,7 @@ fn reduce_prepared_area_row( view, fixed, reduction.sampling.logical_x_area(target_x), - reduction.color, + color, ) }), } @@ -1940,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, @@ -1952,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; } @@ -1982,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, @@ -1991,6 +2135,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 +2273,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 +2300,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 +2318,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, + )?)) } } } @@ -2180,37 +2364,25 @@ 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, - ], - 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, + f64::from(sample[0]), + f64::from(sample[1]), + f64::from(sample[2]), + f64::from(sample[3]), ], + Self::Managed(prepared) => prepared.decode_and_map_source(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]), @@ -2252,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" })?; @@ -2307,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, @@ -2338,7 +2524,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)?, }; @@ -2521,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(), + )); + } }) } @@ -2530,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/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/src/input/screen/tone_map.rs b/crates/hypercolor-core/src/input/screen/tone_map.rs new file mode 100644 index 000000000..1dfe74062 --- /dev/null +++ b/crates/hypercolor-core/src/input/screen/tone_map.rs @@ -0,0 +1,1094 @@ +//! 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; + +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] { + 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(source[3]), + ] + } + + /// 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::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 => { + unreachable!("prepared target transfer remains SDR") + } + }; + [ + encode(linear[0]), + encode(linear[1]), + encode(linear[2]), + encode_byte(linear[3] as f32), + ] + } + + fn decode_source_channel(self, encoded: f32) -> f32 { + match self.source_transfer { + 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") + } + } + } +} + +/// 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(all(test, feature = "macos-capture-fixtures"))] + 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::Rec709 | CaptureTransferFunction::Rec2020, + CaptureDynamicRange::Standard + ) | ( + CaptureTransferFunction::Pq + | CaptureTransferFunction::Hlg + | CaptureTransferFunction::Linear, + CaptureDynamicRange::High + ) + ) + } else { + matches!( + (transfer, dynamic_range), + ( + CaptureTransferFunction::Srgb | CaptureTransferFunction::Linear, + CaptureDynamicRange::Standard + ) | ( + CaptureTransferFunction::Rec709 | CaptureTransferFunction::Rec2020, + 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 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; + 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") + } + + 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( + 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]); + 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] + 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/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/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/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/status.rs b/crates/hypercolor-core/src/input/status.rs index 6f25f89dd..c979fdac2 100644 --- a/crates/hypercolor-core/src/input/status.rs +++ b/crates/hypercolor-core/src/input/status.rs @@ -101,6 +101,336 @@ 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, + /// Whether another process holds the secure-input assertion (Secure + /// Keyboard Entry). While true, keyboard events are withheld from the + /// event tap and held-key state has been cleared through a gap. + pub secure_input_active: bool, + /// 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>, + /// 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, + /// Event-tap callback entry through canonical core publication. + pub callback_to_publication_timing: Option, +} + +/// Bounded latency distribution retained without raw samples. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MacosTimingStatus { + /// Number of observations in the distribution. + pub sample_count: u64, + /// Saturating sum of all observations. + pub total_ns: u64, + /// Exact maximum observation. + pub max_ns: u64, + /// Bounded 95th-percentile upper estimate. + pub p95_ns: u64, + /// Bounded 99th-percentile upper estimate. + pub p99_ns: u64, +} + +/// Screen-capture stage and end-to-end latency distributions. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MacosScreenTimingStatus { + /// Native callback execution. + pub callback: MacosTimingStatus, + /// Native surface validation and retain work. + pub retain: MacosTimingStatus, + /// Latest-value worker enqueue work. + pub enqueue: MacosTimingStatus, + /// Retained native-sample decode and validation work. + pub conversion: MacosTimingStatus, + /// Core CPU reduction work. + pub cpu_reduction: MacosTimingStatus, + /// Renderer IOSurface import work. + pub native_import: MacosTimingStatus, + /// Native reduction encode and queue-submission work. + pub native_reduction_submit: MacosTimingStatus, + /// Decoded native-frame publication work. + pub publication: MacosTimingStatus, + /// Capture timestamp through exact native publication. + pub capture_to_native_publication: MacosTimingStatus, + /// Capture timestamp through CPU-converted publication. + pub capture_to_converted_publication: MacosTimingStatus, +} + +/// 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, + /// Privacy-safe bounded label for the selected content style. + pub selection_diagnostic_label: Option>, + /// Monotonic native selection lifecycle revision. + pub selection_revision: u64, + /// 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>, + /// Stage and end-to-end timing distributions for the active session. + pub timing: MacosScreenTimingStatus, + /// 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 retained native-frame decode and validation time. + pub conversion_total_ns: u64, + /// Maximum retained native-frame decode and validation 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. +#[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 { @@ -295,6 +625,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. @@ -315,6 +647,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, } @@ -335,6 +669,7 @@ impl SourceStatus { configured, consented, demanded, + active_consumer_count: 0, state: SourceState::Stopped, freshness: SourceFreshness::NotApplicable, source_graph_generation: 0, @@ -345,6 +680,7 @@ impl SourceStatus { denied_resource_count: 0, issue: None, freshness_issue: None, + platform: None, retired: false, } } @@ -919,6 +1255,49 @@ 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 + /// + /// 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 @@ -1008,6 +1387,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); @@ -1202,6 +1582,22 @@ 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, + 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/src/input/traits.rs b/crates/hypercolor-core/src/input/traits.rs index 1b98484c4..408f65702 100644 --- a/crates/hypercolor-core/src/input/traits.rs +++ b/crates/hypercolor-core/src/input/traits.rs @@ -5,15 +5,140 @@ //! 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}; -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; +/// 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. +#[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. +#[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< + 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. @@ -114,6 +239,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 +283,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; @@ -171,7 +298,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, @@ -199,6 +326,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. @@ -211,6 +342,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", } @@ -289,6 +422,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 +445,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 +472,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 +480,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 { @@ -494,6 +665,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 @@ -819,6 +1000,32 @@ 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, + _designated_requirement_hash: Option>, + ) -> anyhow::Result<()> { + 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 @@ -827,4 +1034,24 @@ 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 + } + + #[cfg(target_os = "macos")] + fn macos_screenshot_reference_action(&self) -> Option { + None + } } 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/audio_pipeline_tests.rs b/crates/hypercolor-core/tests/audio_pipeline_tests.rs index 3a4e99ed6..eaf0771f0 100644 --- a/crates/hypercolor-core/tests/audio_pipeline_tests.rs +++ b/crates/hypercolor-core/tests/audio_pipeline_tests.rs @@ -1060,7 +1060,11 @@ fn missing_audio_device_reports_recoverable_device_loss() { .start() .expect("missing hardware should enter recoverable degraded mode"); - let deadline = Instant::now() + Duration::from_secs(2); + // Discovery runs on a background thread against real CoreAudio; the + // bound only asserts it terminates, so it carries headroom for a + // fully loaded machine (the whole suite in parallel starves a tight + // deadline; solo runs finish in well under a second). + let deadline = Instant::now() + Duration::from_secs(10); let snapshot = loop { let _ = input.sample().expect("recovery polling should succeed"); let snapshot = status.snapshot(); 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-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-core/tests/capture_color_contract_tests.rs b/crates/hypercolor-core/tests/capture_color_contract_tests.rs index cdd60722a..bbecff498 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)); @@ -653,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] @@ -698,6 +914,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/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 d8ace633a..933710544 100644 --- a/crates/hypercolor-core/tests/input_tests.rs +++ b/crates/hypercolor-core/tests/input_tests.rs @@ -11,15 +11,18 @@ use hypercolor_core::input::screen::WindowsScreenCaptureInput; #[cfg(target_os = "linux")] use hypercolor_core::input::screen::{CaptureConfig, WaylandScreenCaptureInput}; use hypercolor_core::input::screen::{ - PixelExtent, ScreenAdmissionCapacity, ScreenAnalysisResourcePlan, ScreenCaptureDemand, - ScreenCaptureInput, + PixelExtent, ScreenAdmissionCapacity, ScreenAnalysisResourcePlan, ScreenCaptureCadence, + ScreenCaptureDemand, ScreenCaptureInput, ScreenCursorPolicy, }; 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, MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosScreenTimingStatus, 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}; use hypercolor_core::types::event::{InputButtonState, InputEvent, TimedInputEvent, ZoneColors}; @@ -44,6 +47,60 @@ struct StatusAwareScreenSource { session_sink: Arc>>, } +#[derive(Debug, PartialEq)] +struct RetainedMacosState { + owner: MacosCapabilityOwner, + conflict: Option, + designated_requirement_hash: 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, + 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(()) + } + + 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 { @@ -125,6 +182,25 @@ fn screen_capture_demand_unions_each_axis_without_a_resolution_cap() { assert!(ScreenCaptureDemand::try_active(1_280, 0).is_err()); } +#[test] +fn screen_capture_demand_unions_native_cadence_and_cursor_policy() { + let fixed = ScreenCaptureDemand::active_with_policy( + PixelExtent::new(640, 480).expect("fixture extent is valid"), + ScreenCaptureCadence::frames_per_second(30).expect("fixture cadence is valid"), + ScreenCursorPolicy::Exclude, + ); + let native = ScreenCaptureDemand::active_with_policy( + PixelExtent::new(1_280, 720).expect("fixture extent is valid"), + ScreenCaptureCadence::NativeRefresh, + ScreenCursorPolicy::Include, + ); + let union = fixed.union(native); + + assert_eq!(union.requested_extent(), PixelExtent::new(1_280, 720).ok()); + assert_eq!(union.cadence(), Some(ScreenCaptureCadence::NativeRefresh)); + assert_eq!(union.cursor(), Some(ScreenCursorPolicy::Include)); +} + #[derive(Default)] struct DemandTransitionState { demand: ScreenCaptureDemand, @@ -990,6 +1066,47 @@ 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, + designated_requirement_hash: None, + metal4: false, + })); + let mut manager = InputManager::new(); + manager + .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) + .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), + designated_requirement_hash: Some(Arc::from("designated-launchd")), + metal4: true, + } + ); +} + #[test] fn failing_source_reports_error() { let mut src = FailingSource; @@ -1476,7 +1593,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()); } @@ -2452,6 +2569,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)] @@ -3055,6 +3207,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!( @@ -3224,6 +3419,91 @@ 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, + selection_diagnostic_label: None, + selection_revision: 0, + tahoe: MacosTahoeCapabilities { + host_architecture: MacosArchitecture::AppleSilicon, + translated_process: false, + content_tone_mapping_info: true, + metal4: false, + }, + 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, + timing: MacosScreenTimingStatus::default(), + 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 + .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(); 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-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-core/tests/macos_host_input_tests.rs b/crates/hypercolor-core/tests/macos_host_input_tests.rs new file mode 100644 index 000000000..c7c94ca90 --- /dev/null +++ b/crates/hypercolor-core/tests/macos_host_input_tests.rs @@ -0,0 +1,616 @@ +//! 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 std::sync::Arc; + + use hypercolor_core::input::{ + InputData, InputManager, InputSource, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosHostInput, MacosInputFixtureBackend, + MacosProtectedSourceState, SourcePlatformStatus, 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); + 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() + .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" + ); + 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_macos_daemon_ownership( + MacosCapabilityOwner::AppSidecar, + Some(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }), + Some(Arc::from("designated-app-sidecar")), + ) + .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); + assert_eq!( + platform.owner_conflict.as_deref(), + Some(&MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }) + ); + assert_eq!( + platform.owner_designated_requirement_hash.as_deref(), + Some("designated-app-sidecar") + ); + } + + #[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") + ); + } + + #[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 + .execute() + .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); + 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); + } + } + + #[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 new file mode 100644 index 000000000..50d8926b2 --- /dev/null +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -0,0 +1,886 @@ +//! ScreenCaptureKit core worker fixture contracts. + +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use std::{num::NonZeroU64, num::NonZeroUsize}; + +use hypercolor_core::effect::builtin::ScreenCastRenderer; +use hypercolor_core::effect::{EffectRenderer, FrameDataSources, FrameInput}; +use hypercolor_core::input::screen::{ + CaptureConfig, MacosScreenCaptureFixture, PixelExtent, ScreenAdmissionCapacity, + ScreenAnalysisComputeCapacity, ScreenByteAdmissionCoordinator, ScreenCaptureCadence, + ScreenCaptureDemand, ScreenComputeCapacityPolicy, ScreenCursorPolicy, +}; +use hypercolor_core::input::{ + InputData, InputManager, InputSource, InteractionData, MacosArchitecture, + MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosProtectedSourceState as CoreProtectedSourceState, MacosSelectionState, + SourcePlatformStatus, +}; +use hypercolor_macos_capture::{ + MacosAttachment, MacosCaptureCadence, MacosCaptureCapabilities, MacosCaptureColorimetry, + MacosCaptureDynamicRange, MacosCaptureError, MacosCaptureFrame, MacosCapturePixelFormat, + MacosCaptureSelection, MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, + MacosDeliveredFrameMetadata, MacosFrameDecoder, MacosFrameEvent, MacosHostArchitecture, + MacosPixelExtent, MacosPointRect, MacosProtectedSourceState, MacosRawCapturePlane, + MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, MacosRuntimeCapability, + MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, MacosTransferFunction, +}; +use hypercolor_types::audio::AudioData; +use hypercolor_types::canvas::Rgba; +use hypercolor_types::sensor::SystemSnapshot; + +const BGRA8: u32 = 0x4247_5241; +const RGBA16_FLOAT: u32 = 0x5247_6841; + +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_hdr_frame(epoch: u64, pixel: [u8; 8]) -> MacosCaptureFrame { + let extent = MacosPixelExtent::new(4, 2).expect("fixture extent is valid"); + let bytes = Arc::<[u8]>::from(pixel.repeat(8)); + let color = MacosCaptureColorimetry { + primaries: MacosColorPrimaries::DisplayP3, + transfer: MacosTransferFunction::Linear, + matrix: None, + range: MacosColorRange::Full, + chroma_location: None, + }; + let delivered = MacosDeliveredFrameMetadata::new( + MacosCapturePixelFormat::Rgba16Float, + color, + Some(203.0), + Some(2.0), + ) + .expect("fixture HDR delivery is valid"); + let surface = MacosCaptureSurface::new_cpu_fixture(8, 64, epoch, vec![bytes]) + .expect("fixture surface is valid") + .with_delivery_metadata(delivered) + .expect("fixture delivery matches its surface"); + let sample = MacosRawCaptureSample { + frame: Some(MacosRawCompleteFrame { + storage_extent: extent, + planes: vec![MacosRawCapturePlane { + index: 0, + extent, + bytes_per_row: 32, + length_bytes: 64, + }], + pixel_format_fourcc: RGBA16_FLOAT, + color, + 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::Rgba16Float); + *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"), + } + } +} + +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"), + } + } +} + +fn canvas_bytes(data: &hypercolor_core::input::ScreenData) -> Vec { + data.canvas_downscale + .as_ref() + .expect("fixture screen data has a compatibility surface") + .rgba_bytes() + .to_vec() +} + +fn wait_for_canvas_change( + source: &mut impl InputSource, + previous: &[u8], +) -> hypercolor_core::input::ScreenData { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match source.sample().expect("fixture sample succeeds") { + InputData::Screen(data) if canvas_bytes(&data) != previous => return data, + InputData::Screen(_) | InputData::None if Instant::now() < deadline => { + thread::yield_now(); + } + InputData::Screen(_) | InputData::None => { + panic!("fixture worker did not publish changed compatibility bytes"); + } + _ => panic!("macOS fixture published the wrong input kind"), + } + } +} + +#[test] +fn native_refresh_hdr_and_cursor_demand_reaches_capture_and_screen_cast() { + let config = CaptureConfig { + target_fps: 60, + grid_cols: 1, + grid_rows: 1, + smoothing_alpha: 1.0, + ..CaptureConfig::default() + }; + let (mut source, fixture) = fixture_source(config); + source.start().expect("fixture source starts idle"); + source + .set_screen_capture_demand(ScreenCaptureDemand::active_with_policy( + PixelExtent::new(4, 2).expect("fixture demand is valid"), + ScreenCaptureCadence::NativeRefresh, + ScreenCursorPolicy::Include, + )) + .expect("native-refresh demand activates"); + let request = fixture.stream_request(); + assert_eq!(request.cadence, MacosCaptureCadence::NativeRefresh); + assert!(request.cursor_composed); + assert_eq!(request.dynamic_range, MacosCaptureDynamicRange::Hdr); + + fixture.set_selection(MacosCaptureSelection::Display { + source_id: Arc::from("display:hdr-effect-fixture"), + }); + fixture.publish(fixture_hdr_frame(1, [0x00, 0x3c, 0, 0, 0, 0, 0x00, 0x3c])); + let screen = wait_for_screen(&mut source); + assert!(screen.canvas_downscale.is_some()); + + let audio = AudioData::silence(); + let interaction = InteractionData::default(); + let sensors = SystemSnapshot::empty(); + let mut renderer = ScreenCastRenderer::new(); + let canvas = renderer + .tick(&FrameInput { + time_secs: 0.0, + delta_secs: 1.0 / 60.0, + frame_number: 0, + audio: &audio, + interaction: &interaction, + screen: Some(&screen), + sensors: &sensors, + sources: FrameDataSources::default(), + canvas_width: 4, + canvas_height: 2, + }) + .expect("ScreenCast consumes the derived HDR compatibility surface"); + let pixel = canvas.get_pixel(0, 0); + assert!(pixel.r > pixel.g && pixel.r > pixel.b); + assert_ne!(pixel, Rgba::BLACK); +} + +#[test] +fn macos_exposes_installed_compute_capacity_for_cpu_fallback() { + let analysis = ScreenAnalysisComputeCapacity::new( + NonZeroUsize::new(2).expect("fixture worker count is nonzero"), + NonZeroU64::new(1_000_000).expect("fixture throughput is nonzero"), + ); + let policy = ScreenComputeCapacityPolicy::calibrated( + analysis, + NonZeroU64::new(2_000_000).expect("fixture exact throughput is nonzero"), + ); + let admission = + ScreenByteAdmissionCoordinator::new(ScreenAdmissionCapacity::new(u64::MAX, u64::MAX)); + let (source, _) = MacosScreenCaptureFixture::source_with_compute_capacity_policy( + CaptureConfig::default(), + admission, + policy, + ); + + assert_eq!(source.screen_analysis_compute_capacity(), Some(analysis)); +} + +#[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); + 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!( + source.protected_state(), + MacosProtectedSourceState::ReadyIdle + ); + source + .set_macos_daemon_ownership( + MacosCapabilityOwner::AppSidecar, + Some(MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }), + Some(Arc::from("designated-app-sidecar")), + ) + .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") + .set_source_graph_generation(1); + 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.owner_conflict.as_deref(), + Some(&MacosDaemonOwnerConflict { + active: MacosCapabilityOwner::AppSidecar, + contender: MacosCapabilityOwner::HomebrewService, + observed_at_ms: 42, + }) + ); + assert_eq!(platform.selection, MacosSelectionState::None); + assert_eq!(platform.selection_diagnostic_label, None); + assert_eq!(platform.selection_revision, 0); + 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))); + + source + .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::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.selection_revision, 1); + 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); + 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); + 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"); + }; + 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() + .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.selection_revision, 2); + 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) + .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); + assert_eq!(platform.tahoe_selection, None); + assert_eq!(platform.selection_revision, 3); +} + +#[test] +fn inactive_demand_deactivates_without_reconfiguring_the_native_request() { + let (mut source, fixture) = fixture_source(CaptureConfig::default()); + 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"); + let request = fixture.stream_request(); + let request_transitions = fixture.stream_request_transitions(); + let active_transitions = fixture.active_transitions(); + + source + .set_screen_capture_demand(ScreenCaptureDemand::Inactive) + .expect("inactive demand commits"); + + assert!(!fixture.is_active()); + assert_eq!(fixture.stream_request(), request); + assert_eq!(fixture.stream_request_transitions(), request_transitions); + assert_eq!(fixture.active_transitions(), active_transitions + 1); +} + +#[test] +fn rejected_demand_request_preserves_the_committed_worker_and_demand() { + let config = CaptureConfig { + grid_cols: 2, + grid_rows: 1, + smoothing_alpha: 1.0, + ..CaptureConfig::default() + }; + let (mut source, fixture) = fixture_source(config); + source.start().expect("fixture source starts idle"); + let committed = + ScreenCaptureDemand::active(PixelExtent::new(4, 2).expect("fixture demand is valid")); + source + .set_screen_capture_demand(committed) + .expect("initial demand activates"); + let request = fixture.stream_request(); + fixture.reject_next_stream_request(); + + let error = source + .set_screen_capture_demand(ScreenCaptureDemand::active_with_policy( + PixelExtent::new(4, 2).expect("fixture demand is valid"), + ScreenCaptureCadence::NativeRefresh, + ScreenCursorPolicy::Include, + )) + .expect_err("native request failure rejects the demand transaction"); + + assert!(error.to_string().contains("fixture rejected")); + assert_eq!(source.screen_capture_demand(), committed); + assert_eq!(fixture.stream_request(), request); + assert!(fixture.is_active()); + fixture.publish(fixture_frame(1, [0, 0, 255, 255])); + assert_eq!(wait_for_screen(&mut source).grid_width, 2); +} + +#[test] +fn rejected_reconfiguration_request_preserves_the_committed_worker_config() { + 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("initial demand activates"); + let request = fixture.stream_request(); + fixture.reject_next_stream_request(); + + source + .reconfigure_screen_capture(&CaptureConfig { + target_fps: 30, + grid_cols: 1, + ..config + }) + .expect_err("native request failure rejects worker reconfiguration"); + + assert_eq!(fixture.stream_request(), request); + assert!(fixture.is_active()); + fixture.publish(fixture_frame(1, [0, 255, 0, 255])); + assert_eq!(wait_for_screen(&mut source).grid_width, 2); +} + +#[test] +fn asynchronous_demand_request_failure_preserves_the_committed_worker_and_demand() { + let config = CaptureConfig { + grid_cols: 2, + grid_rows: 1, + smoothing_alpha: 1.0, + ..CaptureConfig::default() + }; + let (mut source, fixture) = fixture_source(config); + source.start().expect("fixture source starts idle"); + let committed = + ScreenCaptureDemand::active(PixelExtent::new(4, 2).expect("fixture demand is valid")); + source + .set_screen_capture_demand(committed) + .expect("initial demand activates"); + let request = fixture.stream_request(); + fixture.defer_next_stream_request(); + + let thread = thread::scope(|scope| { + let update = scope.spawn(|| { + source.set_screen_capture_demand(ScreenCaptureDemand::active_with_policy( + PixelExtent::new(4, 2).expect("fixture demand is valid"), + ScreenCaptureCadence::NativeRefresh, + ScreenCursorPolicy::Include, + )) + }); + let deadline = Instant::now() + Duration::from_secs(2); + while fixture.pending_stream_request().is_none() { + assert!( + Instant::now() < deadline, + "stream request never reached pending" + ); + thread::yield_now(); + } + assert_eq!(fixture.stream_request(), request); + fixture.fail_pending_stream_request(); + update.join().expect("demand update thread joins") + }); + + let error = thread.expect_err("async request failure rejects the transaction"); + assert!(format!("{error:#}").contains("failed asynchronously")); + assert_eq!(source.screen_capture_demand(), committed); + assert_eq!(fixture.stream_request(), request); + fixture.publish(fixture_frame(1, [0, 0, 255, 255])); + assert_eq!(wait_for_screen(&mut source).grid_width, 2); +} + +#[test] +fn asynchronous_reconfiguration_commits_after_native_activation() { + 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("initial demand activates"); + let request = fixture.stream_request(); + fixture.defer_next_stream_request(); + + thread::scope(|scope| { + let next = CaptureConfig { + target_fps: 30, + grid_cols: 1, + ..config + }; + let source = &mut source; + let update = scope.spawn(move || source.reconfigure_screen_capture(&next)); + let deadline = Instant::now() + Duration::from_secs(2); + while fixture.pending_stream_request().is_none() { + assert!( + Instant::now() < deadline, + "stream request never reached pending" + ); + thread::yield_now(); + } + assert_eq!(fixture.stream_request(), request); + fixture.commit_pending_stream_request(); + update + .join() + .expect("reconfiguration thread joins") + .expect("native activation commits reconfiguration"); + }); + + fixture.publish(fixture_frame(1, [0, 255, 0, 255])); + assert_eq!(wait_for_screen(&mut source).grid_width, 1); +} + +#[test] +fn processing_reconfiguration_changes_legacy_hdr_bytes_at_a_frame_boundary() { + let config = CaptureConfig { + target_fps: 60, + grid_cols: 1, + 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"); + let encoded = [0x00, 0x38, 0x00, 0x3c, 0x00, 0x40, 0x00, 0x3c]; + fixture.publish(fixture_hdr_frame(1, encoded)); + let before = canvas_bytes(&wait_for_screen(&mut source)); + + source + .reconfigure_screen_processing(&CaptureConfig { + exposure_ev: -2.0, + ..config + }) + .expect("valid processing calibration commits on the worker"); + fixture.publish(fixture_hdr_frame(2, encoded)); + let after = canvas_bytes(&wait_for_canvas_change(&mut source, &before)); + + assert_ne!(&after[..4], &before[..4]); + assert!(after[0] < before[0]); + assert!(after[1] < before[1]); + assert!(after[2] < before[2]); + assert_eq!(after[3], 255); +} + +#[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 { + 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"); + 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_grid_width(&mut source, 1); + assert_eq!(data.grid_width, 1); + 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.execute().expect("fixture authorization succeeds")); + picker.execute().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); +} + +#[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()); + 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()), + Some(Arc::from("designated-homebrew")), + ) + .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_eq!( + platform.owner_designated_requirement_hash.as_deref(), + Some("designated-homebrew") + ); + assert!(platform.tahoe.metal4); +} 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-core/tests/screen_cpu_branch_processing_tests.rs b/crates/hypercolor-core/tests/screen_cpu_branch_processing_tests.rs index a2f8d243a..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") + } } } @@ -364,6 +378,7 @@ fn smoothed_color( CaptureTransferFunction::Srgb, Duration::ZERO, false, + false, ) .expect("reference baseline stages"); assert!(smoother.commit_staged()); @@ -376,6 +391,7 @@ fn smoothed_color( CaptureTransferFunction::Srgb, elapsed, false, + false, ) .expect("reference response stages"); colors[0] @@ -449,6 +465,7 @@ fn surface_letterbox_fill_modes_preserve_content_and_alpha() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("Surface fill stages"); @@ -541,6 +558,7 @@ fn surface_fill_is_exact_after_tuning_for_rgba_and_bgra() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("processed Surface stages"); @@ -605,6 +623,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"); @@ -619,6 +638,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"); @@ -640,6 +660,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"); @@ -654,6 +675,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"); @@ -698,6 +720,7 @@ fn detected_bars_reflow_without_stretching_content_aspect() { &fixture.physical, &physical, now, + false, &mut publication, ) .expect("detected content stages"); @@ -755,6 +778,7 @@ fn surface_materializer_rejects_substituted_physical_storage_transactionally() { &fixture.physical, &[0; 4], now, + false, &mut publication, ), Err(CpuSurfaceMaterializationError::PhysicalByteLengthMismatch { @@ -798,6 +822,7 @@ fn rejected_moving_bars_preserve_committed_surface_history() { &fixture.physical, &horizontal, start, + false, &mut first, ) .expect("first bar state stages"); @@ -819,6 +844,7 @@ fn rejected_moving_bars_preserve_committed_surface_history() { &fixture.physical, &vertical, start + Duration::from_millis(16), + false, &mut rejected, ) .expect("moving bars stage"); @@ -837,6 +863,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"); @@ -890,6 +917,7 @@ fn dynamic_crop_compacts_the_effective_grid_and_reuses_exact_scratch() { &fixture.physical, &pixels, now, + false, &mut publication, ) .expect("dynamic grid stages"); @@ -965,6 +993,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[0, 0, 0, 255], started, + false, &mut initial, ) .expect("initial frame stages"); @@ -981,6 +1010,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[255, 255, 255, 255], next_at, + false, &mut rejected, ) .expect("candidate frame stages"); @@ -999,6 +1029,7 @@ fn rejected_publication_preserves_committed_smoothing_history() { &fixture.physical, &[255, 255, 255, 255], next_at, + false, &mut retry, ) .expect("retry frame stages"); @@ -1052,6 +1083,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"); @@ -1082,6 +1114,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"); @@ -1121,6 +1154,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[255, 0, 0, 255], now, + false, &mut publication, ), Err(CpuZoneMaterializationError::PlanGenerationMismatch { .. }) @@ -1133,6 +1167,7 @@ fn plan_generation_fences_state_and_reset_is_deterministic() { &fixture.physical, &[0, 0, 0, 255], now, + false, &mut baseline, ) .expect("baseline stages"); @@ -1148,6 +1183,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"); @@ -1171,6 +1207,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"); @@ -1243,6 +1280,7 @@ fn stateful_materialization_supports_rgba_bgra_srgb_and_linear() { &fixture.physical, &pixels, now, + false, &mut publication, ) .expect("stateful transfer stages"); @@ -1299,6 +1337,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &rgba.physical, &[0, 0, 0, 255], started, + false, &mut rgba_initial, ) .expect("RGBA baseline stages"); @@ -1313,6 +1352,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &bgra.physical, &[255, 255, 255, 255], started, + false, &mut bgra_initial, ) .expect("BGRA baseline stages"); @@ -1329,6 +1369,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &rgba.physical, &[128, 128, 128, 255], later, + false, &mut rgba_next, ) .expect("RGBA next frame stages"); @@ -1342,6 +1383,7 @@ fn distinct_descriptors_keep_independent_temporal_history() { &bgra.physical, &[128, 128, 128, 255], later, + false, &mut bgra_next, ) .expect("BGRA next frame stages"); @@ -1369,6 +1411,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 +1426,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 +1462,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 +1475,141 @@ 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 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..2511f764a 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"); @@ -831,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_cpu_reducer_tests.rs b/crates/hypercolor-core/tests/screen_cpu_reducer_tests.rs index a94320561..b730545a7 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, @@ -154,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") + } } } } @@ -234,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); } @@ -588,7 +848,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 +858,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 +876,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( @@ -670,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") + } } } } @@ -791,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-core/tests/screen_gpu_publication_reclamation_tests.rs b/crates/hypercolor-core/tests/screen_gpu_publication_reclamation_tests.rs index 95c436bd7..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, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, - ScreenProcessingProfile, ScreenPublicationColorimetry, ScreenPublicationExecutor, - ScreenPublicationExecutorRequest, ScreenPublicationHealth, ScreenPublicationHub, - ScreenPublicationHubError, ScreenPublicationKind, ScreenPublicationMetadata, - ScreenPublicationRequest, ScreenPublicationSlotPolicy, ScreenResourceApi, ScreenResourceKind, - ScreenResourceLifetime, ScreenSourceReflection, ScreenSourceSelector, ScreenWorkerBinding, - ScreenWorkerExactLedgerBuilder, SourceScale, + ScreenNativePreparationPayload, ScreenNativeRetentionQuote, ScreenNativeTargetBindingError, + ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, ScreenNativeTargetResourceError, + ScreenNativeWorkPayload, ScreenPhysicalGpuDeviceIdentity, ScreenPlanBuilder, + ScreenProcessingProfile, ScreenProcessingProfileConfig, ScreenPublicationColorimetry, + ScreenPublicationExecutor, ScreenPublicationExecutorRequest, ScreenPublicationHealth, + ScreenPublicationHub, ScreenPublicationHubError, ScreenPublicationKind, + ScreenPublicationMetadata, ScreenPublicationRequest, ScreenPublicationSlotPolicy, + 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), ); @@ -375,6 +392,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 +446,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 +517,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 +614,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 +645,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 +665,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( @@ -655,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(); @@ -878,6 +1202,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..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"] @@ -56,10 +57,33 @@ 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, } +struct SplitCountingPreparer { + calls: Arc, +} + +struct DispatchCountingPreparer { + quote_calls: Arc, + prepare_calls: Arc, +} + impl ScreenNativeTargetPreparer for CountingPreparer { fn quote_retained_bytes( &self, @@ -86,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, @@ -94,6 +179,8 @@ struct WrongOutputPreparer { struct MisquotingPreparer; +struct MisquotingSharedPreparer; + impl ScreenNativeTargetPreparer for MisquotingPreparer { fn quote_retained_bytes( &self, @@ -119,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, @@ -318,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); @@ -339,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( @@ -357,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 @@ -380,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 = @@ -434,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, @@ -529,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", ) 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 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() ); } 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-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-daemon/Cargo.toml b/crates/hypercolor-daemon/Cargo.toml index d9f8afd7d..4a6fa3a28 100644 --- a/crates/hypercolor-daemon/Cargo.toml +++ b/crates/hypercolor-daemon/Cargo.toml @@ -16,6 +16,10 @@ path = "src/main.rs" name = "persistence_flush_tests" required-features = ["persistence-test-hooks"] +[[test]] +name = "macos_tcc_canary_tests" +required-features = ["macos-tcc-canary"] + [lints] workspace = true @@ -24,6 +28,7 @@ allocation-contract-tests = [] builtin-drivers = ["dep:hypercolor-driver-builtin", "hypercolor-driver-builtin/default"] media-lottie = ["hypercolor-core/media-lottie"] media-video = ["hypercolor-core/media-video"] +macos-tcc-canary = ["screen-capture"] persistence-test-hooks = [] wgpu = [ "dep:wgpu", @@ -31,14 +36,27 @@ 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] 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 } @@ -48,6 +66,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } single-instance = "0.3.3" socket2 = "0.6.3" tokio = { workspace = true } @@ -72,6 +92,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 } @@ -83,6 +104,21 @@ pollster = { workspace = true, optional = true } sd-notify = "0.4" sysinfo = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10.1" +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 = { workspace = true } +objc2-app-kit = { workspace = true, features = ["NSApplication", "NSResponder", "NSRunningApplication"] } +objc2-core-foundation = { workspace = true, features = ["std", "CFRunLoop"] } +security-framework = "3.7.0" +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/api/capture.rs b/crates/hypercolor-daemon/src/api/capture.rs index 8f48e9ffa..94ef34156 100644 --- a/crates/hypercolor-daemon/src/api/capture.rs +++ b/crates/hypercolor-daemon/src/api/capture.rs @@ -1,63 +1,404 @@ //! 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, 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::RequestAuthContext; -/// `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 { +pub(crate) fn protected_control_rejection(auth_context: RequestAuthContext) -> Option { + (!auth_context.can_protected_control()) + .then(|| ApiError::forbidden("Protected capture access requires a control credential")) +} + +const fn grant_owner(owner: MacosCapabilityOwner) -> ProtectedSourceGrantOwner { + match owner { + 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(owner: ProtectedSourceActionOwner) -> ProtectedSourceGrantOwner { + match owner { + 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(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), + ) +} + +#[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. +#[utoipa::path( + post, + path = "/api/v1/input/authorize", + responses( + ( + status = 200, + description = "Input Monitoring authorization result", + body = crate::api::envelope::ApiResponse + ), + ( + status = 403, + description = "Control credential required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn authorize_input_monitoring( + State(state): State>, + Extension(auth_context): Extension, +) -> Response { + if let Some(response) = protected_control_rejection(auth_context) { + return 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.resolved_input_authorization_action() + }; + let Some(action) = action else { + return ApiError::validation("No Input Monitoring authorization action is available"); + }; + 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(CaptureAuthorizationResponse { + authorized, + grant_owner: protected_action_owner(grant_owner), + }) + } + 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. +#[utoipa::path( + post, + path = "/api/v1/capture/authorize", + responses( + ( + status = 200, + description = "Screen Recording authorization result", + body = crate::api::envelope::ApiResponse + ), + ( + status = 403, + description = "Control credential required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn authorize_screen_recording( + State(state): State>, + Extension(auth_context): Extension, +) -> Response { + if let Some(response) = protected_control_rejection(auth_context) { + 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 { return ApiError::validation( - "Screen capture is disabled; enable capture.enabled before picking a source", + "Screen capture is disabled; enable capture.enabled before authorizing", ); } + let action = { + let input_manager = state.input_manager.lock().await; + input_manager.resolved_screen_authorization_action() + }; + let Some(action) = action else { + return ApiError::validation("No Screen Recording authorization action is available"); + }; + 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(CaptureAuthorizationResponse { + authorized, + grant_owner: protected_action_owner(grant_owner), + }) + } + 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. +/// +/// 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 = "Control credential required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn pick_capture_source( + State(state): State>, + Extension(auth_context): Extension, +) -> Response { + if let Some(response) = protected_control_rejection(auth_context) { + return response; + } + let Some(manager) = state.config_manager.as_ref() else { + return ApiError::internal("Config manager unavailable in this runtime"); + }; - let mut input_manager = state.input_manager.lock().await; - if !input_manager.has_screen_source() { + let expected = manager.get(); + if !expected.capture.enabled { return ApiError::validation( - "No screen capture source is registered; restart the daemon or re-enable capture", + "Screen capture is disabled; enable capture.enabled before picking a source", ); } - if let Err(error) = input_manager.reselect_screen_source() { + 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", + ); + } + 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"); + }; + 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); + } + }; + #[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}")); } + #[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, + )) + }); + } + info!("Screen capture source picker requested"); - ApiResponse::ok(serde_json::json!({ "picking": true })) -} - -/// 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, + ApiResponse::ok(CapturePickerResponse { + picking: true, + grant_owner: protected_action_owner(grant_owner), + }) } /// `GET /api/v1/capture/monitors` — Display outputs capture can address. @@ -65,7 +406,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 = "Control credential required", + body = crate::api::envelope::ApiErrorResponse + ) + ), + tag = "capture" +)] +pub(crate) async fn list_capture_monitors( + Extension(auth_context): Extension, +) -> Response { + if let Some(response) = protected_control_rejection(auth_context) { + return response; + } let monitors: Vec = hypercolor_core::input::screen::available_monitors() .into_iter() .map(|monitor| CaptureMonitor { @@ -81,3 +444,110 @@ pub async fn list_capture_monitors() -> Response { ApiResponse::ok(monitors) } + +#[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; + + #[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() { + assert_eq!( + [ + MacosCapabilityOwner::AppSidecar, + MacosCapabilityOwner::App, + MacosCapabilityOwner::LaunchdService, + MacosCapabilityOwner::HomebrewService, + MacosCapabilityOwner::Broker, + MacosCapabilityOwner::Standalone, + ] + .map(grant_owner), + [ + ProtectedSourceGrantOwner::AppSidecar, + ProtectedSourceGrantOwner::App, + ProtectedSourceGrantOwner::LaunchdService, + ProtectedSourceGrantOwner::HomebrewService, + ProtectedSourceGrantOwner::Broker, + ProtectedSourceGrantOwner::Standalone, + ] + ); + assert_eq!( + protected_action_owner(ProtectedSourceActionOwner::PlatformBackend), + ProtectedSourceGrantOwner::PlatformBackend + ); + assert_eq!( + requires_app_ui_details(MacosCapabilityOwner::LaunchdService), + serde_json::json!({ + "active_owner": "launchd_service", + "remedy": { "kind": "requires_app_ui" }, + }) + ); + } + + #[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 7a97218f9..6e04c955b 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use anyhow::Context; -use axum::Json; use axum::extract::{Query, State}; use axum::response::Response; +use axum::{Extension, Json}; use serde::Deserialize; use tracing::{info, warn}; use utoipa::ToSchema; @@ -20,7 +20,9 @@ use hypercolor_types::audio::{AudioPipelineConfig, AudioSourceType}; use hypercolor_types::config::{CaptureConfig, HypercolorConfig}; use crate::api::AppState; +use crate::api::capture::protected_control_rejection; use crate::api::envelope::{ApiError, ApiResponse}; +use crate::api::security::RequestAuthContext; use crate::scene_transactions::{ PreparedLayoutUpdate, SceneTransaction, apply_prepared_layout_update_under_guard, }; @@ -43,6 +45,46 @@ pub struct ResetConfigRequest { pub live: Option, } +/// Privacy-bearing config keys. Mutating them starts, retargets, or +/// enables screen, audio, or host-input capture, so they carry the same +/// control-credential requirement as the dedicated capture endpoints +/// (`/capture/source/pick` guards the identical `capture.source` mutation). +/// +/// The whole `capture` domain qualifies (screen content is the most +/// sensitive plane and every leaf feeds the capture reconfiguration +/// transaction). For audio and input, only the leaves that enable capture +/// or retarget a device qualify: DSP tuning (`audio.fft_size`, +/// `audio.smoothing`, ...) and interaction routing policy shape an +/// already-consented stream and stay credential-free so a keyless install +/// keeps its sliders. +fn key_requires_protected_control(key: &str) -> bool { + if key == "capture" + || key + .strip_prefix("capture") + .is_some_and(|rest| rest.starts_with('.')) + { + return true; + } + matches!( + key, + "audio" + | "audio.enabled" + | "audio.device" + | "input" + | "input.enabled" + | "input.keyboard" + | "input.mouse" + ) +} + +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)) @@ -71,10 +113,16 @@ pub async fn get_config_value( } /// `POST /api/v1/config/set` — Set a dotted config key and persist. -pub async fn set_config_value( +pub(crate) async fn set_config_value( State(state): State>, + Extension(auth_context): Extension, Json(body): Json, ) -> Response { + if key_requires_protected_control(&normalize_config_key(&body.key)) + && let Some(rejection) = protected_control_rejection(auth_context) + { + return rejection; + } let Some(manager) = state.config_manager.as_ref() else { return ApiError::internal("Config manager unavailable in this runtime"); }; @@ -180,6 +228,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 @@ -249,10 +339,20 @@ fn canonicalize_config_value(key: &str, value: serde_json::Value) -> serde_json: } /// `POST /api/v1/config/reset` — Reset one key or the full config to defaults. -pub async fn reset_config_value( +pub(crate) async fn reset_config_value( State(state): State>, + Extension(auth_context): Extension, Json(body): Json, ) -> Response { + // A full reset (no key) rewrites the capture domains too. + if body + .key + .as_deref() + .is_none_or(|key| key_requires_protected_control(&normalize_config_key(key))) + && let Some(rejection) = protected_control_rejection(auth_context) + { + return rejection; + } let Some(manager) = state.config_manager.as_ref() else { return ApiError::internal("Config manager unavailable in this runtime"); }; @@ -269,15 +369,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; @@ -337,6 +433,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. @@ -399,6 +529,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(); @@ -688,7 +836,14 @@ async fn apply_capture_config_transaction( "config manager unavailable" ))); }; - #[cfg(any(target_os = "linux", target_os = "windows"))] + #[cfg(target_os = "macos")] + 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) = { let input_manager = state.input_manager.lock().await; let plan = input_manager.plan_screen_runtime_config(capture.enabled); @@ -725,13 +880,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 +896,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 +947,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 +992,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 +1003,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"); @@ -881,6 +1036,72 @@ async fn apply_capture_config_transaction( Ok(()) } +#[cfg(target_os = "macos")] +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; + 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_live_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_capture(&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) => { + 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) => { + 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 compatible macOS capture config without reopening the native stream"); + Ok(()) +} + /// How long a prepared replacement source may take to become usable. /// /// Windows rebuilds in-process and settles in tens of milliseconds. A @@ -978,6 +1199,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 @@ -1011,27 +1368,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 } @@ -1211,30 +1581,150 @@ 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 = "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::screen::{ + CaptureConfig as ScreenCaptureConfig, PixelExtent, ScreenCaptureDemand, + }; use hypercolor_core::input::{ InputData, InputManager, InputSource, ScreenReconfigurationConflict, SourceIssue, SourceKind, SourceState, SourceStatus, SourceStatusHandle, SourceStatusReporter, }; use hypercolor_types::config::InteractionRoutePolicy; + #[cfg(target_os = "macos")] + use super::capture_diff_is_live_compatible; 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, + apply_host_input_config_transaction_with_builder, canvas_dimensions_differ, + capture_statuses_match, key_requires_protected_control, maybe_apply_input_config_change, + reset_config_value, reset_json_scope, set_config_value, validate_prepared_capture_status, }; use crate::api::AppState; + #[test] + fn privacy_bearing_config_domains_require_protected_control() { + for key in [ + "capture", + "capture.enabled", + "capture.source", + "audio.device", + "input.keyboard", + ] { + assert!(key_requires_protected_control(key), "{key}"); + } + for key in [ + "daemon.canvas_width", + "render.fps", + "captured", + "inputs.something", + "audiophile", + "audio.fft_size", + "audio.smoothing", + "audio.noise_gate", + "audio.beat_sensitivity", + "input.daemon_route", + "input.preview_route", + ] { + assert!(!key_requires_protected_control(key), "{key}"); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn uncredentialed_capture_config_writes_are_rejected() { + let state = Arc::new(AppState::new()); + + let response = set_config_value( + axum::extract::State(Arc::clone(&state)), + axum::Extension(crate::api::security::RequestAuthContext::unsecured()), + axum::Json(SetConfigRequest { + key: "capture.enabled".to_owned(), + value: "true".to_owned(), + live: None, + }), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + + let response = reset_config_value( + axum::extract::State(state), + axum::Extension(crate::api::security::RequestAuthContext::unsecured()), + axum::Json(ResetConfigRequest { + key: None, + live: None, + }), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } + struct TestScreenSource { 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 { @@ -1243,6 +1733,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, } } } @@ -1282,6 +1807,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 { @@ -1384,6 +1928,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()) @@ -1450,6 +2085,310 @@ 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(target_os = "macos")] + #[test] + 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; + 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; + 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 [ + { + 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.publication_memory_bytes = Some(1_000_000); + config + }, + { + let mut config = calibration.clone(); + config.restore_token = Some("other-session".to_owned()); + config + }, + ] { + 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() { + 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::Extension(crate::api::security::trusted_local_control_context()), + 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(); @@ -1513,7 +2452,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 +2497,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"); @@ -1688,6 +2627,7 @@ mod tests { let response = set_config_value( axum::extract::State(Arc::clone(&state)), + axum::Extension(crate::api::security::trusted_local_control_context()), axum::Json(SetConfigRequest { key: "capture.enabled".to_owned(), value: "false".to_owned(), @@ -1729,6 +2669,7 @@ mod tests { let request = tokio::spawn(async move { set_config_value( axum::extract::State(request_state), + axum::Extension(crate::api::security::trusted_local_control_context()), axum::Json(SetConfigRequest { key: "capture.capture_fps".to_owned(), value: unchanged_fps.to_string(), diff --git a/crates/hypercolor-daemon/src/api/diagnose.rs b/crates/hypercolor-daemon/src/api/diagnose.rs index 3423ad7f3..0b809a139 100644 --- a/crates/hypercolor-daemon/src/api/diagnose.rs +++ b/crates/hypercolor-daemon/src/api/diagnose.rs @@ -2,15 +2,17 @@ use std::sync::Arc; -use axum::Json; use axum::extract::State; use axum::response::Response; +use axum::{Extension, Json}; use hypercolor_core::device::{UsbActorMetricsSnapshot, usb_actor_metrics_snapshot}; use hypercolor_types::device::USB_OUTPUT_BACKEND_ID; use serde::{Deserialize, Serialize}; use crate::api::AppState; +use crate::api::capture::protected_control_rejection; use crate::api::envelope::{ApiError, ApiResponse}; +use crate::api::security::RequestAuthContext; use crate::api::system::{InputStatus, actionable_input_diagnostics, input_status_snapshot}; use crate::device_metrics::{DeviceMetrics, DeviceMetricsSnapshot}; use crate::display_frames::DisplayOutputMetricsSnapshot; @@ -54,6 +56,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)] @@ -209,8 +213,9 @@ struct DiagnoseDeviceOutputItem { clippy::too_many_lines, reason = "diagnostics response assembly keeps checks and snapshot state in one handler" )] -pub async fn run_diagnostics( +pub(crate) async fn run_diagnostics( State(state): State>, + Extension(auth_context): Extension, body: Option>, ) -> Response { let requested = body @@ -227,6 +232,15 @@ pub async fn run_diagnostics( ] }); + // The parity check actuates a real screenshot-reference capture, so + // it rides the protected-capture credential like every other capture + // actuation. The default check set stays credential-free. + if requested.iter().any(|check| check == "macos_screen_parity") + && let Some(rejection) = protected_control_rejection(auth_context) + { + return rejection; + } + let include_system = body.as_ref().and_then(|b| b.system).unwrap_or(false); let render_elapsed_ms = state.start_time.elapsed().as_secs_f64() * 1000.0; @@ -235,7 +249,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 +434,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 +600,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/local.rs b/crates/hypercolor-daemon/src/api/local.rs index 1678ae0b4..11e452c63 100644 --- a/crates/hypercolor-daemon/src/api/local.rs +++ b/crates/hypercolor-daemon/src/api/local.rs @@ -324,6 +324,48 @@ mod tests { socket.shutdown().await; } + #[tokio::test] + async fn trusted_websocket_preserves_protected_control_without_api_keys() { + let api = TrustedLocalApi::new(Arc::new(AppState::new())); + let mut socket = api + .open_websocket("/api/v1/ws") + .expect("canonical trusted local websocket path should open"); + + assert_eq!( + message_json( + socket + .recv() + .await + .expect("trusted local websocket should emit hello") + )["type"], + "hello" + ); + socket + .send(Message::Text( + serde_json::json!({ + "type": "command", + "id": "capture_monitors", + "method": "GET", + "path": "/capture/monitors" + }) + .to_string() + .into(), + )) + .await + .expect("trusted local websocket should accept a command frame"); + let response = message_json( + socket + .recv() + .await + .expect("trusted local websocket should emit a command response"), + ); + assert_eq!(response["type"], "response"); + assert_eq!(response["id"], "capture_monitors"); + assert_eq!(response["status"], 200); + + socket.shutdown().await; + } + #[tokio::test] async fn trusted_websocket_shutdown_joins_active_preview_cleanup() { let mut source = BrowserInputSource::new(); 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 ff0f77093..ccfa8747c 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; @@ -39,11 +41,13 @@ 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; +use arc_swap::{ArcSwap, ArcSwapOption}; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::http::{HeaderValue, Method, header}; @@ -111,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 @@ -136,6 +143,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>, @@ -193,9 +203,22 @@ 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, + /// 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, @@ -293,6 +316,9 @@ pub struct AppState { /// Stable network identity exposed by API and discovery surfaces. pub server_identity: ServerIdentity, + /// Current daemon process session identifier, when one was attested. + pub server_session_id: Option, + /// Shared API auth and rate-limiting state for HTTP and WS command dispatch. pub security_state: security::SecurityState, } @@ -570,6 +596,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, @@ -589,7 +616,13 @@ 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, input_status, browser_input, interaction_routing, @@ -627,6 +660,7 @@ impl AppState { instance_name: "hypercolor".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), }, + server_session_id: None, security_state: security::SecurityState::from_config(&HypercolorConfig::default()), } } @@ -654,6 +688,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), @@ -673,9 +708,15 @@ 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"), + #[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(), @@ -709,9 +750,19 @@ impl AppState { playlist_runtime: Arc::new(Mutex::new(PlaylistRuntimeState::new())), start_time: daemon.start_time, server_identity: daemon.server_identity.clone(), + server_session_id: None, security_state: security::SecurityState::from_config(&daemon.config_manager.get()), } } + + pub(crate) fn install_macos_daemon_session( + &mut self, + attestation: &crate::macos_owner::MacosDaemonSessionAttestation, + ) { + self.server_session_id = Some(attestation.server_session_id.as_str().to_owned()); + self.security_state + .install_macos_daemon_session(attestation); + } } impl Default for AppState { @@ -1532,7 +1583,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}", @@ -1544,6 +1595,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), @@ -1665,7 +1724,9 @@ fn configured_cors_origin(origin: &str) -> Option { } fn is_allowed_cors_origin(origin: &HeaderValue, configured_origins: &[HeaderValue]) -> bool { - is_loopback_origin(origin) || configured_origins.iter().any(|allowed| allowed == origin) + is_loopback_origin(origin) + || security::is_trusted_tauri_origin(origin) + || configured_origins.iter().any(|allowed| allowed == origin) } fn is_http_origin(origin: &str) -> bool { @@ -1723,6 +1784,17 @@ mod cors_tests { &origin("http://127.0.0.1:9430"), &configured )); + for native_origin in [ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + ] { + assert!(is_allowed_cors_origin(&origin(native_origin), &configured)); + } + assert!(!is_allowed_cors_origin( + &origin("tauri://attacker.example"), + &configured + )); } #[test] diff --git a/crates/hypercolor-daemon/src/api/openapi.rs b/crates/hypercolor-daemon/src/api/openapi.rs index ecaaabf4a..36bab0001 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", @@ -752,6 +837,18 @@ pub const ROUTES: &[RouteSpec] = &[ "effects", "Apply effect", ), + RouteSpec::get( + "/api/v1/effects/{id}/presets", + "list_effect_presets", + "effects", + "List effect presets", + ), + RouteSpec::post( + "/api/v1/effects/{id}/presets/{preset_id}/apply", + "apply_effect_preset", + "effects", + "Apply effect preset", + ), RouteSpec::patch( "/api/v1/effects/{id}/controls", "update_effect_controls", @@ -1155,6 +1252,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 +1286,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 +1296,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..38cd6762a 100644 --- a/crates/hypercolor-daemon/src/api/security.rs +++ b/crates/hypercolor-daemon/src/api/security.rs @@ -23,6 +23,7 @@ use tokio::sync::Mutex; use tracing::warn; use crate::api::envelope::ApiError; +use crate::macos_owner::{MacosDaemonSessionAttestation, MacosProtectedControlCredential}; use hypercolor_types::config::{ HypercolorConfig, NetworkAccessMode, NetworkClientScope, NetworkConfig, }; @@ -33,6 +34,20 @@ const WRITE_LIMIT_PER_MIN: u32 = 60; const DISCOVERY_LIMIT_PER_MIN: u32 = 2; const PAIRING_LIMIT_PER_MIN: u32 = 6; +const TRUSTED_TAURI_ORIGINS: &[&str] = &[ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", +]; + +pub(crate) fn is_trusted_tauri_origin(origin: &HeaderValue) -> bool { + origin.to_str().is_ok_and(|origin| { + TRUSTED_TAURI_ORIGINS + .iter() + .any(|trusted| origin.eq_ignore_ascii_case(trusted)) + }) +} + const HEADER_RATE_LIMIT_LIMIT: HeaderName = HeaderName::from_static("x-ratelimit-limit"); const HEADER_RATE_LIMIT_REMAINING: HeaderName = HeaderName::from_static("x-ratelimit-remaining"); const HEADER_RATE_LIMIT_RESET: HeaderName = HeaderName::from_static("x-ratelimit-reset"); @@ -41,6 +56,7 @@ const HEADER_RETRY_AFTER: HeaderName = HeaderName::from_static("retry-after"); #[derive(Clone)] pub struct SecurityState { auth: AuthConfig, + macos_session_credential: Option, network: NetworkAccessPolicy, rate_limiter: Arc>, } @@ -49,6 +65,13 @@ pub struct SecurityState { pub(crate) struct RequestAuthContext { security_enabled: bool, granted_tier: Option, + protected_control: ProtectedControl, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProtectedControl { + Denied, + Granted, } #[derive(Debug, Clone, Copy)] @@ -60,6 +83,7 @@ impl RequestAuthContext { Self { security_enabled: false, granted_tier: None, + protected_control: ProtectedControl::Denied, } } @@ -68,6 +92,7 @@ impl RequestAuthContext { Self { security_enabled: true, granted_tier: None, + protected_control: ProtectedControl::Denied, } } @@ -76,6 +101,19 @@ impl RequestAuthContext { Self { security_enabled: true, granted_tier: Some(granted_tier), + protected_control: match granted_tier { + AccessTier::Read => ProtectedControl::Denied, + AccessTier::Control => ProtectedControl::Granted, + }, + } + } + + #[must_use] + const fn macos_daemon_session(security_enabled: bool) -> Self { + Self { + security_enabled, + granted_tier: Some(AccessTier::Control), + protected_control: ProtectedControl::Granted, } } @@ -101,6 +139,11 @@ impl RequestAuthContext { !self.security_enabled || matches!(self.granted_tier, Some(AccessTier::Control)) } + #[must_use] + pub(crate) const fn can_protected_control(self) -> bool { + matches!(self.protected_control, ProtectedControl::Granted) + } + #[must_use] const fn granted_tier(self) -> Option { self.granted_tier @@ -113,6 +156,7 @@ impl SecurityState { if cfg!(test) { return Self { auth: AuthConfig::default(), + macos_session_credential: None, network: NetworkAccessPolicy::default(), rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), }; @@ -125,6 +169,7 @@ impl SecurityState { control_key, read_key, }, + macos_session_credential: None, network: NetworkAccessPolicy::default(), rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), } @@ -140,6 +185,29 @@ impl SecurityState { pub(crate) fn security_enabled(&self) -> bool { self.auth.control_key.is_some() || self.auth.read_key.is_some() } + + pub(crate) fn install_macos_daemon_session( + &mut self, + attestation: &MacosDaemonSessionAttestation, + ) { + self.macos_session_credential = Some(attestation.protected_control_credential.clone()); + } + + fn is_macos_session_credential(&self, token: &str) -> bool { + self.macos_session_credential + .as_ref() + .is_some_and(|credential| constant_time_str_eq(credential.expose_secret(), token)) + } + + fn resolve_loopback_token(&self, token: &str) -> Option { + if self.is_macos_session_credential(token) { + Some(RequestAuthContext::macos_daemon_session( + self.security_enabled(), + )) + } else { + resolve_token_tier(token, &self.auth).map(RequestAuthContext::authenticated) + } + } } #[must_use] @@ -170,6 +238,7 @@ impl SecurityState { control_key: control_key.map(ToOwned::to_owned), read_key: read_key.map(ToOwned::to_owned), }, + macos_session_credential: None, network: NetworkAccessPolicy::default(), rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), } @@ -178,14 +247,22 @@ impl SecurityState { pub(crate) fn with_network_config(network: NetworkConfig) -> Self { Self { auth: AuthConfig::default(), + macos_session_credential: None, network: NetworkAccessPolicy::from_config(&network), rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), } } + fn with_macos_session_credential(credential: MacosProtectedControlCredential) -> Self { + let mut state = Self::with_keys(None, None); + state.macos_session_credential = Some(credential); + state + } + fn with_network_policy(network: NetworkAccessPolicy) -> Self { Self { auth: AuthConfig::default(), + macos_session_credential: None, network, rate_limiter: Arc::new(Mutex::new(RateLimiter::new())), } @@ -520,7 +597,6 @@ pub async fn enforce_security( next: Next, ) -> Response { let mut request = request; - if request .extensions_mut() .remove::() @@ -536,6 +612,12 @@ pub async fn enforce_security( return response; } + if !request_is_loopback(&request) + && extract_token(&request).is_some_and(|token| state.is_macos_session_credential(&token)) + { + return ApiError::unauthorized("Invalid API key"); + } + if is_exempt_path(request.uri().path()) { request .extensions_mut() @@ -544,28 +626,28 @@ pub async fn enforce_security( } if request_is_loopback(&request) { - if is_mutating_request(request.method()) && is_cross_site_request(&request) { + if is_mutating_request(request.method()) + && is_cross_site_request(&request) + && !has_trusted_tauri_session(&state, &request) + { return ApiError::forbidden( "Cross-site mutating requests to the loopback API are blocked to prevent CSRF.", ); } - if !state.security_enabled() { - request - .extensions_mut() - .insert(RequestAuthContext::unsecured()); - return next.run(request).await; - } - request - .extensions_mut() - .insert(RequestAuthContext::unsecured()); + let auth_context = extract_token(&request) + .and_then(|token| state.resolve_loopback_token(&token)) + .map_or_else(RequestAuthContext::unsecured, std::convert::identity); + request.extensions_mut().insert(auth_context); return next.run(request).await; } if !state.security_enabled() { - request - .extensions_mut() - .insert(RequestAuthContext::unsecured()); + if request.extensions().get::().is_none() { + request + .extensions_mut() + .insert(RequestAuthContext::unsecured()); + } return next.run(request).await; } @@ -664,9 +746,9 @@ fn is_mutating_request(method: &Method) -> bool { } /// Returns `true` only when the browser explicitly marks the request as -/// cross-site. Same-origin/same-site requests (the bundled web UI) and -/// non-browser clients (CLI, SDK) omit or set a non-`cross-site` value, so -/// this blocks drive-by CSRF without rejecting legitimate local clients. +/// cross-site. Ordinary same-origin/same-site requests and non-browser clients +/// omit or set a non-`cross-site` value. The bundled Tauri UI is cross-site and +/// passes only through the separate exact-origin plus session-credential gate. fn is_cross_site_request(request: &Request) -> bool { request .headers() @@ -675,20 +757,45 @@ fn is_cross_site_request(request: &Request) -> bool { .is_some_and(|site| site == "cross-site") } +fn has_trusted_tauri_session(state: &SecurityState, request: &Request) -> bool { + request + .headers() + .get(header::ORIGIN) + .is_some_and(is_trusted_tauri_origin) + && extract_token(request).is_some_and(|token| state.is_macos_session_credential(&token)) +} + fn resolve_token_tier(token: &str, auth: &AuthConfig) -> Option { - if auth.control_key.as_deref() == Some(token) { + if auth + .control_key + .as_deref() + .is_some_and(|key| constant_time_str_eq(key, token)) + { if token.starts_with("hc_ak_r_") { Some(AccessTier::Read) } else { Some(AccessTier::Control) } - } else if auth.read_key.as_deref() == Some(token) { + } else if auth + .read_key + .as_deref() + .is_some_and(|key| constant_time_str_eq(key, token)) + { Some(AccessTier::Read) } else { None } } +/// Compare a presented token against a stored credential without leaking +/// match position through timing. Length still leaks, which is standard: +/// credential lengths are fixed and public. +fn constant_time_str_eq(left: &str, right: &str) -> bool { + use subtle::ConstantTimeEq; + + left.as_bytes().ct_eq(right.as_bytes()).into() +} + fn tier_satisfies(granted: AccessTier, required: AccessTier) -> bool { matches!( (granted, required), @@ -795,11 +902,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 +919,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)); @@ -912,7 +1014,7 @@ fn masked_v6(address: Ipv6Addr, prefix: u8) -> u128 { mod tests { use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use axum::extract::ConnectInfo; + use axum::extract::{ConnectInfo, Extension}; use axum::http::header::AUTHORIZATION; use axum::routing::{get, post}; use axum::{Router, body::Body}; @@ -923,8 +1025,10 @@ mod tests { use hypercolor_types::config::{NetworkAccessMode, NetworkClientScope, NetworkConfig}; use super::{ - ClientAddressRule, NetworkAccessPolicy, SecurityState, enforce_security, normalize_api_key, + ClientAddressRule, NetworkAccessPolicy, RequestAuthContext, SecurityState, + enforce_security, normalize_api_key, }; + use crate::macos_owner::MacosProtectedControlCredential; const CONTROL_KEY: &str = "hc_ak_control_test"; const READ_KEY: &str = "hc_ak_r_read_test"; @@ -938,7 +1042,30 @@ mod tests { Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/api/v1/status", get(|| async { StatusCode::OK })) - .route("/api/v1/ws", get(|| async { StatusCode::OK })) + .route( + "/api/v1/ws", + get( + |Extension(context): Extension| async move { + if context.can_protected_control() { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + }, + ), + ) + .route( + "/api/v1/protected-control", + get( + |Extension(context): Extension| async move { + if context.can_protected_control() { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + }, + ), + ) .route("/api/v1/scenes", post(|| async { StatusCode::CREATED })) .route( "/api/v1/effects/install", @@ -992,6 +1119,32 @@ mod tests { request } + async fn loopback_cross_site_mutation( + state: SecurityState, + origin: Option<&str>, + token: Option<&str>, + ) -> StatusCode { + let mut builder = Request::builder() + .method("POST") + .uri("/api/v1/scenes") + .header("sec-fetch-site", "cross-site"); + if let Some(origin) = origin { + builder = builder.header(http::header::ORIGIN, origin); + } + if let Some(token) = token { + builder = with_bearer(builder, token); + } + router_with_security_state(state) + .oneshot(with_connect_info( + builder.body(Body::empty()).expect("request should build"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed") + .status() + } + #[test] fn normalize_api_key_ignores_missing_or_blank_values() { assert_eq!(normalize_api_key(None), None); @@ -1061,6 +1214,111 @@ mod tests { assert!(response.headers().get("x-ratelimit-limit").is_none()); } + #[tokio::test] + async fn loopback_locality_does_not_grant_protected_control() { + let response = secured_test_router() + .oneshot(with_connect_info( + Request::builder() + .uri("/api/v1/protected-control") + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn loopback_read_key_does_not_grant_protected_control() { + let response = secured_test_router() + .oneshot(with_connect_info( + with_bearer( + Request::builder().uri("/api/v1/protected-control"), + READ_KEY, + ) + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn loopback_control_key_grants_protected_control() { + let response = secured_test_router() + .oneshot(with_connect_info( + with_bearer( + Request::builder().uri("/api/v1/protected-control"), + CONTROL_KEY, + ) + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn loopback_session_credential_grants_control_without_enabling_public_auth() { + let credential = MacosProtectedControlCredential::from_bytes([0x42; 32]); + let state = SecurityState::with_macos_session_credential(credential.clone()); + assert!(!state.security_enabled()); + let context = state + .resolve_loopback_token(credential.expose_secret()) + .expect("session credential should resolve"); + assert!(context.can_control()); + assert!(context.can_protected_control()); + assert!(!context.security_enabled()); + + let response = router_with_security_state(state) + .oneshot(with_connect_info( + with_bearer( + Request::builder().uri("/api/v1/protected-control"), + credential.expose_secret(), + ) + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn nonloopback_session_credential_is_rejected_when_public_auth_is_disabled() { + let credential = MacosProtectedControlCredential::from_bytes([0x24; 32]); + let state = SecurityState::with_macos_session_credential(credential.clone()); + let response = router_with_security_state(state) + .oneshot(with_connect_info( + with_bearer( + Request::builder().uri("/api/v1/protected-control"), + credential.expose_secret(), + ) + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn loopback_proxy_with_forwarded_remote_ip_requires_authentication() { let app = secured_test_router(); @@ -1144,6 +1402,72 @@ mod tests { assert_eq!(json["error"]["code"], "forbidden"); } + #[tokio::test] + async fn tauri_cross_site_bypass_requires_exact_origin_and_current_session() { + let credential = MacosProtectedControlCredential::from_bytes([0x63; 32]); + let session = credential.expose_secret(); + let session_state = || SecurityState::with_macos_session_credential(credential.clone()); + + assert_eq!( + loopback_cross_site_mutation( + session_state(), + Some("tauri://localhost"), + Some(session), + ) + .await, + StatusCode::CREATED + ); + assert_eq!( + loopback_cross_site_mutation(session_state(), Some("tauri://localhost"), None).await, + StatusCode::FORBIDDEN + ); + assert_eq!( + loopback_cross_site_mutation( + session_state(), + Some("tauri://attacker.example"), + Some(session), + ) + .await, + StatusCode::FORBIDDEN + ); + assert_eq!( + loopback_cross_site_mutation( + session_state(), + Some("https://tauri.localhost.evil"), + Some(session), + ) + .await, + StatusCode::FORBIDDEN + ); + + let response = router_with_security_state(session_state()) + .oneshot(with_connect_info( + with_bearer( + Request::builder().method("POST").uri("/api/v1/scenes"), + session, + ) + .body(Body::empty()) + .expect("native request should build"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("native request failed"); + assert_eq!(response.status(), StatusCode::CREATED); + + let mut public_key_state = SecurityState::with_keys(Some(CONTROL_KEY), None); + public_key_state.macos_session_credential = Some(credential); + assert_eq!( + loopback_cross_site_mutation( + public_key_state, + Some("tauri://localhost"), + Some(CONTROL_KEY), + ) + .await, + StatusCode::FORBIDDEN + ); + } + #[tokio::test] async fn loopback_same_site_mutating_requests_are_allowed() { let app = secured_test_router(); @@ -1239,7 +1563,7 @@ mod tests { } #[tokio::test] - async fn websocket_upgrade_allows_query_token_authentication() { + async fn websocket_upgrade_read_query_lacks_protected_control() { let app = secured_test_router(); let response = app .oneshot( @@ -1252,7 +1576,66 @@ mod tests { .await .expect("request failed"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response.headers().contains_key("x-ratelimit-limit")); + } + + #[tokio::test] + async fn websocket_upgrade_control_query_grants_protected_control() { + let response = secured_test_router() + .oneshot( + Request::builder() + .uri(format!("/api/v1/ws?token={CONTROL_KEY}")) + .header("upgrade", "websocket") + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key("x-ratelimit-limit")); + } + + #[tokio::test] + async fn loopback_websocket_control_query_grants_protected_control() { + let response = secured_test_router() + .oneshot(with_connect_info( + Request::builder() + .uri(format!("/api/v1/ws?token={CONTROL_KEY}")) + .header("upgrade", "websocket") + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get("x-ratelimit-limit").is_none()); + } + + #[tokio::test] + async fn loopback_websocket_session_query_grants_protected_control() { + let credential = MacosProtectedControlCredential::from_bytes([0x81; 32]); + let response = router_with_security_state(SecurityState::with_macos_session_credential( + credential.clone(), + )) + .oneshot(with_connect_info( + Request::builder() + .uri(format!("/api/v1/ws?token={}", credential.expose_secret())) + .header("upgrade", "websocket") + .body(Body::empty()) + .expect("failed to build request"), + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1042, + )) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get("x-ratelimit-limit").is_none()); } #[tokio::test] diff --git a/crates/hypercolor-daemon/src/api/settings.rs b/crates/hypercolor-daemon/src/api/settings.rs index 8abbdb1bb..ec183276f 100644 --- a/crates/hypercolor-daemon/src/api/settings.rs +++ b/crates/hypercolor-daemon/src/api/settings.rs @@ -50,7 +50,18 @@ pub struct SetBrightnessRequest { /// `GET /api/v1/audio/devices` — Enumerate audio input devices for the Settings UI. pub async fn list_audio_devices(State(state): State>) -> Response { let current = current_audio_device_id(&state); - let devices = audio_device_options(¤t); + let current_for_enumeration = current.clone(); + let devices = + match tokio::task::spawn_blocking(move || audio_device_options(¤t_for_enumeration)) + .await + { + Ok(devices) => devices, + Err(error) => { + return ApiError::internal(format!( + "Audio device enumeration task failed: {error}" + )); + } + }; ApiResponse::ok(AudioDevicesResponse { devices, current }) } @@ -91,10 +102,6 @@ pub async fn set_brightness( }) } -pub(crate) fn audio_input_available() -> bool { - enumerate_audio_input_devices().is_ok() -} - pub(crate) fn capture_input_available() -> bool { if cfg!(target_os = "windows") { return true; diff --git a/crates/hypercolor-daemon/src/api/system.rs b/crates/hypercolor-daemon/src/api/system.rs index e4101effd..1ddaf7e49 100644 --- a/crates/hypercolor-daemon/src/api/system.rs +++ b/crates/hypercolor-daemon/src/api/system.rs @@ -7,13 +7,19 @@ 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::{ PixelExtent, ScreenAnalysisComputeCapacity, ScreenAnalysisResourcePlan, ScreenAnalysisWorkPlan, }; -use hypercolor_core::input::{SourceFreshness, SourceIssue, SourceKind, SourceState, SourceStatus}; +use hypercolor_core::input::{ + MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, MacosDaemonOwnerConflict, + MacosInputPlatformStatus, MacosProtectedSourceState, MacosScreenPlatformStatus, + MacosScreenTimingStatus, MacosSelectionState, MacosTahoeCapabilities, + MacosTahoeSelectionCapabilities, MacosTimingStatus, SourceFreshness, SourceIssue, SourceKind, + SourcePlatformStatus, SourceState, SourceStatus, +}; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::sensor::SystemSnapshot; use serde::Serialize; @@ -21,7 +27,9 @@ use utoipa::ToSchema; use crate::api::AppState; use crate::api::envelope::{ApiError, ApiResponse}; +use crate::api::security::RequestAuthContext; 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; @@ -61,8 +69,11 @@ 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 session_performance: SessionPerformanceStatus, pub latest_frame: Option, pub effect_health: EffectHealthStatus, pub preview_runtime: PreviewRuntimeStatus, @@ -70,6 +81,45 @@ pub struct SystemStatus { pub capabilities: Vec, } +#[derive(Debug, Serialize, ToSchema)] +pub struct SessionPerformanceStatus { + pub input_stage: LatencyPercentilesStatus, + pub full_frame_cpu_copies: FullFrameCopySessionStatus, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct LatencyPercentilesStatus { + pub sample_count: u64, + pub avg_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub max_ms: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub cumulative_histogram: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct LatencyHistogramStatus { + pub bucket_width_us: u32, + pub overflow_bucket_index: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot_frame_token: Option, + pub buckets: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct LatencyHistogramBucketStatus { + pub bucket_index: u32, + pub count: u64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FullFrameCopySessionStatus { + pub count: u64, + pub frames: u64, + pub bytes: u64, +} + /// Installed byte fences for transactional screen publication admission. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ScreenCaptureCapacityStatus { @@ -172,6 +222,280 @@ 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, 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 { + 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, 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)] +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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_to_publication_timing: Option, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosTimingApiStatus { + pub sample_count: u64, + pub total_ns: u64, + pub max_ns: u64, + pub p95_ns: u64, + pub p99_ns: u64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MacosScreenTimingApiStatus { + pub callback: MacosTimingApiStatus, + pub retain: MacosTimingApiStatus, + pub enqueue: MacosTimingApiStatus, + pub conversion: MacosTimingApiStatus, + pub cpu_reduction: MacosTimingApiStatus, + pub native_import: MacosTimingApiStatus, + pub native_reduction_submit: MacosTimingApiStatus, + pub publication: MacosTimingApiStatus, + pub capture_to_native_publication: MacosTimingApiStatus, + pub capture_to_converted_publication: MacosTimingApiStatus, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timing: 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 { + MacosInput { + keyboard: MacosProtectedSourceStateApi, + pointer: MacosProtectedSourceStateApi, + keyboard_tcc: MacosAuthorizationStateApi, + secure_input_active: bool, + keyboard_owner: MacosCapabilityOwnerApi, + pointer_owner: MacosCapabilityOwnerApi, + #[serde(default, skip_serializing_if = "Option::is_none")] + owner_conflict: Option, + telemetry: MacosInputTelemetryApiStatus, + }, + MacosScreen { + state: MacosProtectedSourceStateApi, + 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")] + owner_conflict: Option, + telemetry: MacosScreenTelemetryApiStatus, + }, +} + /// Lock-free lifecycle and freshness status for one input source. #[derive(Debug, Clone, Serialize, ToSchema)] #[allow( @@ -185,6 +509,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, @@ -202,6 +527,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, } @@ -466,13 +793,22 @@ pub struct HealthChecks { pub struct ServerInfo { #[serde(flatten)] pub identity: ServerIdentity, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_session_id: Option, pub device_count: usize, pub auth_required: bool, } -/// Build the canonical lock-free input health snapshot used by every status surface. +/// Build the redacted input health snapshot used without protected control. #[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 @@ -485,7 +821,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 { @@ -565,7 +901,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 @@ -580,6 +920,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, @@ -595,10 +936,360 @@ 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, include_private_selection_ids) + }), retired: source.retired, } } +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, + include_private_selection_ids, + )), + _ => None, + } +} + +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), + keyboard_tcc: macos_authorization_state(status.keyboard_tcc), + secure_input_active: status.secure_input_active, + 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), + 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, + callback_to_publication_timing: status + .callback_to_publication_timing + .as_ref() + .map(macos_timing_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), + 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().map(|capabilities| { + macos_tahoe_selection_capabilities(capabilities, include_private_selection_ids) + }), + owner_conflict: status + .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), + timing: Some(macos_screen_timing_status(&status.timing)), + 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, + }, + } +} + +fn macos_timing_status(status: &MacosTimingStatus) -> MacosTimingApiStatus { + MacosTimingApiStatus { + sample_count: status.sample_count, + total_ns: status.total_ns, + max_ns: status.max_ns, + p95_ns: status.p95_ns, + p99_ns: status.p99_ns, + } +} + +fn macos_screen_timing_status(status: &MacosScreenTimingStatus) -> MacosScreenTimingApiStatus { + MacosScreenTimingApiStatus { + callback: macos_timing_status(&status.callback), + retain: macos_timing_status(&status.retain), + enqueue: macos_timing_status(&status.enqueue), + conversion: macos_timing_status(&status.conversion), + cpu_reduction: macos_timing_status(&status.cpu_reduction), + native_import: macos_timing_status(&status.native_import), + native_reduction_submit: macos_timing_status(&status.native_reduction_submit), + publication: macos_timing_status(&status.publication), + capture_to_native_publication: macos_timing_status(&status.capture_to_native_publication), + capture_to_converted_publication: macos_timing_status( + &status.capture_to_converted_publication, + ), + } +} + +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, + } +} + +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, + 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, + include_private_selection_ids: bool, +) -> MacosTahoeSelectionCapabilitiesApiStatus { + MacosTahoeSelectionCapabilitiesApiStatus { + 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, + } +} + +fn macos_tahoe_capabilities( + capabilities: &MacosTahoeCapabilities, +) -> MacosTahoeCapabilitiesApiStatus { + MacosTahoeCapabilitiesApiStatus { + 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(), @@ -675,6 +1366,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(auth_context): Extension, +) -> Response { + get_status_with_privacy(state, auth_context.can_protected_control()).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(); @@ -691,7 +1396,13 @@ pub async fn get_status(State(state): State>) -> Response { }) }; - let performance = state.performance.read().await.snapshot(); + let (performance, input_time_histogram) = { + let performance = state.performance.read().await; + ( + performance.snapshot(), + performance.input_time_histogram_snapshot(), + ) + }; // Query the live render loop for timing data. let render_loop_status = { @@ -729,6 +1440,36 @@ pub async fn get_status(State(state): State>) -> Response { } else { None }; + let session_performance = SessionPerformanceStatus { + input_stage: LatencyPercentilesStatus { + sample_count: performance.input_time_sample_count, + avg_ms: round_2(performance.input_time.avg_ms), + p95_ms: round_2(performance.input_time.p95_ms), + p99_ms: round_2(performance.input_time.p99_ms), + max_ms: round_2(performance.input_time.max_ms), + cumulative_histogram: Some(LatencyHistogramStatus { + bucket_width_us: input_time_histogram.bucket_width_us, + overflow_bucket_index: input_time_histogram.overflow_bucket_index, + snapshot_frame_token: performance + .latest_frame + .as_ref() + .map(|frame| frame.timeline.frame_token), + buckets: input_time_histogram + .buckets + .into_iter() + .map(|bucket| LatencyHistogramBucketStatus { + bucket_index: bucket.bucket_index, + count: bucket.count, + }) + .collect(), + }), + }, + full_frame_cpu_copies: FullFrameCopySessionStatus { + count: performance.full_frame_copy_count_total, + frames: performance.full_frame_copy_frames_total, + bytes: performance.full_frame_copy_bytes_total, + }, + }; let servo_health = servo_effect_health_counts(); let pipeline_health = render_pipeline_health_counts(); let effect_health = EffectHealthStatus { @@ -834,7 +1575,12 @@ 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 audio_available = input_status.sources.iter().any(|source| { + source.kind == "audio" + && !source.retired + && !matches!(source.state.as_str(), "unavailable" | "failed") + }); let screen_capture_capacity = { let capacity_snapshot = state.screen_capacity_status.snapshot(); let policy = capacity_snapshot.policy(); @@ -885,6 +1631,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, @@ -901,12 +1652,14 @@ pub async fn get_status(State(state): State>) -> Response { active_scene, active_scene_snapshot_locked, global_brightness: brightness_percent(current_global_brightness(&state.power_state)), - audio_available: settings::audio_input_available(), + audio_available, 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, + session_performance, latest_frame, effect_health, preview_runtime, @@ -951,6 +1704,7 @@ pub async fn get_server(State(state): State>) -> Response { ApiResponse::ok(ServerInfo { identity: state.server_identity.clone(), + server_session_id: state.server_session_id.clone(), device_count, auth_required: state.security_state.security_enabled(), }) @@ -1569,8 +2323,17 @@ 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_server, 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::{ + MacosDaemonOwner, MacosDaemonSessionAttestation, MacosHandoverPhase, MacosOwnerConflict, + MacosOwnerIdentity, MacosOwnerRecoveryRequired, MacosOwnerSnapshot, + MacosProtectedControlCredential, MacosServerSessionId, + }; use crate::performance::{ CompositorBackendKind, FrameTimeline, FullFrameCopyMetrics, LatestFrameMetrics, OutputFrameSourceKind, @@ -1580,12 +2343,538 @@ mod tests { use axum::extract::{Path, State}; use hypercolor_core::bus::CanvasFrame; use hypercolor_core::input::screen::ScreenAdmissionCapacity; + use hypercolor_core::input::{ + InputData, InputSource, MacosArchitecture, MacosAuthorizationState, MacosCapabilityOwner, + MacosDaemonOwnerConflict, MacosInputPlatformStatus, MacosProtectedSourceState, + MacosScreenPlatformStatus, MacosScreenTimingStatus, MacosSelectionState, + MacosTahoeCapabilities, MacosTahoeSelectionCapabilities, MacosTimingStatus, + SourceFreshness, SourceKind, SourcePlatformStatus, SourceState, SourceStatus, + SourceStatusHandle, SourceStatusReporter, + }; 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; + 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) -> &'static 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 + } + } + + #[tokio::test] + async fn server_response_exposes_only_the_attested_session_id() { + let tempdir = tempfile::tempdir().expect("server test data dir should be created"); + let session_id = MacosServerSessionId::from_bytes([0x33; 16]); + let credential = MacosProtectedControlCredential::from_bytes([0x77; 32]); + let attestation = MacosDaemonSessionAttestation { + schema_version: crate::macos_owner::MACOS_DAEMON_SESSION_ATTESTATION_SCHEMA_VERSION, + owner: MacosDaemonOwner::AppSidecar, + owner_epoch: 7, + owner_identity: MacosOwnerIdentity::new( + "audit-server", + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + "requirement-server", + 4242, + ) + .expect("fixture identity should be valid"), + server_session_id: session_id.clone(), + protected_control_credential: credential.clone(), + }; + let mut state = AppState::new_with_data_dir(tempdir.path().join("data")); + state.install_macos_daemon_session(&attestation); + + let response = get_server(State(Arc::new(state))).await; + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("server response should read"); + let value: Value = serde_json::from_slice(&bytes).expect("server response should be JSON"); + + assert_eq!(value["data"]["server_session_id"], session_id.as_str()); + assert!(!String::from_utf8_lossy(&bytes).contains(credential.expose_secret())); + } + + 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, + active_consumer_count: 2, + 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, + } + } + + const fn timing_fixture( + sample_count: u64, + total_ns: u64, + max_ns: u64, + p95_ns: u64, + p99_ns: u64, + ) -> MacosTimingStatus { + MacosTimingStatus { + sample_count, + total_ns, + max_ns, + p95_ns, + p99_ns, + } + } + + #[test] + fn input_source_status_serializes_macos_input_platform() { + let platform = SourcePlatformStatus::MacosInput(MacosInputPlatformStatus { + keyboard: MacosProtectedSourceState::NeedsProcessRestart, + pointer: MacosProtectedSourceState::Live, + keyboard_tcc: MacosAuthorizationState::Authorized, + secure_input_active: true, + 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, + })), + 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), + callback_to_publication_timing: Some(timing_fixture( + 990, 1_980_000, 4_000, 2_000, 3_000, + )), + }); + 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!( + value["platform"], + json!({ + "type": "macos_input", + "keyboard": "needs_process_restart", + "pointer": "live", + "keyboard_tcc": "authorized", + "secure_input_active": true, + "keyboard_owner": "app_sidecar", + "pointer_owner": "broker", + "owner_conflict": { + "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, + "callback_to_publication_timing": { + "sample_count": 990, + "total_ns": 1_980_000, + "max_ns": 4_000, + "p95_ns": 2_000, + "p99_ns": 3_000 + } + } + }) + ); + } + + #[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" + } + }) + ); + } + + #[tokio::test] + async 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"), + }, + selection_diagnostic_label: Some(Arc::from("multiple_windows")), + selection_revision: 17, + tahoe: MacosTahoeCapabilities { + host_architecture: MacosArchitecture::AppleSilicon, + translated_process: true, + content_tone_mapping_info: true, + metal4: false, + }, + tahoe_selection: Some(MacosTahoeSelectionCapabilities { + source_id: Arc::from("macos:session:multiple-windows:w42:a18:com.secret.private"), + 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, + })), + 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")), + timing: MacosScreenTimingStatus { + callback: timing_fixture(10, 900, 90, 80, 90), + retain: timing_fixture(10, 400, 40, 30, 40), + enqueue: timing_fixture(10, 300, 30, 20, 30), + conversion: timing_fixture(10, 700, 70, 60, 70), + cpu_reduction: timing_fixture(10, 1_100, 110, 100, 110), + native_import: timing_fixture(10, 600, 60, 50, 60), + native_reduction_submit: timing_fixture(10, 800, 80, 70, 80), + publication: timing_fixture(10, 500, 50, 40, 50), + capture_to_native_publication: timing_fixture( + 8, 8_000_000, 1_200_000, 1_000_000, 1_200_000, + ), + capture_to_converted_publication: timing_fixture( + 6, 9_000_000, 1_800_000, 1_600_000, 1_800_000, + ), + }, + 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 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); + 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["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"); + 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); + assert_eq!(telemetry["timing"]["callback"]["sample_count"], 10); + assert_eq!(telemetry["timing"]["enqueue"]["p99_ns"], 30); + assert_eq!( + telemetry["timing"]["capture_to_native_publication"]["p95_ns"], + 1_000_000 + ); + assert_eq!( + telemetry["timing"]["capture_to_converted_publication"]["sample_count"], + 6 + ); + + 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(), true); + 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" }) + ); + + 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] + 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("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("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("MacosTimingApiStatus")); + assert!(schemas.contains_key("MacosScreenTimingApiStatus")); + 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")); + assert!(encoded.contains("macos_screen")); + } + #[expect( clippy::too_many_lines, reason = "Status response assertions cover many nested metrics fields in one scenario" @@ -1643,6 +2932,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, @@ -1783,6 +3073,46 @@ mod tests { assert!(delivered_fps > 0.0); assert!(delivered_fps < 60.0); assert_eq!(json["data"]["render_loop"]["actual_fps"], 60.0); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["sample_count"], + 2 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["p95_ms"], + 0.1 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["p99_ms"], + 0.1 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["cumulative_histogram"]["bucket_width_us"], + 100 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["cumulative_histogram"]["overflow_bucket_index"], + 4096 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["cumulative_histogram"]["snapshot_frame_token"], + 77 + ); + assert_eq!( + json["data"]["session_performance"]["input_stage"]["cumulative_histogram"]["buckets"], + serde_json::json!([{ "bucket_index": 1, "count": 2 }]) + ); + assert_eq!( + json["data"]["session_performance"]["full_frame_cpu_copies"]["count"], + 4 + ); + assert_eq!( + json["data"]["session_performance"]["full_frame_cpu_copies"]["frames"], + 2 + ); + assert_eq!( + json["data"]["session_performance"]["full_frame_cpu_copies"]["bytes"], + 512_000 + ); assert_eq!( json["data"]["compositor_acceleration"]["requested_mode"], "cpu" diff --git a/crates/hypercolor-daemon/src/api/ws/protocol.rs b/crates/hypercolor-daemon/src/api/ws/protocol.rs index fb1a00cfd..aaa7c0856 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>, @@ -1190,6 +1266,7 @@ pub(super) struct SceneRef { pub(super) struct MetricsPayload { pub(super) fps: MetricsFps, pub(super) frame_time: MetricsFrameTime, + pub(super) input_latency: MetricsSessionLatency, pub(super) stages: MetricsStages, pub(super) pacing: MetricsPacing, pub(super) effect_health: MetricsEffectHealth, @@ -1225,6 +1302,15 @@ pub(super) struct MetricsFrameTime { pub(super) max_ms: f64, } +#[derive(Debug, Serialize)] +pub(super) struct MetricsSessionLatency { + pub(super) sample_count: u64, + pub(super) avg_ms: f64, + pub(super) p95_ms: f64, + pub(super) p99_ms: f64, + pub(super) max_ms: f64, +} + #[derive(Debug, Serialize)] #[allow( clippy::struct_field_names, @@ -1455,6 +1541,9 @@ pub(super) struct MetricsCopies { pub(super) publication_full_frame_count: u32, pub(super) publication_full_frame_kb: f64, pub(super) publication_reason: Option<&'static str>, + pub(super) session_full_frame_count: u64, + pub(super) session_full_frame_frames: u64, + pub(super) session_full_frame_bytes: u64, } #[derive(Debug, Serialize)] diff --git a/crates/hypercolor-daemon/src/api/ws/relays.rs b/crates/hypercolor-daemon/src/api/ws/relays.rs index e36f929ab..653ae5813 100644 --- a/crates/hypercolor-daemon/src/api/ws/relays.rs +++ b/crates/hypercolor-daemon/src/api/ws/relays.rs @@ -43,8 +43,8 @@ use super::protocol::{ ActiveFramesConfig, CanvasConfig, MetricsCopies, MetricsDevices, MetricsDisplayLane, MetricsDisplayOutput, MetricsEffectHealth, MetricsFps, MetricsFrameTime, MetricsMemory, MetricsPacing, MetricsPayload, MetricsPreview, MetricsPreviewDemand, MetricsRenderSurfaces, - MetricsStages, MetricsTimeline, MetricsWebsocket, ServerMessage, SpectrumConfig, - SubscriptionState, WsChannel, event_message_parts, should_relay_event, + MetricsSessionLatency, MetricsStages, MetricsTimeline, MetricsWebsocket, ServerMessage, + SpectrumConfig, SubscriptionState, WsChannel, event_message_parts, should_relay_event, }; use crate::api::AppState; use crate::interactive_preview::PreviewResourceLease; @@ -2761,6 +2761,13 @@ pub(super) async fn build_metrics_message( p99_ms: round_2(frame_time.p99_ms), max_ms: round_2(frame_time.max_ms), }, + input_latency: MetricsSessionLatency { + sample_count: performance_snapshot.input_time_sample_count, + avg_ms: round_2(performance_snapshot.input_time.avg_ms), + p95_ms: round_2(performance_snapshot.input_time.p95_ms), + p99_ms: round_2(performance_snapshot.input_time.p99_ms), + max_ms: round_2(performance_snapshot.input_time.max_ms), + }, stages: MetricsStages { input_sampling_ms: round_2(us_to_ms(latest_frame.input_us)), producer_rendering_ms: round_2(us_to_ms(latest_frame.producer_us)), @@ -3097,6 +3104,9 @@ pub(super) async fn build_metrics_message( latest_frame.publication_full_frame_copy.bytes, )), publication_reason: latest_frame.publication_full_frame_copy.reason, + session_full_frame_count: performance_snapshot.full_frame_copy_count_total, + session_full_frame_frames: performance_snapshot.full_frame_copy_frames_total, + session_full_frame_bytes: performance_snapshot.full_frame_copy_bytes_total, }, memory: MetricsMemory { daemon_rss_mb: round_1(daemon_rss_mb), diff --git a/crates/hypercolor-daemon/src/api/ws/session.rs b/crates/hypercolor-daemon/src/api/ws/session.rs index 299b17b04..20c1fc8b4 100644 --- a/crates/hypercolor-daemon/src/api/ws/session.rs +++ b/crates/hypercolor-daemon/src/api/ws/session.rs @@ -107,18 +107,39 @@ pub(crate) async fn ws_handler( pub(crate) fn spawn_trusted_local_socket( state: Arc, runtime: &tokio::runtime::Handle, +) -> TrustedLocalWebSocket { + spawn_local_socket_with_context( + state, + runtime, + crate::api::security::trusted_local_control_context(), + ) +} + +fn spawn_local_socket_with_context( + state: Arc, + runtime: &tokio::runtime::Handle, + auth_context: RequestAuthContext, ) -> TrustedLocalWebSocket { let (socket, transport) = trusted_local_socket_pair(); let shutdown = transport.shutdown_token(); drop(runtime.spawn(handle_socket( SessionSocket::Local(transport), state, - crate::api::security::trusted_local_control_context(), + auth_context, Some(shutdown), ))); socket } +#[cfg(test)] +pub(super) fn spawn_test_local_socket( + state: Arc, + runtime: &tokio::runtime::Handle, + auth_context: RequestAuthContext, +) -> TrustedLocalWebSocket { + spawn_local_socket_with_context(state, runtime, auth_context) +} + enum SessionSocket { Network(WebSocket), Local(TrustedLocalSocketTransport), @@ -156,7 +177,7 @@ fn ws_origin_allowed(state: &AppState, headers: &HeaderMap) -> bool { return true; }; - if is_loopback_origin(origin) { + if is_loopback_origin(origin) || crate::api::security::is_trusted_tauri_origin(origin) { return true; } @@ -1127,7 +1148,7 @@ pub(super) fn authorize_subscription_channels( auth_context: RequestAuthContext, channels: &[WsChannel], ) -> Result<(), WsProtocolError> { - if auth_context.can_control() { + if auth_context.can_protected_control() { return Ok(()); } @@ -1142,7 +1163,7 @@ pub(super) fn authorize_subscription_channels( Ok(()) } else { Err(WsProtocolError::forbidden( - "Screen capture preview subscriptions require a control-tier API key", + "Sensitive screen and input subscriptions require a control credential", json!({"channels": restricted_channels, "required_tier": "control"}), )) } @@ -1346,7 +1367,12 @@ async fn handle_client_message( height, format, }; - let result = match ensure_control_tier(auth_context) { + // Opening a preview raises real screen, audio, and interaction + // capture demand, so it rides the protected-capture credential + // like every other capture actuation; the remaining preview + // verbs manage an already-authorized session and stay at the + // control tier. + let result = match ensure_protected_control(auth_context) { Ok(()) => browser_previews.open(preview_id, config).await, Err(error) => Err(error), } @@ -1399,6 +1425,17 @@ fn ensure_control_tier(auth_context: RequestAuthContext) -> Result<(), WsProtoco } } +fn ensure_protected_control(auth_context: RequestAuthContext) -> Result<(), WsProtocolError> { + if auth_context.can_protected_control() { + Ok(()) + } else { + Err(WsProtocolError::forbidden( + "Protected capture access requires a control credential", + serde_json::json!({"required_tier": "protected_control"}), + )) + } +} + async fn handle_zone_layout_preview( state: &Arc, zone_layout_preview_keys: &mut HashSet<(SceneId, ZoneId)>, @@ -1710,6 +1747,32 @@ mod origin_tests { ))); } + #[test] + fn exact_bundled_tauri_origins_are_allowed_but_lookalikes_are_rejected() { + let state = AppState::new(); + for origin in [ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + ] { + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + origin.parse().expect("native origin should parse"), + ); + assert!(ws_origin_allowed(&state, &headers)); + } + + for origin in ["tauri://attacker.example", "https://tauri.localhost.evil"] { + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + origin.parse().expect("lookalike origin should parse"), + ); + assert!(!ws_origin_allowed(&state, &headers)); + } + } + #[test] fn origin_comparison_is_case_insensitive() { let origin = HeaderValue::from_static("https://studio.example"); diff --git a/crates/hypercolor-daemon/src/api/ws/tests.rs b/crates/hypercolor-daemon/src/api/ws/tests.rs index 3a4bd50dc..39cd20b83 100644 --- a/crates/hypercolor-daemon/src/api/ws/tests.rs +++ b/crates/hypercolor-daemon/src/api/ws/tests.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, LazyLock, Mutex as StdMutex, PoisonError}; use std::time::{Duration, SystemTime}; use axum::body::Bytes; -use axum::extract::ws::Utf8Bytes; +use axum::extract::ws::{Message, Utf8Bytes}; use axum::response::IntoResponse; use tokio::sync::{RwLock, watch}; @@ -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, @@ -79,7 +80,7 @@ use super::relays::{ }; use super::session::{ BrowserPreviewSession, WsInputDemandLeases, authorize_subscription_channels, - negotiate_preview_transport, validated_zone_layout_preview, + negotiate_preview_transport, spawn_test_local_socket, validated_zone_layout_preview, }; use crate::api::AppState; use crate::api::security::{RequestAuthContext, SecurityState}; @@ -567,6 +568,7 @@ async fn metrics_message_includes_latest_frame_timeline() { performance.record_effect_fallback_applied(); performance.record_frame(&LatestFrameMetrics { timestamp_ms: 1234, + input_sampled: true, input_us: 200, deferred_sample_us: 60, producer_us: 900, @@ -719,6 +721,9 @@ async fn metrics_message_includes_latest_frame_timeline() { assert_eq!(json["timeline"]["gpu_readback_failed"], true); assert_eq!(json["timeline"]["budget_ms"], 16.67); assert_eq!(json["timeline"]["wake_late_ms"], 0.22); + assert_eq!(json["input_latency"]["sample_count"], 1); + assert_eq!(json["input_latency"]["p95_ms"], 0.2); + assert_eq!(json["input_latency"]["p99_ms"], 0.2); assert_eq!(json["pacing"]["push_avg_ms"], 0.25); assert_eq!(json["pacing"]["push_p95_ms"], 0.25); assert_eq!(json["pacing"]["publish_avg_ms"], 0.18); @@ -741,6 +746,9 @@ async fn metrics_message_includes_latest_frame_timeline() { assert_eq!(json["pacing"]["output_published_frame"], 1); assert_eq!(json["pacing"]["output_routed_reuse"], 0); assert_eq!(json["pacing"]["output_reused_published_frame"], 1); + assert_eq!(json["copies"]["session_full_frame_count"], 2); + assert_eq!(json["copies"]["session_full_frame_frames"], 1); + assert_eq!(json["copies"]["session_full_frame_bytes"], 2_048); assert_eq!(json["render_surfaces"]["scene_pool_slot_count"], 10); assert_eq!(json["render_surfaces"]["scene_pool_max_slots"], 12); assert_eq!(json["render_surfaces"]["direct_pool_slot_count"], 6); @@ -2222,6 +2230,83 @@ fn read_only_auth_rejects_private_capture_subscriptions() { ); } +#[test] +fn unsecured_loopback_auth_rejects_private_capture_subscriptions() { + let channels = [ + WsChannel::ScreenCanvas, + WsChannel::ScreenZones, + WsChannel::InputEvents, + ]; + + let error = authorize_subscription_channels(RequestAuthContext::unsecured(), &channels) + .expect_err("loopback locality must not authorize sensitive subscriptions"); + + assert_eq!(error.code, "forbidden"); + assert_eq!( + error.details, + Some(serde_json::json!({ + "channels": ["screen_canvas", "screen_zones", "input_events"], + "required_tier": "control" + })) + ); +} + +#[tokio::test] +async fn rejected_private_subscription_creates_no_input_demand() { + let state = Arc::new(AppState::new()); + let mut socket = spawn_test_local_socket( + Arc::clone(&state), + &tokio::runtime::Handle::current(), + RequestAuthContext::unsecured(), + ); + let hello = socket.recv().await.expect("test socket should emit hello"); + assert!(matches!(hello, Message::Text(_))); + + socket + .send(Message::Text( + serde_json::json!({ + "type": "subscribe", + "channels": ["screen_canvas", "screen_zones", "input_events"] + }) + .to_string() + .into(), + )) + .await + .expect("test socket should accept subscription request"); + let rejection = socket + .recv() + .await + .expect("test socket should emit subscription rejection"); + let Message::Text(rejection) = rejection else { + panic!("subscription rejection should be JSON text"); + }; + let rejection: serde_json::Value = + serde_json::from_str(rejection.as_str()).expect("rejection should be JSON"); + assert_eq!(rejection["type"], "error"); + assert_eq!(rejection["code"], "forbidden"); + + assert_eq!( + state + .input_publication_demands + .registration_count(InputPublicationConsumer::PassiveStream), + 0 + ); + assert_eq!( + state + .input_publication_demands + .requested_hz(SourceKind::Screen), + 0 + ); + assert_eq!( + state + .input_publication_demands + .requested_hz(SourceKind::Interaction), + 0 + ); + + socket.shutdown().await; +} + #[test] fn read_only_auth_allows_non_capture_preview_subscriptions() { let channels = [ @@ -2550,6 +2635,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, }), @@ -2558,6 +2644,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); } @@ -2944,7 +3031,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 +3040,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 +3064,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 +3092,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 +3225,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] @@ -3790,6 +3967,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() @@ -4192,6 +4381,65 @@ async fn dispatch_command_preserves_secured_ws_auth_context() { } } +#[tokio::test] +async fn dispatch_command_rejects_unsecured_protected_capture_access() { + let state = Arc::new(AppState::new()); + let message = dispatch_command( + &state, + RequestAuthContext::unsecured(), + "cmd_capture_monitors".to_owned(), + "GET".to_owned(), + "/capture/monitors".to_owned(), + None, + ) + .await; + + match message { + ServerMessage::Response { + status, + data, + error, + .. + } => { + assert_eq!(status, 403); + assert!(data.is_none()); + assert_eq!( + error.and_then(|value| value.get("code").cloned()), + Some(serde_json::json!("forbidden")) + ); + } + _ => panic!("expected command response"), + } +} + +#[tokio::test] +async fn dispatch_command_allows_control_protected_capture_access() { + let state = secured_state(); + let message = dispatch_command( + &state, + RequestAuthContext::control(), + "cmd_capture_monitors".to_owned(), + "GET".to_owned(), + "/capture/monitors".to_owned(), + None, + ) + .await; + + match message { + ServerMessage::Response { + status, + data, + error, + .. + } => { + assert_eq!(status, 200); + assert!(data.is_some()); + assert!(error.is_none()); + } + _ => panic!("expected command response"), + } +} + #[tokio::test] async fn dispatch_command_requires_auth_context_when_security_is_enabled() { let state = secured_state(); diff --git a/crates/hypercolor-daemon/src/daemon.rs b/crates/hypercolor-daemon/src/daemon.rs index c3ec35acc..67ce26ef6 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, MacosDaemonSessionAttestation, MacosOwnerSnapshot}; use crate::mdns::MdnsPublisher; use crate::startup::{DaemonState, load_config}; @@ -48,6 +49,131 @@ 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, + /// Exact private process session derived from canonical macOS ownership. + pub macos_daemon_session_attestation: Option, +} + +/// Ownership handle for the exact sockets bound during daemon preparation. +/// +/// Each handle is a duplicate descriptor for the same listening socket used +/// by Tokio. Keeping the lease alive prevents another process from binding the +/// API address after serving stops and before process-level authority is +/// invalidated. +#[doc(hidden)] +pub struct ApiListenerLease { + _listeners: Vec, +} + +/// Daemon startup state whose final API sockets are already bound. +#[doc(hidden)] +pub struct PreparedDaemon { + options: DaemonRunOptions, + config: HypercolorConfig, + config_path: PathBuf, + listen_addr: String, + listeners: Vec, + listener_lease: Option, + advertised_bind: SocketAddr, +} + +impl PreparedDaemon { + /// Resume a prepared daemon using its already-bound API listeners. + /// + /// # Errors + /// + /// Returns an error when subsystem startup, serving, or shutdown fails. + pub async fn run(self, shutdown_rx: watch::Receiver) -> Result<()> { + self.run_with_extensions(shutdown_rx, &[]).await + } + + /// Return the primary address owned by this prepared daemon. + #[must_use] + pub const fn advertised_bind(&self) -> SocketAddr { + self.advertised_bind + } + + /// Attach the exact macOS process session published after socket binding. + pub fn install_macos_daemon_session_attestation( + &mut self, + attestation: MacosDaemonSessionAttestation, + ) { + self.options.macos_daemon_session_attestation = Some(attestation); + } + + /// Transfer the socket lifetime lease to the process-level owner. + /// + /// # Errors + /// + /// Returns an error when the lease was already transferred. + pub fn take_api_listener_lease(&mut self) -> Result { + self.listener_lease + .take() + .context("prepared API listener lease was already transferred") + } + + async fn run_with_extensions( + mut self, + shutdown_rx: watch::Receiver, + extension_installers: &[&dyn DaemonExtensionInstaller], + ) -> Result<()> { + let macos_daemon_session_attestation = + self.options.macos_daemon_session_attestation.clone(); + let listeners = std::mem::take(&mut self.listeners); + let mut daemon_state = DaemonState::initialize_with_macos_owner( + &self.config, + self.config_path.clone(), + self.options.macos_owner_snapshot, + )?; + for installer in extension_installers { + installer.install(&mut daemon_state)?; + } + daemon_state.start().await?; + + let ui_dir = resolve_ui_dir(self.options.ui_dir.clone()); + let mut app_state = AppState::from_daemon_state(&daemon_state); + if let Some(attestation) = macos_daemon_session_attestation.as_ref() { + app_state.install_macos_daemon_session(attestation); + } + let app_state = Arc::new(app_state); + api::displays::sync_display_preference_overlays(&app_state).await; + if let Err(error) = notify_api_ready_extensions(&daemon_state, &app_state).await { + if let Err(shutdown_error) = daemon_state.shutdown().await { + warn!(%shutdown_error, "Failed to roll back daemon after API-ready hook failure"); + } + return Err(error); + } + let router = api::build_router(app_state, ui_dir.as_deref()); + + let mdns_publisher = MdnsPublisher::new( + &daemon_state.server_identity, + self.advertised_bind, + self.config.network.mdns_publish, + api::security::api_auth_required_from_env(), + )?; + + if ui_dir.is_some() { + info!(url = %format!("http://{}/", self.advertised_bind), "Web UI available"); + } + info!(binds = %self.listen_addr, "API server listening"); + + notify_ready(); + spawn_watchdog(); + + serve_api_listeners(listeners, router, shutdown_rx).await?; + + if let Some(publisher) = mdns_publisher { + publisher.shutdown().await; + } + + daemon_state.shutdown().await?; + + info!("Hypercolor daemon exited cleanly"); + Ok(()) + } } pub trait DaemonExtensionInstaller: Send + Sync { @@ -95,6 +221,21 @@ pub async fn run_with_extensions( shutdown_rx: watch::Receiver, extension_installers: &[&dyn DaemonExtensionInstaller], ) -> Result<()> { + prepare(options) + .await? + .run_with_extensions(shutdown_rx, extension_installers) + .await +} + +/// Load configuration and bind every final API listener without starting the +/// daemon subsystems or accepting connections. +/// +/// # Errors +/// +/// Returns an error when configuration, address resolution, authentication +/// validation, or any final listener bind fails. +#[doc(hidden)] +pub async fn prepare(options: DaemonRunOptions) -> Result { // Must land before any registry scan, which resolves the bundled catalog // the first time it enumerates effects. if options.effects_dir.is_some() { @@ -162,55 +303,22 @@ pub async fn run_with_extensions( config.network.unauthenticated_remote_access_allowed(), )?; } - let listeners = bind_api_listeners(&binds)?; + let (listeners, listener_lease) = bind_api_listeners(&binds)?; let advertised_bind = listeners .first() .context("no API listeners were bound")? .local_addr() .context("failed to read API listener address")?; - let mut daemon_state = DaemonState::initialize(&config, config_path)?; - for installer in extension_installers { - installer.install(&mut daemon_state)?; - } - daemon_state.start().await?; - - let ui_dir = resolve_ui_dir(options.ui_dir); - let app_state = Arc::new(AppState::from_daemon_state(&daemon_state)); - api::displays::sync_display_preference_overlays(&app_state).await; - if let Err(error) = notify_api_ready_extensions(&daemon_state, &app_state).await { - if let Err(shutdown_error) = daemon_state.shutdown().await { - warn!(%shutdown_error, "Failed to roll back daemon after API-ready hook failure"); - } - return Err(error); - } - let router = api::build_router(app_state, ui_dir.as_deref()); - - let mdns_publisher = MdnsPublisher::new( - &daemon_state.server_identity, + Ok(PreparedDaemon { + options, + config, + config_path, + listen_addr, + listeners, + listener_lease: Some(listener_lease), advertised_bind, - config.network.mdns_publish, - api::security::api_auth_required_from_env(), - )?; - - if ui_dir.is_some() { - info!(url = %format!("http://{advertised_bind}/"), "Web UI available"); - } - info!(binds = %listen_addr, "API server listening"); - - notify_ready(); - spawn_watchdog(); - - serve_api_listeners(listeners, router, shutdown_rx).await?; - - if let Some(publisher) = mdns_publisher { - publisher.shutdown().await; - } - - daemon_state.shutdown().await?; - - info!("Hypercolor daemon exited cleanly"); - Ok(()) + }) } async fn notify_api_ready_extensions(daemon: &DaemonState, state: &Arc) -> Result<()> { @@ -363,16 +471,18 @@ async fn resolve_bind_targets(targets: &[String]) -> Result> { Ok(resolved) } -fn bind_api_listeners(binds: &[SocketAddr]) -> Result> { +fn bind_api_listeners(binds: &[SocketAddr]) -> Result<(Vec, ApiListenerLease)> { let mut listeners = Vec::with_capacity(binds.len()); + let mut leases = Vec::with_capacity(binds.len()); for bind in binds { - let listener = bind_api_listener(*bind) + let (listener, lease) = bind_api_listener_with_lease(*bind) .with_context(|| format!("failed to bind API server to {bind}"))?; listeners.push(listener); + leases.push(lease); } - Ok(listeners) + Ok((listeners, ApiListenerLease { _listeners: leases })) } /// Construct one API TCP listener with the daemon's socket options. @@ -383,6 +493,10 @@ fn bind_api_listeners(binds: &[SocketAddr]) -> Result> { /// listened on, or converted into a Tokio listener. #[doc(hidden)] pub fn bind_api_listener(bind: SocketAddr) -> Result { + bind_api_listener_with_lease(bind).map(|(listener, _lease)| listener) +} + +fn bind_api_listener_with_lease(bind: SocketAddr) -> Result<(TcpListener, std::net::TcpListener)> { let socket = Socket::new( if bind.is_ipv4() { Domain::IPV4 @@ -407,7 +521,12 @@ pub fn bind_api_listener(bind: SocketAddr) -> Result { let listener: std::net::TcpListener = socket.into(); listener.set_nonblocking(true)?; - TcpListener::from_std(listener).context("failed to create async TCP listener") + let lease = listener + .try_clone() + .context("failed to duplicate API listener ownership handle")?; + let listener = + TcpListener::from_std(listener).context("failed to create async TCP listener")?; + Ok((listener, lease)) } async fn serve_api_listeners( @@ -738,7 +857,10 @@ mod tests { use hypercolor_core::config::ConfigManager; use hypercolor_types::config::{HypercolorConfig, LogLevel, RenderAccelerationMode}; - use super::{default_env_filter, notify_api_ready_extensions, resolve_log_level}; + use super::{ + bind_api_listener, bind_api_listener_with_lease, default_env_filter, + notify_api_ready_extensions, resolve_log_level, serve_api_listeners_with_shutdown_timeout, + }; use crate::api::AppState; use crate::extensions::DaemonLifecycleExtension; use crate::startup::{DaemonState, default_config}; @@ -818,6 +940,51 @@ mod tests { } } + #[tokio::test] + async fn exact_prebound_listener_is_served_and_leased_until_explicit_release() { + let (listener, lease) = bind_api_listener_with_lease( + "127.0.0.1:0" + .parse() + .expect("ephemeral loopback address should parse"), + ) + .expect("listener and lease should bind together"); + let address = listener + .local_addr() + .expect("prepared listener address should resolve"); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let router = axum::Router::new().route( + "/listener-identity", + axum::routing::get(|| async { "prepared-listener" }), + ); + let server = tokio::spawn(serve_api_listeners_with_shutdown_timeout( + vec![listener], + router, + shutdown_rx, + tokio::time::Duration::from_secs(1), + )); + + let response = reqwest::get(format!("http://{address}/listener-identity")) + .await + .expect("request should reach the prepared listener"); + assert_eq!( + response.text().await.expect("response body should read"), + "prepared-listener" + ); + shutdown_tx + .send(true) + .expect("shutdown signal should reach the listener"); + server + .await + .expect("listener task should join") + .expect("listener shutdown should succeed"); + + bind_api_listener(address).expect_err("lease must keep the exact socket unavailable"); + drop(lease); + let rebound = + bind_api_listener(address).expect("dropping the lease should release the port"); + drop(rebound); + } + #[tokio::test] async fn api_ready_hooks_receive_the_serving_state_in_registration_order() { let directory = tempfile::tempdir().expect("daemon test directory should be created"); diff --git a/crates/hypercolor-daemon/src/lib.rs b/crates/hypercolor-daemon/src/lib.rs index a82fcd37a..160266afb 100644 --- a/crates/hypercolor-daemon/src/lib.rs +++ b/crates/hypercolor-daemon/src/lib.rs @@ -21,6 +21,9 @@ pub mod layout_auto_exclusions; pub mod layout_store; pub mod library; pub mod logical_devices; +pub mod macos_owner; +#[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] +pub mod macos_tcc_canary; pub mod mcp; pub mod mdns; pub mod network; diff --git a/crates/hypercolor-daemon/src/macos_launcher_authority.rs b/crates/hypercolor-daemon/src/macos_launcher_authority.rs new file mode 100644 index 000000000..5ce93ab41 --- /dev/null +++ b/crates/hypercolor-daemon/src/macos_launcher_authority.rs @@ -0,0 +1,698 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use hypercolor_macos_owner::{MACOS_APP_BUNDLE_BINARY_NAMES, MacosDaemonOwner}; +use security_framework::os::macos::code_signing::{ + Flags as CodeSigningFlags, GuestAttributes, SecCode, SecRequirement, +}; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + +pub const MACOS_OWNER_ENV: &str = "HYPERCOLOR_MACOS_OWNER"; + +const DIRECT_LAUNCHD_LABEL: &str = "tech.hyperbliss.hypercolor"; +const HOMEBREW_LAUNCHD_LABEL: &str = "homebrew.mxcl.hypercolor"; +const SIDECAR_REQUIREMENT_PREFIX: &str = "identifier \"tech.hyperbliss.hypercolor.sidecar\" and "; +const APP_REQUIREMENT_PREFIX: &str = "identifier \"tech.hyperbliss.hypercolor\" and "; +const MAX_COMMAND_OUTPUT_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MacosLauncherAuthorityEvidence { + pub app_sidecar: bool, + pub direct_launchd: bool, + pub homebrew: bool, + pub standalone: bool, +} + +impl MacosLauncherAuthorityEvidence { + fn exact_owner(self) -> Result { + let candidates = [ + (self.app_sidecar, MacosDaemonOwner::AppSidecar), + (self.direct_launchd, MacosDaemonOwner::DirectLaunchd), + (self.homebrew, MacosDaemonOwner::Homebrew), + (self.standalone, MacosDaemonOwner::Standalone), + ] + .into_iter() + .filter_map(|(matches, owner)| matches.then_some(owner)) + .collect::>(); + + match candidates.as_slice() { + [owner] => Ok(*owner), + [] => anyhow::bail!("macOS daemon launcher authority is missing"), + _ => anyhow::bail!("macOS daemon launcher authority is ambiguous"), + } + } +} + +pub fn parse_macos_owner_claim(value: &str) -> Result { + match value { + "app-sidecar" => Ok(MacosDaemonOwner::AppSidecar), + "direct-launchd" => Ok(MacosDaemonOwner::DirectLaunchd), + "homebrew" => Ok(MacosDaemonOwner::Homebrew), + "standalone" => Ok(MacosDaemonOwner::Standalone), + _ => anyhow::bail!("invalid macOS daemon launcher claim {value:?}"), + } +} + +pub fn resolve_macos_launcher_owner( + environment_claim: Option<&OsStr>, + argument_claim: Option, + evidence: MacosLauncherAuthorityEvidence, +) -> Result { + let environment_claim = environment_claim + .map(|value| { + value + .to_str() + .context("macOS daemon launcher environment claim is not UTF-8") + .and_then(parse_macos_owner_claim) + }) + .transpose()?; + if let (Some(environment), Some(argument)) = (environment_claim, argument_claim) { + anyhow::ensure!( + environment == argument, + "conflicting macOS daemon launcher claims: environment={} argument={}", + owner_name(environment), + owner_name(argument) + ); + } + + let claim = environment_claim.or(argument_claim); + let authority = evidence.exact_owner()?; + if let Some(claim) = claim { + anyhow::ensure!( + claim == authority, + "macOS daemon launcher claim {} is not corroborated by {} authority", + owner_name(claim), + owner_name(authority) + ); + } + Ok(authority) +} + +pub fn inspect_macos_launcher_authority( + current_executable: &Path, + current_designated_requirement: &str, +) -> Result { + let current_pid = std::process::id(); + let (parent_pid, parent_executable) = current_process_parent(current_pid)?; + let direct_launchd = launchctl_service_pid(DIRECT_LAUNCHD_LABEL)? == Some(current_pid); + let homebrew = launchctl_service_pid(HOMEBREW_LAUNCHD_LABEL)? == Some(current_pid); + let mut app_sidecar = app_sidecar_parent_is_valid( + current_executable, + current_designated_requirement, + parent_pid, + &parent_executable, + )?; + if app_sidecar { + let (rechecked_parent_pid, rechecked_parent_executable) = + current_process_parent(current_pid)?; + app_sidecar = rechecked_parent_pid == parent_pid + && paths_are_equal(&rechecked_parent_executable, &parent_executable); + } + // Standalone is the user-directed residual: no positively attested + // manager owns this process. Gating it on the parent binary's name + // (shell allowlists) adds no authority (any launcher can interpose + // `sh -c`) and breaks cargo, sudo, and supervisor launches. + let standalone = !app_sidecar && !direct_launchd && !homebrew; + + Ok(MacosLauncherAuthorityEvidence { + app_sidecar, + direct_launchd, + homebrew, + standalone, + }) +} + +const fn owner_name(owner: MacosDaemonOwner) -> &'static str { + match owner { + MacosDaemonOwner::AppSidecar => "app-sidecar", + MacosDaemonOwner::DirectLaunchd => "direct-launchd", + MacosDaemonOwner::Homebrew => "homebrew", + MacosDaemonOwner::Standalone => "standalone", + } +} + +fn current_process_parent(current_pid: u32) -> Result<(u32, PathBuf)> { + let mut system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + system.refresh_processes(ProcessesToUpdate::All, true); + let current = system + .process(Pid::from_u32(current_pid)) + .context("current daemon is missing from the process table")?; + let parent_pid = current + .parent() + .context("current daemon has no inspectable parent process")?; + let parent = system + .process(parent_pid) + .context("daemon parent disappeared during launcher inspection")?; + let parent_executable = parent + .exe() + .context("daemon parent executable is unavailable")? + .to_path_buf(); + Ok((parent_pid.as_u32(), parent_executable)) +} + +fn app_sidecar_parent_is_valid( + current_executable: &Path, + current_designated_requirement: &str, + parent_pid: u32, + parent_executable: &Path, +) -> Result { + if !app_sidecar_layout_is_valid(current_executable) { + return Ok(false); + } + let daemon_directory = current_executable + .parent() + .expect("validated app sidecar path has a parent"); + if !parent_is_adjacent_app_binary(parent_executable, daemon_directory) { + return Ok(false); + } + + let Some(parent_requirement) = app_parent_requirement(current_designated_requirement) else { + // Unsigned local builds are ad-hoc signed and carry a bare cdhash + // requirement with no identifier chain to verify, so the signed + // parent/daemon requirement handshake is impossible. Structural + // evidence still holds: the launcher must be live code executing + // the exact app binary adjacent to this daemon inside a bundle + // layout. A daemon whose own requirement carries the identifier + // chain never takes this path; for signed builds the full + // handshake stays mandatory. + if is_adhoc_requirement(current_designated_requirement) { + return live_process_path_matches(parent_pid, parent_executable); + } + return Ok(false); + }; + + let parent_valid = + live_process_satisfies_requirement(parent_pid, parent_executable, &parent_requirement)?; + let daemon_valid = + current_process_satisfies_requirement(current_executable, current_designated_requirement)?; + Ok(parent_valid && daemon_valid) +} + +fn is_adhoc_requirement(requirement: &str) -> bool { + requirement.starts_with("cdhash ") +} + +/// Whether the observed parent executable is one of the bundle's app +/// binaries sitting in the same `Contents/MacOS` directory as the daemon. +/// +/// The canonical file name must match the candidate byte-for-byte: on the +/// default case-insensitive APFS volume, a product-cased candidate would +/// otherwise resolve onto the lowercase CLI binary in the same directory. +fn parent_is_adjacent_app_binary(parent_executable: &Path, daemon_directory: &Path) -> bool { + MACOS_APP_BUNDLE_BINARY_NAMES.iter().any(|name| { + paths_are_equal(parent_executable, &daemon_directory.join(name)) + && parent_executable + .canonicalize() + .ok() + .is_some_and(|path| path.file_name() == Some(OsStr::new(name))) + }) +} + +fn live_process_path_matches(pid: u32, expected: &Path) -> Result { + let pid = i32::try_from(pid).context("launcher pid exceeds the macOS pid range")?; + let mut attributes = GuestAttributes::new(); + attributes.set_pid(pid); + let code = SecCode::copy_guest_with_attribues(None, &attributes, CodeSigningFlags::NONE) + .context("failed to inspect the live launcher code identity")?; + let Some(observed) = code + .path(CodeSigningFlags::NONE) + .context("failed to resolve the live launcher code path")? + .to_path() + else { + return Ok(false); + }; + Ok(code_path_matches(&observed, expected, true)) +} + +fn app_sidecar_layout_is_valid(current_executable: &Path) -> bool { + let Some(executable_directory) = current_executable.parent() else { + return false; + }; + let is_sidecar_name = current_executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "hypercolor-daemon" || name.starts_with("hypercolor-daemon-")); + let is_bundle_macos_directory = executable_directory + .file_name() + .and_then(|name| name.to_str()) + == Some("MacOS") + && executable_directory + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some("Contents"); + is_sidecar_name && is_bundle_macos_directory +} + +fn app_parent_requirement(sidecar_requirement: &str) -> Option { + let tail = sidecar_requirement.strip_prefix(SIDECAR_REQUIREMENT_PREFIX)?; + (!tail.is_empty()).then(|| format!("{APP_REQUIREMENT_PREFIX}{tail}")) +} + +fn current_process_satisfies_requirement(path: &Path, requirement: &str) -> Result { + let code = SecCode::for_self(CodeSigningFlags::NONE) + .context("failed to inspect the live daemon code identity")?; + code_satisfies_requirement(&code, path, requirement, false) +} + +fn live_process_satisfies_requirement(pid: u32, path: &Path, requirement: &str) -> Result { + let pid = i32::try_from(pid).context("launcher pid exceeds the macOS pid range")?; + let mut attributes = GuestAttributes::new(); + attributes.set_pid(pid); + let code = SecCode::copy_guest_with_attribues(None, &attributes, CodeSigningFlags::NONE) + .context("failed to inspect the live launcher code identity")?; + code_satisfies_requirement(&code, path, requirement, true) +} + +fn code_satisfies_requirement( + code: &SecCode, + path: &Path, + requirement: &str, + allow_bundle_root: bool, +) -> Result { + let Some(observed_path) = code + .path(CodeSigningFlags::NONE) + .context("failed to resolve the live code path")? + .to_path() + else { + return Ok(false); + }; + if !code_path_matches(&observed_path, path, allow_bundle_root) { + return Ok(false); + } + let compiled_requirement = requirement + .parse::() + .context("failed to compile the expected code requirement")?; + Ok(code + .check_validity(CodeSigningFlags::STRICT_VALIDATE, &compiled_requirement) + .is_ok()) +} + +fn code_path_matches(observed: &Path, executable: &Path, allow_bundle_root: bool) -> bool { + if paths_are_equal(observed, executable) { + return true; + } + if !allow_bundle_root { + return false; + } + let Some(bundle_root) = executable + .parent() + .filter(|directory| directory.file_name() == Some(OsStr::new("MacOS"))) + .and_then(Path::parent) + .filter(|directory| directory.file_name() == Some(OsStr::new("Contents"))) + .and_then(Path::parent) + .filter(|directory| directory.extension() == Some(OsStr::new("app"))) + else { + return false; + }; + paths_are_equal(observed, bundle_root) +} + +/// Canonicalize-and-compare that treats resolution failure as non-equality. +/// Every caller gathers launcher evidence, where an unresolvable path means +/// the relationship cannot be attested; errors must demote privilege, never +/// abort daemon startup (standalone and homebrew layouts have no app binary +/// sibling to resolve). +fn paths_are_equal(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn launchctl_service_pid(label: &str) -> Result> { + let uid = command_output("/usr/bin/id", &["-u"])?; + if uid.exit_code != Some(0) { + anyhow::bail!( + "id -u failed with status {:?}: {}", + uid.exit_code, + bounded_utf8(&uid.stderr, "id error output")?.trim() + ); + } + let uid = bounded_utf8(&uid.stdout, "id output")?.trim(); + let uid = uid + .parse::() + .context("id -u returned an invalid user identifier")?; + let target = format!("gui/{uid}/{label}"); + let output = command_output("/bin/launchctl", &["print", &target])?; + let missing_service = + format!("Could not find service \"{label}\" in domain for user gui: {uid}"); + parse_launchctl_service_pid( + output.exit_code, + &output.stdout, + &output.stderr, + &missing_service, + ) +} + +struct CommandOutput { + exit_code: Option, + stdout: Vec, + stderr: Vec, +} + +fn command_output(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {program}"))?; + anyhow::ensure!( + output.stdout.len() <= MAX_COMMAND_OUTPUT_BYTES + && output.stderr.len() <= MAX_COMMAND_OUTPUT_BYTES, + "{program} output exceeds 64 KiB" + ); + Ok(CommandOutput { + exit_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + }) +} + +fn parse_launchctl_service_pid( + exit_code: Option, + stdout: &[u8], + stderr: &[u8], + expected_missing_service: &str, +) -> Result> { + match exit_code { + Some(0) => {} + Some(113) => { + let stderr = bounded_utf8(stderr, "launchctl error output")?; + anyhow::ensure!( + stderr.lines().any(|line| line == expected_missing_service), + "launchctl returned status 113 without the exact missing-service diagnostic" + ); + return Ok(None); + } + code => anyhow::bail!( + "launchctl inspection failed with status {:?}: {}", + code, + bounded_utf8(stderr, "launchctl error output")?.trim() + ), + } + let stdout = bounded_utf8(stdout, "launchctl output")?; + let pids = stdout + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("pid = ")) + .map(str::parse::) + .collect::, _>>()?; + match pids.as_slice() { + [] => Ok(None), + [pid] if *pid > 0 => Ok(Some(*pid)), + [_] => anyhow::bail!("launchctl returned a zero service pid"), + _ => anyhow::bail!("launchctl returned ambiguous service pids"), + } +} + +fn bounded_utf8<'a>(bytes: &'a [u8], label: &str) -> Result<&'a str> { + std::str::from_utf8(bytes).with_context(|| format!("{label} is not UTF-8")) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + use super::{ + APP_REQUIREMENT_PREFIX, MacosLauncherAuthorityEvidence, SIDECAR_REQUIREMENT_PREFIX, + app_parent_requirement, app_sidecar_layout_is_valid, app_sidecar_parent_is_valid, + code_path_matches, is_adhoc_requirement, parent_is_adjacent_app_binary, + parse_launchctl_service_pid, parse_macos_owner_claim, paths_are_equal, + resolve_macos_launcher_owner, + }; + use hypercolor_macos_owner::MacosDaemonOwner; + + fn evidence(owner: MacosDaemonOwner) -> MacosLauncherAuthorityEvidence { + MacosLauncherAuthorityEvidence { + app_sidecar: owner == MacosDaemonOwner::AppSidecar, + direct_launchd: owner == MacosDaemonOwner::DirectLaunchd, + homebrew: owner == MacosDaemonOwner::Homebrew, + standalone: owner == MacosDaemonOwner::Standalone, + } + } + + #[test] + fn owner_claim_parser_accepts_only_canonical_legacy_values() { + for (value, owner) in [ + ("app-sidecar", MacosDaemonOwner::AppSidecar), + ("direct-launchd", MacosDaemonOwner::DirectLaunchd), + ("homebrew", MacosDaemonOwner::Homebrew), + ("standalone", MacosDaemonOwner::Standalone), + ] { + assert_eq!( + parse_macos_owner_claim(value).expect("canonical claim should parse"), + owner + ); + } + for value in ["", "app_sidecar", "APP-SIDECAR", "launchd", "unknown"] { + assert!(parse_macos_owner_claim(value).is_err()); + } + } + + #[test] + fn env_and_argument_compatibility_matrix_requires_exact_authority() { + for (environment, argument, authority, expected) in [ + ( + None, + None, + MacosDaemonOwner::Standalone, + MacosDaemonOwner::Standalone, + ), + ( + Some("app-sidecar"), + None, + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::AppSidecar, + ), + ( + None, + Some(MacosDaemonOwner::DirectLaunchd), + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::DirectLaunchd, + ), + ( + Some("homebrew"), + Some(MacosDaemonOwner::Homebrew), + MacosDaemonOwner::Homebrew, + MacosDaemonOwner::Homebrew, + ), + ] { + assert_eq!( + resolve_macos_launcher_owner( + environment.map(OsStr::new), + argument, + evidence(authority), + ) + .expect("compatible claims should resolve"), + expected + ); + } + + assert!( + resolve_macos_launcher_owner( + Some(OsStr::new("homebrew")), + Some(MacosDaemonOwner::DirectLaunchd), + evidence(MacosDaemonOwner::Homebrew), + ) + .is_err() + ); + assert!( + resolve_macos_launcher_owner( + Some(OsStr::new("invalid")), + Some(MacosDaemonOwner::DirectLaunchd), + evidence(MacosDaemonOwner::DirectLaunchd), + ) + .is_err() + ); + assert!( + resolve_macos_launcher_owner( + Some(OsStr::from_bytes(&[0xff])), + Some(MacosDaemonOwner::DirectLaunchd), + evidence(MacosDaemonOwner::DirectLaunchd), + ) + .is_err() + ); + assert!( + resolve_macos_launcher_owner( + Some(OsStr::new("app-sidecar")), + None, + evidence(MacosDaemonOwner::Homebrew), + ) + .is_err() + ); + } + + #[test] + fn missing_and_ambiguous_authority_fail_closed() { + assert!( + resolve_macos_launcher_owner(None, None, MacosLauncherAuthorityEvidence::default()) + .is_err() + ); + assert!( + resolve_macos_launcher_owner( + None, + None, + MacosLauncherAuthorityEvidence { + app_sidecar: true, + direct_launchd: true, + ..MacosLauncherAuthorityEvidence::default() + }, + ) + .is_err() + ); + } + + #[test] + fn adjacent_app_binary_check_accepts_the_real_bundle_binary_name() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let macos_dir = directory.path().join("Hypercolor.app/Contents/MacOS"); + std::fs::create_dir_all(&macos_dir).expect("bundle directories should build"); + for name in ["hypercolor-app", "hypercolor", "hypercolor-daemon"] { + std::fs::write(macos_dir.join(name), b"fixture").expect("fixture binary should write"); + } + + // Tauri bundles ship the cargo binary name, not the product name. + assert!(parent_is_adjacent_app_binary( + &macos_dir.join("hypercolor-app"), + &macos_dir + )); + // The CLI living in the same directory is not an app launcher. + assert!(!parent_is_adjacent_app_binary( + &macos_dir.join("hypercolor"), + &macos_dir + )); + // A same-named binary outside the daemon's directory does not count. + let elsewhere = directory.path().join("hypercolor-app"); + std::fs::write(&elsewhere, b"fixture").expect("outside fixture should write"); + assert!(!parent_is_adjacent_app_binary(&elsewhere, &macos_dir)); + } + + #[test] + fn adhoc_requirement_detection_matches_only_bare_cdhash_shapes() { + assert!(is_adhoc_requirement("cdhash H\"0123456789abcdef\"")); + assert!(!is_adhoc_requirement( + "identifier \"tech.hyperbliss.hypercolor.sidecar\" and anchor apple generic" + )); + assert!(!is_adhoc_requirement( + "identifier \"x\" and cdhash H\"0123456789abcdef\"" + )); + } + + #[test] + fn app_parent_requirement_preserves_the_exact_sidecar_signer_tail() { + let tail = "anchor apple generic and certificate leaf[subject.OU] = \"TEAMID1234\""; + let sidecar = format!("{SIDECAR_REQUIREMENT_PREFIX}{tail}"); + let app = app_parent_requirement(&sidecar) + .expect("sidecar requirement should carry the canonical prefix"); + assert_eq!(app, format!("{APP_REQUIREMENT_PREFIX}{tail}")); + assert!(app_parent_requirement("cdhash H\"0123456789abcdef\"").is_none()); + assert!(app_parent_requirement(SIDECAR_REQUIREMENT_PREFIX).is_none()); + } + + #[test] + fn app_sidecar_layout_requires_the_bundle_macos_directory() { + let daemon = Path::new( + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon-aarch64-apple-darwin", + ); + assert!(app_sidecar_layout_is_valid(daemon)); + assert!(!app_sidecar_layout_is_valid(Path::new( + "/Applications/Hypercolor.app/Contents/Resources/hypercolor-daemon" + ))); + } + + #[test] + fn live_app_code_path_accepts_its_bundle_root() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let bundle = directory.path().join("Hypercolor.app"); + let executable = bundle.join("Contents/MacOS/Hypercolor"); + std::fs::create_dir_all( + executable + .parent() + .expect("executable should have a parent"), + ) + .expect("bundle directories should build"); + std::fs::write(&executable, b"fixture").expect("fixture executable should write"); + + assert!(code_path_matches(&bundle, &executable, true)); + assert!(!code_path_matches(&bundle, &executable, false)); + } + + #[test] + fn path_comparison_treats_unresolvable_paths_as_unattested() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let present = directory.path().join("present"); + std::fs::write(&present, b"fixture").expect("fixture file should write"); + let missing = directory.path().join("missing"); + + assert!(paths_are_equal(&present, &present)); + assert!(!paths_are_equal(&present, &missing)); + assert!(!paths_are_equal(&missing, &missing)); + } + + #[test] + fn sidecar_parent_check_rejects_non_bundle_layouts_without_error() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let daemon = directory.path().join("hypercolor-daemon"); + std::fs::write(&daemon, b"fixture").expect("fixture daemon should write"); + let parent = directory.path().join("cargo"); + std::fs::write(&parent, b"fixture").expect("fixture parent should write"); + + let verdict = app_sidecar_parent_is_valid(&daemon, "unused", 1, &parent) + .expect("a target/debug style layout must not abort launcher inspection"); + assert!(!verdict); + } + + #[test] + fn sidecar_parent_check_rejects_a_bundle_missing_the_app_binary_without_error() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let macos_dir = directory.path().join("Hypercolor.app/Contents/MacOS"); + std::fs::create_dir_all(&macos_dir).expect("bundle directories should build"); + let daemon = macos_dir.join("hypercolor-daemon"); + std::fs::write(&daemon, b"fixture").expect("fixture daemon should write"); + let parent = directory.path().join("zsh"); + std::fs::write(&parent, b"fixture").expect("fixture parent should write"); + + let verdict = app_sidecar_parent_is_valid(&daemon, "unused", 1, &parent) + .expect("a bundle without its app binary must not abort launcher inspection"); + assert!(!verdict); + } + + #[test] + fn launchctl_pid_parser_rejects_malformed_or_ambiguous_jobs() { + assert_eq!( + parse_launchctl_service_pid(Some(0), b"state = running\n\tpid = 42\n", b"", "unused",) + .expect("one pid should parse"), + Some(42) + ); + let missing = "Could not find service \"missing\" in domain for user gui: 501"; + assert_eq!( + parse_launchctl_service_pid( + Some(113), + b"", + b"Bad request.\nCould not find service \"missing\" in domain for user gui: 501\n", + missing, + ) + .expect("the exact missing-service status should be absent"), + None + ); + assert!( + parse_launchctl_service_pid(Some(113), b"", b"permission denied", missing).is_err() + ); + let error = parse_launchctl_service_pid(Some(1), b"", b"permission denied", "unused") + .expect_err("arbitrary launchctl failure must remain fatal"); + assert!(error.to_string().contains("permission denied")); + assert!(parse_launchctl_service_pid(None, b"", b"terminated", "unused").is_err()); + assert!(parse_launchctl_service_pid(Some(0), b"pid = nope\n", b"", "unused").is_err()); + assert!( + parse_launchctl_service_pid(Some(0), b"pid = 42\npid = 43\n", b"", "unused",).is_err() + ); + assert!(parse_launchctl_service_pid(Some(0), b"pid = 0\n", b"", "unused").is_err()); + assert!(parse_launchctl_service_pid(Some(0), &[0xff], b"", "unused").is_err()); + } +} diff --git a/crates/hypercolor-daemon/src/macos_owner.rs b/crates/hypercolor-daemon/src/macos_owner.rs new file mode 100644 index 000000000..e1e81fac2 --- /dev/null +++ b/crates/hypercolor-daemon/src/macos_owner.rs @@ -0,0 +1,3 @@ +//! Durable macOS daemon ownership and handover state. + +pub use hypercolor_macos_owner::*; diff --git a/crates/hypercolor-daemon/src/macos_tcc_canary.rs b/crates/hypercolor-daemon/src/macos_tcc_canary.rs new file mode 100644 index 000000000..83232de8f --- /dev/null +++ b/crates/hypercolor-daemon/src/macos_tcc_canary.rs @@ -0,0 +1,3529 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Read, Write}, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::{Path, PathBuf}, +}; + +#[cfg(feature = "screen-capture")] +use std::time::Duration; + +use anyhow::{Context, Result}; +use hypercolor_macos_owner::MacosDaemonOwner; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "screen-capture")] +use std::{ + process::Command, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + mpsc, + }, + thread, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +#[cfg(feature = "screen-capture")] +use core_foundation::{base::TCFType, data::CFData}; +#[cfg(feature = "screen-capture")] +use hypercolor_macos_capture::{ + MacosCaptureCadence, MacosCaptureSelection, MacosCaptureSelector, MacosFrameEvent, + MacosProtectedSourceState, MacosScreenCaptureSession, MacosStreamRequest, +}; +#[cfg(feature = "screen-capture")] +use hypercolor_macos_input::{ + MacosInputConfig, MacosInputError, MacosInputPublicationOutcome, MacosInputSession, + MacosWorkerState, current_process_audit_token_identity, input_monitoring_granted, + request_input_monitoring, +}; +#[cfg(feature = "screen-capture")] +use security_framework::os::macos::code_signing::{ + Flags as CodeSigningFlags, GuestAttributes, SecCode, SecRequirement, +}; +use sha2::{Digest, Sha256}; +#[cfg(feature = "screen-capture")] +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + +pub const MACOS_TCC_CANARY_SCHEMA_VERSION: u32 = 2; +const REQUEST_FILE_NAME: &str = "request.json"; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_RECEIPT_BYTES: u64 = 128 * 1024; +const MAX_WITNESS_BYTES: u64 = 64 * 1024; +const MAX_WITNESS_EVIDENCE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_EVIDENCE_ARTIFACTS: usize = 2_048; +const MIN_OPERATION_TIMEOUT_MS: u64 = 1_000; +const MAX_OPERATION_TIMEOUT_MS: u64 = 300_000; +const REQUIRED_ENTITLEMENTS: [&str; 6] = [ + "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", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTccCanaryCapability { + Keyboard, + Pointer, + Picker, + Stream, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTccCanaryInstallationScenario { + AppOnly, + DirectLaunchdOnly, + HomebrewOnly, + StandaloneOnly, + AppDirectAppEnabledFirst, + AppDirectDirectEnabledFirst, + AppHomebrew, + DirectHomebrew, + AppDirectHomebrew, +} + +impl MacosTccCanaryInstallationScenario { + const fn permits(self, topology: MacosDaemonOwner) -> bool { + match self { + Self::AppOnly => matches!(topology, MacosDaemonOwner::AppSidecar), + Self::DirectLaunchdOnly => matches!(topology, MacosDaemonOwner::DirectLaunchd), + Self::HomebrewOnly => matches!(topology, MacosDaemonOwner::Homebrew), + Self::StandaloneOnly => matches!(topology, MacosDaemonOwner::Standalone), + Self::AppDirectAppEnabledFirst | Self::AppDirectDirectEnabledFirst => matches!( + topology, + MacosDaemonOwner::AppSidecar | MacosDaemonOwner::DirectLaunchd + ), + Self::AppHomebrew => matches!( + topology, + MacosDaemonOwner::AppSidecar | MacosDaemonOwner::Homebrew + ), + Self::DirectHomebrew => matches!( + topology, + MacosDaemonOwner::DirectLaunchd | MacosDaemonOwner::Homebrew + ), + Self::AppDirectHomebrew => !matches!(topology, MacosDaemonOwner::Standalone), + } + } + + const fn needs_repeated_login_proof(self) -> bool { + !matches!( + self, + Self::AppOnly | Self::DirectLaunchdOnly | Self::HomebrewOnly | Self::StandaloneOnly + ) + } + + const fn installed_topologies(self) -> &'static [MacosDaemonOwner] { + match self { + Self::AppOnly => &[MacosDaemonOwner::AppSidecar], + Self::DirectLaunchdOnly => &[MacosDaemonOwner::DirectLaunchd], + Self::HomebrewOnly => &[MacosDaemonOwner::Homebrew], + Self::StandaloneOnly => &[MacosDaemonOwner::Standalone], + Self::AppDirectAppEnabledFirst | Self::AppDirectDirectEnabledFirst => &[ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + ], + Self::AppHomebrew => &[MacosDaemonOwner::AppSidecar, MacosDaemonOwner::Homebrew], + Self::DirectHomebrew => &[MacosDaemonOwner::DirectLaunchd, MacosDaemonOwner::Homebrew], + Self::AppDirectHomebrew => &[ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Homebrew, + ], + } + } + + fn enable_order_is_valid(self, order: &[MacosDaemonOwner]) -> bool { + let installed = self.installed_topologies(); + if order.len() != installed.len() + || order.iter().any(|owner| !installed.contains(owner)) + || order + .iter() + .enumerate() + .any(|(index, owner)| order[..index].contains(owner)) + { + return false; + } + match self { + Self::AppDirectAppEnabledFirst => { + order + == [ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + ] + } + Self::AppDirectDirectEnabledFirst => { + order + == [ + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::AppSidecar, + ] + } + _ => true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTccCanaryLifecyclePhase { + Grant, + Deny, + LaterGrant, + RevokeWhileLive, + GrantAfterRevocation, + AppLaunch, + OwnerRestart, + AppRelaunch, + ServiceInstall, + LoginStart, + ServiceRestart, + SignedUpdate, +} + +impl MacosTccCanaryLifecyclePhase { + const fn needs_predecessor(self) -> bool { + matches!( + self, + Self::LaterGrant + | Self::GrantAfterRevocation + | Self::OwnerRestart + | Self::AppRelaunch + | Self::ServiceRestart + | Self::SignedUpdate + ) + } + + const fn replaces_process(self) -> bool { + self.needs_predecessor() + } + + const fn needs_lifecycle_action_witness(self) -> bool { + matches!( + self, + Self::AppLaunch | Self::ServiceInstall | Self::LoginStart + ) + } + + const fn required_predecessor(self) -> Option { + match self { + Self::LaterGrant => Some(Self::Deny), + Self::GrantAfterRevocation => Some(Self::RevokeWhileLive), + Self::OwnerRestart | Self::AppRelaunch | Self::ServiceRestart | Self::SignedUpdate => { + Some(Self::Grant) + } + Self::Grant + | Self::Deny + | Self::RevokeWhileLive + | Self::AppLaunch + | Self::ServiceInstall + | Self::LoginStart => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryRequest { + pub schema_version: u32, + pub run_id: String, + pub row_id: String, + pub scenario_id: String, + pub installation_scenario: MacosTccCanaryInstallationScenario, + pub login_iteration: u32, + pub expected_topology: MacosDaemonOwner, + pub lifecycle_phase: MacosTccCanaryLifecyclePhase, + pub predecessor_row_id: Option, + pub process_replacement_witness_id: Option, + pub lifecycle_action_witness_id: Option, + pub login_arbitration_witness_id: Option, + pub scored_capability: MacosTccCanaryCapability, + pub capabilities: Vec, + pub allow_input_prompt: bool, + pub allow_screen_prompt: bool, + pub allow_picker: bool, + pub operation_timeout_ms: u64, + pub fresh_tcc_reset_witness_id: Option, + pub system_settings_identity_witness_id: String, + pub expected_prompt_text: String, + pub expected_system_settings_entry: String, +} + +impl MacosTccCanaryRequest { + pub fn validate(&self) -> Result<()> { + anyhow::ensure!( + self.schema_version == MACOS_TCC_CANARY_SCHEMA_VERSION, + "unsupported macOS TCC canary request schema {}", + self.schema_version + ); + validate_identifier(&self.run_id, "run_id")?; + validate_identifier(&self.row_id, "row_id")?; + validate_identifier(&self.scenario_id, "scenario_id")?; + anyhow::ensure!( + self.installation_scenario.permits(self.expected_topology), + "installation scenario does not permit the expected topology" + ); + anyhow::ensure!(self.login_iteration > 0, "login_iteration must be positive"); + if let Some(predecessor) = self.predecessor_row_id.as_deref() { + validate_identifier(predecessor, "predecessor_row_id")?; + anyhow::ensure!(predecessor != self.row_id, "a row cannot precede itself"); + } + anyhow::ensure!( + self.lifecycle_phase.needs_predecessor() == self.predecessor_row_id.is_some(), + "predecessor_row_id is permitted exactly for process replacement phases" + ); + anyhow::ensure!( + self.lifecycle_phase.replaces_process() + == self.process_replacement_witness_id.is_some(), + "process replacement phases require exactly one process replacement witness" + ); + if let Some(witness) = self.process_replacement_witness_id.as_deref() { + validate_identifier(witness, "process_replacement_witness_id")?; + } + anyhow::ensure!( + self.lifecycle_phase.needs_lifecycle_action_witness() + == self.lifecycle_action_witness_id.is_some(), + "app launch, service install, and login start require exactly one lifecycle action witness" + ); + if let Some(witness) = self.lifecycle_action_witness_id.as_deref() { + validate_identifier(witness, "lifecycle_action_witness_id")?; + } + anyhow::ensure!( + self.installation_scenario.needs_repeated_login_proof() + == self.login_arbitration_witness_id.is_some(), + "mixed installation rows require exactly one login arbitration witness" + ); + if let Some(witness) = self.login_arbitration_witness_id.as_deref() { + validate_identifier(witness, "login_arbitration_witness_id")?; + } + if let Some(witness) = self.fresh_tcc_reset_witness_id.as_deref() { + validate_identifier(witness, "fresh_tcc_reset_witness_id")?; + } + validate_identifier( + &self.system_settings_identity_witness_id, + "system_settings_identity_witness_id", + )?; + validate_observed_text(&self.expected_prompt_text, "expected_prompt_text")?; + validate_observed_text( + &self.expected_system_settings_entry, + "expected_system_settings_entry", + )?; + anyhow::ensure!( + (MIN_OPERATION_TIMEOUT_MS..=MAX_OPERATION_TIMEOUT_MS) + .contains(&self.operation_timeout_ms), + "operation_timeout_ms must be from {MIN_OPERATION_TIMEOUT_MS} through {MAX_OPERATION_TIMEOUT_MS}" + ); + anyhow::ensure!( + !self.capabilities.is_empty(), + "capabilities cannot be empty" + ); + let unique = self.capabilities.iter().copied().collect::>(); + anyhow::ensure!( + unique.len() == self.capabilities.len(), + "capabilities cannot contain duplicates" + ); + anyhow::ensure!( + !unique.contains(&MacosTccCanaryCapability::Stream) + || unique.contains(&MacosTccCanaryCapability::Picker), + "stream evidence requires picker evidence in the same process" + ); + anyhow::ensure!( + scored_capability_shape_is_valid(self.scored_capability, &unique), + "each row scores one capability, except stream rows also carry picker evidence" + ); + anyhow::ensure!( + capability_phases(self.scored_capability).contains(&self.lifecycle_phase) + || topology_phases(self.expected_topology).contains(&self.lifecycle_phase), + "the lifecycle phase does not apply to the scored capability and topology" + ); + anyhow::ensure!( + (!unique.contains(&MacosTccCanaryCapability::Picker) + && !unique.contains(&MacosTccCanaryCapability::Stream)) + || self.allow_picker, + "screen evidence requires explicit allow_picker consent" + ); + Ok(()) + } + + #[cfg(feature = "screen-capture")] + fn timeout(&self) -> Duration { + Duration::from_millis(self.operation_timeout_ms) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTccCanaryOutcome { + Passed, + Denied, + Revoked, + NeedsProcessRestart, + Cancelled, + TimedOut, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryCapabilityEvidence { + pub capability: MacosTccCanaryCapability, + pub outcome: MacosTccCanaryOutcome, + pub resulting_api_state: String, + pub typed_error: Option, + pub tcc_preflight_before: Option, + pub tcc_request_result: Option, + pub tcc_preflight_after: Option, + pub requested_tap_mask: Option, + pub tap_mask: Option, + pub tap_created: Option, + pub tap_enabled: Option, + pub run_loop_started: Option, + pub redacted_event_count: Option, + pub picker_presented: Option, + pub picker_selected: Option, + pub stream_started: Option, + pub first_complete_frame: Option, + pub first_frame_monotonic_ns: Option, + pub resource_live_before_revocation: Option, + pub resource_failed_after_revocation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryLauncherEvidence { + pub actual_launcher: String, + pub expected_label: Option, + pub parent_pid: Option, + pub parent_executable_path: Option, + pub parent_signing: Option, + pub launchctl_pid_matches: Option, + pub verified: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanarySigningEvidence { + pub bundle_identifier: String, + pub team_identifier: String, + pub designated_requirement: String, + pub designated_requirement_sha256: String, + pub cdhash: String, + pub process_bound_pid: u32, + pub process_bound_fingerprint: String, + pub process_bound_valid: bool, + pub audit_token_bound_valid: bool, + pub authorities: Vec, + pub entitlement_keys: Vec, + pub codesign_strict_valid: bool, + pub hardened_runtime: bool, + pub secure_timestamp: bool, + pub spctl_accepted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryReceipt { + pub schema_version: u32, + pub run_id: String, + pub row_id: String, + pub scenario_id: String, + pub installation_scenario: MacosTccCanaryInstallationScenario, + pub login_iteration: u32, + pub topology: MacosDaemonOwner, + pub lifecycle_phase: MacosTccCanaryLifecyclePhase, + pub predecessor_row_id: Option, + pub process_replacement_witness_id: Option, + pub lifecycle_action_witness_id: Option, + pub login_arbitration_witness_id: Option, + pub scored_capability: MacosTccCanaryCapability, + pub fresh_tcc_reset_witness_id: Option, + pub system_settings_identity_witness_id: String, + pub expected_prompt_text: String, + pub expected_system_settings_entry: String, + pub host_architecture: String, + pub executable_slice: String, + pub translated_process: bool, + pub os_version: String, + pub binary_version: String, + pub pid: u32, + pub process_fingerprint: String, + pub audit_token_identity: String, + pub executable_path: PathBuf, + pub process_started_unix_ms: u64, + pub operation_finished_unix_ms: u64, + pub launcher: MacosTccCanaryLauncherEvidence, + pub signing: MacosTccCanarySigningEvidence, + pub capabilities: Vec, + pub acceptance_claim: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTccCanaryWitnessKind { + FreshTccReset, + SystemSettingsIdentity, + ProcessReplacement, + LifecycleAction, + LoginArbitration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryWitness { + pub schema_version: u32, + pub run_id: String, + pub row_id: String, + pub witness_id: String, + pub kind: MacosTccCanaryWitnessKind, + pub observer: String, + pub observed_unix_ms: u64, + pub evidence_sha256: String, + pub prompt_text: Option, + pub system_settings_entry: Option, + #[serde(default)] + pub observed_pid: Option, + #[serde(default)] + pub observed_audit_token_identity: Option, + #[serde(default)] + pub observed_signing_audit_token_identity: Option, + #[serde(default)] + pub observed_cdhash: Option, + #[serde(default)] + pub observed_designated_requirement_sha256: Option, + #[serde(default)] + pub observed_process_fingerprint: Option, + #[serde(default)] + pub parent_pid: Option, + #[serde(default)] + pub parent_audit_token_identity: Option, + #[serde(default)] + pub parent_signing_audit_token_identity: Option, + #[serde(default)] + pub parent_cdhash: Option, + #[serde(default)] + pub parent_designated_requirement_sha256: Option, + #[serde(default)] + pub parent_process_fingerprint: Option, + pub fresh_tcc_database_observed: Option, + pub predecessor_pid: Option, + #[serde(default)] + pub predecessor_audit_token_identity: Option, + #[serde(default)] + pub predecessor_process_fingerprint: Option, + pub predecessor_exit_observed: Option, + #[serde(default)] + pub predecessor_parent_pid: Option, + #[serde(default)] + pub predecessor_parent_audit_token_identity: Option, + #[serde(default)] + pub predecessor_parent_process_fingerprint: Option, + #[serde(default)] + pub predecessor_parent_exit_observed: Option, + pub launcher_action: Option, + #[serde(default)] + pub installed_topologies: Option>, + #[serde(default)] + pub enable_order: Option>, + #[serde(default)] + pub selected_topology: Option, + #[serde(default)] + pub losing_topologies: Option>, + #[serde(default)] + pub owner_conflict_observed: Option, + #[serde(default)] + pub login_iteration: Option, + #[serde(default)] + pub login_session_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryValidation { + pub schema_version: u32, + pub receipt_structure_valid: bool, + pub identity_consistent: bool, + pub preferred_topology_eligible: bool, + pub physical_acceptance_claimed: bool, + pub receipt_count: usize, + pub capability_qualifications: Vec, + pub missing_requirements: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosTccCanaryCapabilityQualification { + pub capability: MacosTccCanaryCapability, + pub preferred_topology: Option, + pub qualified_topologies: Vec, + pub app_broker_required: bool, +} + +pub fn macos_tcc_canary_directory(data_dir: &Path) -> PathBuf { + data_dir.join("macos-tcc-canary") +} + +pub fn macos_tcc_canary_request_path(data_dir: &Path) -> PathBuf { + macos_tcc_canary_directory(data_dir).join(REQUEST_FILE_NAME) +} + +pub fn validate_macos_tcc_canary_request(request_path: &Path) -> Result<()> { + read_json_bounded::(request_path, MAX_REQUEST_BYTES)?.validate() +} + +pub fn publish_macos_tcc_canary_artifact( + canary_root: &Path, + source: &Path, + destination: &Path, +) -> Result<()> { + ensure_real_directory(canary_root, false)?; + let parent = destination + .parent() + .context("macOS TCC canary artifact destination has no parent")?; + ensure_canary_descendant_directory(canary_root, parent)?; + let file_name = destination + .file_name() + .context("macOS TCC canary artifact destination has no filename")?; + anyhow::ensure!( + matches!(file_name.to_str(), Some(name) if !name.is_empty() && name != "." && name != ".."), + "macOS TCC canary artifact destination has an invalid filename" + ); + let (file, metadata) = open_regular_file(source)?; + anyhow::ensure!( + metadata.len() <= MAX_WITNESS_EVIDENCE_BYTES, + "macOS TCC canary artifact exceeds {MAX_WITNESS_EVIDENCE_BYTES} bytes" + ); + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0)); + file.take(MAX_WITNESS_EVIDENCE_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {}", source.display()))?; + anyhow::ensure!( + bytes.len() as u64 <= MAX_WITNESS_EVIDENCE_BYTES, + "macOS TCC canary artifact exceeds {MAX_WITNESS_EVIDENCE_BYTES} bytes" + ); + write_bytes_new(destination, &bytes) +} + +pub fn arm_macos_tcc_canary(data_dir: &Path, request_path: &Path) -> Result { + let request = read_json_bounded::(request_path, MAX_REQUEST_BYTES)?; + request.validate()?; + let canary_dir = macos_tcc_canary_directory(data_dir); + ensure_real_directory(data_dir, false)?; + ensure_real_directory(&canary_dir, true)?; + ensure_existing_real_directory(&canary_dir.join("requests"))?; + ensure_existing_real_directory(&canary_dir.join("receipts"))?; + fs::set_permissions(&canary_dir, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to secure {}", canary_dir.display()))?; + let destination = macos_tcc_canary_request_path(data_dir); + write_json_new(&destination, &request)?; + sync_parent(&canary_dir)?; + Ok(destination) +} + +pub fn validate_macos_tcc_canary_receipts(receipt_dir: &Path) -> Result { + ensure_real_directory(receipt_dir, false)?; + ensure_real_directory(&receipt_dir.join("evidence"), false)?; + let mut artifact_paths = fs::read_dir(receipt_dir) + .with_context(|| format!("failed to read {}", receipt_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + artifact_paths.retain(|path| { + path.extension() + .is_some_and(|extension| extension == "json") + }); + artifact_paths.sort(); + anyhow::ensure!( + artifact_paths.len() <= MAX_EVIDENCE_ARTIFACTS, + "receipt directory exceeds {MAX_EVIDENCE_ARTIFACTS} JSON files" + ); + let mut receipts = Vec::new(); + let mut witnesses = Vec::new(); + for path in artifact_paths { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("macOS TCC evidence filename is not valid UTF-8")?; + if file_name.ends_with(".receipt.json") { + receipts.push(read_json_bounded::( + &path, + MAX_RECEIPT_BYTES, + )?); + } else if file_name.ends_with(".witness.json") { + let witness = read_json_bounded::(&path, MAX_WITNESS_BYTES)?; + anyhow::ensure!( + witness_evidence_matches(receipt_dir, &witness)?, + "macOS TCC witness evidence hash does not match {}", + witness.witness_id + ); + witnesses.push(witness); + } else { + anyhow::bail!( + "macOS TCC evidence JSON must end in .receipt.json or .witness.json: {}", + path.display() + ); + } + } + Ok(validate_receipt_set(&receipts, &witnesses)) +} + +#[cfg(feature = "screen-capture")] +pub fn run_armed_macos_tcc_canary( + data_dir: &Path, + actual_topology: MacosDaemonOwner, +) -> Result { + let Some((request, archived_request_path)) = claim_request(data_dir, actual_topology)? else { + return Ok(false); + }; + let canary_dir = macos_tcc_canary_directory(data_dir); + let receipt_path = canary_dir + .join("receipts") + .join(&request.run_id) + .join(format!("{}.receipt.json", request.row_id)); + let (result_tx, result_rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("hypercolor-macos-tcc-canary".to_owned()) + .spawn(move || { + let result = execute_request(request, actual_topology).and_then(|mut receipt| { + let parent = receipt_path + .parent() + .context("macOS TCC canary receipt path has no parent")?; + ensure_canary_descendant_directory(&canary_dir, parent)?; + let pending_path = parent.join(format!("{}.receipt.pending", receipt.row_id)); + write_json_new(&pending_path, &receipt)?; + let live_validation = await_live_identity_witness(parent, &receipt); + let identity_validated_unix_ms = match live_validation { + Ok(observed_unix_ms) => observed_unix_ms, + Err(error) => { + fs::remove_file(&pending_path).with_context(|| { + format!("failed to remove {}", pending_path.display()) + })?; + sync_parent(parent)?; + return Err(error); + } + }; + receipt.operation_finished_unix_ms = identity_validated_unix_ms; + if let Some(parent_signing) = receipt.launcher.parent_signing.as_mut() { + parent_signing.audit_token_bound_valid = true; + } + write_json_new(&receipt_path, &receipt)?; + fs::remove_file(&pending_path) + .with_context(|| format!("failed to remove {}", pending_path.display()))?; + sync_parent(parent)?; + Ok(receipt_path) + }); + let _ = result_tx.send(result); + dispatch2::run_on_main(|_mtm| { + if let Some(run_loop) = objc2_core_foundation::CFRunLoop::main() { + run_loop.stop(); + } + }); + }) + .context("failed to start the macOS TCC canary worker")?; + objc2_core_foundation::CFRunLoop::run(); + let result = result_rx + .recv() + .context("macOS TCC canary worker exited without a result")?; + worker + .join() + .map_err(|_| anyhow::anyhow!("macOS TCC canary worker panicked"))?; + match result { + Ok(receipt_path) => { + println!( + "macos_tcc_canary_receipt={} request={}", + receipt_path.display(), + archived_request_path.display() + ); + Ok(true) + } + Err(error) => Err(error), + } +} + +#[cfg(feature = "screen-capture")] +fn await_live_identity_witness(receipt_dir: &Path, receipt: &MacosTccCanaryReceipt) -> Result { + const WITNESS_DEADLINE: Duration = Duration::from_secs(25); + let witness_path = receipt_dir.join(format!( + "{}.witness.json", + receipt.system_settings_identity_witness_id + )); + let deadline = Instant::now() + WITNESS_DEADLINE; + loop { + if witness_path.exists() { + let witness = + read_json_bounded::(&witness_path, MAX_WITNESS_BYTES)?; + anyhow::ensure!( + witness_evidence_matches(receipt_dir, &witness)?, + "macOS TCC identity witness evidence hash does not match" + ); + let live_identity_valid = live_identity_witness_is_valid(receipt, &witness); + let mut verified_receipt = receipt.clone(); + if let Some(parent_signing) = verified_receipt.launcher.parent_signing.as_mut() { + parent_signing.audit_token_bound_valid = live_identity_valid; + } + let identity_validated_unix_ms = unix_time_ms()?; + verified_receipt.operation_finished_unix_ms = identity_validated_unix_ms; + let witnesses = BTreeMap::from([(witness.witness_id.as_str(), &witness)]); + anyhow::ensure!( + validate_witness_structure(&witness) + && live_identity_valid + && receipt_identity_valid(&verified_receipt, &witnesses), + "macOS TCC identity witness is not bound to the live signed process" + ); + return Ok(identity_validated_unix_ms); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for the current-row System Settings identity witness" + ); + thread::park_timeout(Duration::from_millis(25)); + } +} + +#[cfg(feature = "screen-capture")] +fn live_identity_witness_is_valid( + receipt: &MacosTccCanaryReceipt, + witness: &MacosTccCanaryWitness, +) -> bool { + let daemon_valid = witness + .observed_signing_audit_token_identity + .as_deref() + .is_some_and(|audit_token| { + live_signing_identity_is_valid(audit_token, &receipt.executable_path, &receipt.signing) + }); + if receipt.topology != MacosDaemonOwner::AppSidecar { + return daemon_valid; + } + let Some((parent_path, parent_signing, parent_audit_token)) = receipt + .launcher + .parent_executable_path + .as_deref() + .zip(receipt.launcher.parent_signing.as_ref()) + .zip(witness.parent_signing_audit_token_identity.as_deref()) + .map(|((path, signing), token)| (path, signing, token)) + else { + return false; + }; + daemon_valid && live_signing_identity_is_valid(parent_audit_token, parent_path, parent_signing) +} + +#[cfg(feature = "screen-capture")] +fn live_signing_identity_is_valid( + audit_token: &str, + expected_path: &Path, + signing: &MacosTccCanarySigningEvidence, +) -> bool { + let Some(bytes) = audit_token_bytes(audit_token) else { + return false; + }; + if audit_token_identity(audit_token).map(|identity| identity.pid) + != Some(signing.process_bound_pid) + { + return false; + } + let token_data = CFData::from_buffer(&bytes); + let mut attributes = GuestAttributes::new(); + attributes.set_audit_token(token_data.as_concrete_TypeRef()); + let Ok(code) = SecCode::copy_guest_with_attribues(None, &attributes, CodeSigningFlags::NONE) + else { + return false; + }; + let Some(path) = code + .path(CodeSigningFlags::NONE) + .ok() + .and_then(|url| url.to_path()) + else { + return false; + }; + let Ok(requirement) = signing.designated_requirement.parse::() else { + return false; + }; + let Ok(cdhash_requirement) = + format!("cdhash H\"{}\"", signing.cdhash).parse::() + else { + return false; + }; + path == expected_path + && code + .check_validity(CodeSigningFlags::STRICT_VALIDATE, &requirement) + .is_ok() + && code + .check_validity(CodeSigningFlags::STRICT_VALIDATE, &cdhash_requirement) + .is_ok() +} + +#[cfg(feature = "screen-capture")] +fn claim_request( + data_dir: &Path, + actual_topology: MacosDaemonOwner, +) -> Result> { + ensure_real_directory(data_dir, false)?; + ensure_real_directory(&macos_tcc_canary_directory(data_dir), false)?; + ensure_existing_real_directory(&macos_tcc_canary_directory(data_dir).join("requests"))?; + ensure_existing_real_directory(&macos_tcc_canary_directory(data_dir).join("receipts"))?; + let request_path = macos_tcc_canary_request_path(data_dir); + if !request_path.exists() { + return Ok(None); + } + let request = read_json_bounded::(&request_path, MAX_REQUEST_BYTES)?; + request.validate()?; + if request.expected_topology != actual_topology { + return Ok(None); + } + let archive_dir = macos_tcc_canary_directory(data_dir) + .join("requests") + .join(&request.run_id); + ensure_canary_descendant_directory(&macos_tcc_canary_directory(data_dir), &archive_dir)?; + let archived = archive_dir.join(format!("{}.json", request.row_id)); + anyhow::ensure!( + !archived.exists(), + "macOS TCC canary row {} is already archived", + request.row_id + ); + fs::rename(&request_path, &archived).with_context(|| { + format!( + "failed to claim macOS TCC canary request {}", + request_path.display() + ) + })?; + sync_parent(&macos_tcc_canary_directory(data_dir))?; + sync_parent(&archive_dir)?; + Ok(Some((request, archived))) +} + +#[cfg(feature = "screen-capture")] +fn execute_request( + request: MacosTccCanaryRequest, + actual_topology: MacosDaemonOwner, +) -> Result { + let process_started_unix_ms = unix_time_ms()?; + let executable_path = std::env::current_exe().context("failed to resolve canary executable")?; + let pid = std::process::id(); + let audit_token_identity = + current_process_audit_token_identity().map_err(anyhow::Error::from)?; + let process_fingerprint = process_fingerprint(pid)?; + let signing = inspect_signing( + &executable_path, + pid, + &process_fingerprint, + Some(&audit_token_identity), + )?; + let launcher = inspect_launcher(actual_topology, &signing)?; + let host_architecture = host_architecture()?; + let translated_process = sysctl_flag("sysctl.proc_translated")?; + let os_version = bounded_command_text("/usr/bin/sw_vers", &["-productVersion"])?; + let capabilities = execute_capabilities(&request); + let operation_finished_unix_ms = unix_time_ms()?; + Ok(MacosTccCanaryReceipt { + schema_version: MACOS_TCC_CANARY_SCHEMA_VERSION, + run_id: request.run_id, + row_id: request.row_id, + scenario_id: request.scenario_id, + installation_scenario: request.installation_scenario, + login_iteration: request.login_iteration, + topology: actual_topology, + lifecycle_phase: request.lifecycle_phase, + predecessor_row_id: request.predecessor_row_id, + process_replacement_witness_id: request.process_replacement_witness_id, + lifecycle_action_witness_id: request.lifecycle_action_witness_id, + login_arbitration_witness_id: request.login_arbitration_witness_id, + scored_capability: request.scored_capability, + fresh_tcc_reset_witness_id: request.fresh_tcc_reset_witness_id, + system_settings_identity_witness_id: request.system_settings_identity_witness_id, + expected_prompt_text: request.expected_prompt_text, + expected_system_settings_entry: request.expected_system_settings_entry, + host_architecture, + executable_slice: std::env::consts::ARCH.to_owned(), + translated_process, + os_version, + binary_version: env!("CARGO_PKG_VERSION").to_owned(), + pid, + process_fingerprint, + audit_token_identity, + executable_path, + process_started_unix_ms, + operation_finished_unix_ms, + launcher, + signing, + capabilities, + acceptance_claim: "evidence_only".to_owned(), + }) +} + +#[cfg(feature = "screen-capture")] +fn execute_capabilities(request: &MacosTccCanaryRequest) -> Vec { + let mut evidence = Vec::with_capacity(request.capabilities.len()); + for capability in &request.capabilities { + match capability { + MacosTccCanaryCapability::Keyboard => { + evidence.push(execute_input_capability(request, true)); + } + MacosTccCanaryCapability::Pointer => { + evidence.push(execute_input_capability(request, false)); + } + MacosTccCanaryCapability::Picker => {} + MacosTccCanaryCapability::Stream => {} + } + } + if request + .capabilities + .contains(&MacosTccCanaryCapability::Picker) + { + let (picker, stream) = execute_screen_capabilities(request); + evidence.push(picker); + if let Some(stream) = stream { + evidence.push(stream); + } + } + evidence +} + +#[cfg(feature = "screen-capture")] +fn execute_input_capability( + request: &MacosTccCanaryRequest, + keyboard: bool, +) -> MacosTccCanaryCapabilityEvidence { + let capability = if keyboard { + MacosTccCanaryCapability::Keyboard + } else { + MacosTccCanaryCapability::Pointer + }; + let preflight_before = keyboard.then(input_monitoring_granted); + let request_result = (keyboard && request.allow_input_prompt).then(request_input_monitoring); + let event_count = Arc::new(AtomicU64::new(0)); + let callback_count = Arc::clone(&event_count); + let clock_started = Instant::now(); + let session = MacosInputSession::start( + MacosInputConfig { + keyboard, + pointer: !keyboard, + epoch: 1, + clock: Arc::new(move || { + u64::try_from(clock_started.elapsed().as_millis()).unwrap_or(u64::MAX) + }), + }, + move |batch| { + callback_count.fetch_add( + u64::try_from(batch.events.len()).unwrap_or(u64::MAX), + Ordering::Relaxed, + ); + MacosInputPublicationOutcome::Published + }, + ); + let mut session = match session { + Ok(session) => session, + Err(error) => { + let outcome = match error { + MacosInputError::PermissionDenied if request_result == Some(true) => { + MacosTccCanaryOutcome::NeedsProcessRestart + } + MacosInputError::PermissionDenied => MacosTccCanaryOutcome::Denied, + _ => MacosTccCanaryOutcome::Failed, + }; + return MacosTccCanaryCapabilityEvidence { + capability, + outcome, + resulting_api_state: resulting_api_state(capability, outcome).to_owned(), + typed_error: Some(input_error_code(&error).to_owned()), + tcc_preflight_before: preflight_before, + tcc_request_result: request_result, + tcc_preflight_after: keyboard.then(input_monitoring_granted), + requested_tap_mask: None, + tap_mask: None, + tap_created: Some(false), + tap_enabled: Some(false), + run_loop_started: Some(false), + redacted_event_count: Some(0), + picker_presented: None, + picker_selected: None, + stream_started: None, + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + }; + } + }; + let requested_masks = session.effective_masks(); + let requested_tap_mask = if keyboard { + requested_masks.keyboard + } else { + requested_masks.pointer + }; + let installed_masks = match session.installed_masks() { + Ok(masks) => masks, + Err(error) => { + session.stop(); + return MacosTccCanaryCapabilityEvidence { + capability, + outcome: MacosTccCanaryOutcome::Failed, + resulting_api_state: resulting_api_state(capability, MacosTccCanaryOutcome::Failed) + .to_owned(), + typed_error: Some(input_error_code(&error).to_owned()), + tcc_preflight_before: preflight_before, + tcc_request_result: request_result, + tcc_preflight_after: keyboard.then(input_monitoring_granted), + requested_tap_mask: Some(requested_tap_mask), + tap_mask: None, + tap_created: Some(true), + tap_enabled: Some(true), + run_loop_started: Some(true), + redacted_event_count: Some(0), + picker_presented: None, + picker_selected: None, + stream_started: None, + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + }; + } + }; + let tap_mask = if keyboard { + installed_masks.keyboard + } else { + installed_masks.pointer + }; + if tap_mask != requested_tap_mask { + session.stop(); + let outcome = if keyboard && (request_result == Some(true) || input_monitoring_granted()) { + MacosTccCanaryOutcome::NeedsProcessRestart + } else { + MacosTccCanaryOutcome::Failed + }; + return MacosTccCanaryCapabilityEvidence { + capability, + outcome, + resulting_api_state: resulting_api_state(capability, outcome).to_owned(), + typed_error: Some("installed_tap_mask_incomplete".to_owned()), + tcc_preflight_before: preflight_before, + tcc_request_result: request_result, + tcc_preflight_after: keyboard.then(input_monitoring_granted), + requested_tap_mask: Some(requested_tap_mask), + tap_mask: Some(tap_mask), + tap_created: Some(true), + tap_enabled: Some(true), + run_loop_started: Some(true), + redacted_event_count: Some(0), + picker_presented: None, + picker_selected: None, + stream_started: None, + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + }; + } + let deadline = Instant::now() + request.timeout(); + let mut live_before_revocation = false; + let outcome = loop { + let count = event_count.load(Ordering::Relaxed); + live_before_revocation |= count > 0; + match session.worker_state() { + MacosWorkerState::PermissionRevoked => break MacosTccCanaryOutcome::Revoked, + MacosWorkerState::Failed(_) => break MacosTccCanaryOutcome::Failed, + MacosWorkerState::Running | MacosWorkerState::Degraded(_) => {} + } + if request.lifecycle_phase != MacosTccCanaryLifecyclePhase::RevokeWhileLive && count > 0 { + break MacosTccCanaryOutcome::Passed; + } + if Instant::now() >= deadline { + break MacosTccCanaryOutcome::TimedOut; + } + thread::park_timeout(Duration::from_millis(10)); + }; + session.stop(); + let final_count = event_count.load(Ordering::Relaxed); + MacosTccCanaryCapabilityEvidence { + capability, + outcome, + resulting_api_state: resulting_api_state(capability, outcome).to_owned(), + typed_error: (outcome == MacosTccCanaryOutcome::Failed) + .then(|| "input_worker_failed".to_owned()), + tcc_preflight_before: preflight_before, + tcc_request_result: request_result, + tcc_preflight_after: keyboard.then(input_monitoring_granted), + requested_tap_mask: Some(requested_tap_mask), + tap_mask: Some(tap_mask), + tap_created: Some(true), + tap_enabled: Some(true), + run_loop_started: Some(true), + redacted_event_count: Some(final_count), + picker_presented: None, + picker_selected: None, + stream_started: None, + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: (request.lifecycle_phase + == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(live_before_revocation), + resource_failed_after_revocation: (request.lifecycle_phase + == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(outcome == MacosTccCanaryOutcome::Revoked), + } +} + +#[cfg(feature = "screen-capture")] +fn execute_screen_capabilities( + request: &MacosTccCanaryRequest, +) -> ( + MacosTccCanaryCapabilityEvidence, + Option, +) { + let deadline = Instant::now() + request.timeout(); + let stream_requested = request + .capabilities + .contains(&MacosTccCanaryCapability::Stream); + let preflight_before = MacosScreenCaptureSession::screen_authorized(); + let stream_request = MacosStreamRequest::new(MacosCaptureCadence::NativeRefresh, true) + .expect("native refresh is a valid canary cadence"); + let authorization_session = + MacosScreenCaptureSession::new(stream_request, MacosCaptureSelector::SessionScoped); + let Ok(authorization_session) = authorization_session else { + let picker = failed_screen_evidence( + MacosTccCanaryCapability::Picker, + preflight_before, + "capture_session_start_failed", + ); + let stream = stream_requested.then(|| { + failed_screen_evidence( + MacosTccCanaryCapability::Stream, + preflight_before, + "capture_session_start_failed", + ) + }); + return (picker, stream); + }; + let request_result = request + .allow_screen_prompt + .then(|| authorization_session.request_authorization()) + .map(|state| { + !matches!( + state, + MacosProtectedSourceState::PermissionDenied + | MacosProtectedSourceState::NeedsUserAction + ) + }); + let preflight_after_request = MacosScreenCaptureSession::screen_authorized(); + if stream_requested && request_result == Some(true) && preflight_after_request { + let diagnostic = authorization_session.begin_post_authorization_stream_diagnostic(); + let outcome = diagnostic + .map_err(|_| mpsc::RecvTimeoutError::Disconnected) + .and_then(|transaction| { + transaction.wait_until(deadline).map_err(|error| { + if matches!( + error, + hypercolor_macos_capture::MacosNativeTransactionError::TimedOut { .. } + ) { + mpsc::RecvTimeoutError::Timeout + } else { + mpsc::RecvTimeoutError::Disconnected + } + }) + }); + match outcome { + Ok(MacosProtectedSourceState::ReadyIdle) => {} + Ok(MacosProtectedSourceState::NeedsProcessRestart) => { + authorization_session.stop(); + return post_authorization_restart_evidence( + preflight_before, + request_result, + preflight_after_request, + ); + } + Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => { + authorization_session.stop(); + return post_authorization_failure_evidence( + preflight_before, + request_result, + preflight_after_request, + MacosTccCanaryOutcome::Failed, + "post_authorization_stream_diagnostic_failed", + ); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + authorization_session.stop(); + return post_authorization_failure_evidence( + preflight_before, + request_result, + preflight_after_request, + MacosTccCanaryOutcome::TimedOut, + "post_authorization_stream_diagnostic_timed_out", + ); + } + } + } + authorization_session.stop(); + drop(authorization_session); + let session = + MacosScreenCaptureSession::new(stream_request, MacosCaptureSelector::SessionScoped); + let Ok(session) = session else { + let picker = failed_screen_evidence( + MacosTccCanaryCapability::Picker, + preflight_after_request, + "capture_session_restart_failed", + ); + let stream = stream_requested.then(|| { + failed_screen_evidence( + MacosTccCanaryCapability::Stream, + preflight_after_request, + "capture_session_restart_failed", + ) + }); + return (picker, stream); + }; + if stream_requested { + session.set_capture_active(true); + } + let present_result = session.present_picker(); + if present_result.is_err() { + session.stop(); + let outcome = if !preflight_after_request && request_result != Some(true) { + MacosTccCanaryOutcome::Denied + } else { + MacosTccCanaryOutcome::Failed + }; + let picker = MacosTccCanaryCapabilityEvidence { + capability: MacosTccCanaryCapability::Picker, + outcome, + resulting_api_state: resulting_api_state(MacosTccCanaryCapability::Picker, outcome) + .to_owned(), + typed_error: Some("picker_presentation_failed".to_owned()), + tcc_preflight_before: Some(preflight_before), + tcc_request_result: request_result, + tcc_preflight_after: Some(preflight_after_request), + requested_tap_mask: None, + tap_mask: None, + tap_created: None, + tap_enabled: None, + run_loop_started: None, + redacted_event_count: None, + picker_presented: Some(false), + picker_selected: Some(false), + stream_started: None, + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + }; + let stream = stream_requested.then(|| MacosTccCanaryCapabilityEvidence { + capability: MacosTccCanaryCapability::Stream, + resulting_api_state: resulting_api_state(MacosTccCanaryCapability::Stream, outcome) + .to_owned(), + ..picker.clone() + }); + return (picker, stream); + } + + let started = Instant::now(); + let mailbox = session.mailbox(); + let mut selected = false; + let mut first_frame_monotonic_ns = None; + let mut live_before_revocation = false; + let mut revocation_preflight_observed = false; + let mut resource_failed_after_revocation = false; + loop { + selected |= !matches!(session.selection(), MacosCaptureSelection::None); + let wait = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(50)); + if stream_requested && let Some(delivery) = mailbox.wait_latest(wait) { + match delivery { + Ok(MacosFrameEvent::Frame(_)) => { + selected = true; + live_before_revocation = true; + first_frame_monotonic_ns = + Some(u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX)); + if request.lifecycle_phase != MacosTccCanaryLifecyclePhase::RevokeWhileLive { + break; + } + } + Ok(MacosFrameEvent::Lifecycle(_)) | Ok(MacosFrameEvent::RecoverableError(_)) => {} + Err(_) if revocation_preflight_observed => { + resource_failed_after_revocation = true; + break; + } + Err(_) => {} + } + } else if !stream_requested { + thread::park_timeout(wait); + } + if live_before_revocation && !MacosScreenCaptureSession::screen_authorized() { + revocation_preflight_observed = true; + resource_failed_after_revocation |= matches!( + session.status(), + MacosProtectedSourceState::PermissionDenied + | MacosProtectedSourceState::Revoked + | MacosProtectedSourceState::Interrupted + | MacosProtectedSourceState::Failed + ); + if resource_failed_after_revocation { + break; + } + } + if Instant::now() >= deadline || (!stream_requested && selected) { + break; + } + } + session.stop(); + let preflight_after = MacosScreenCaptureSession::screen_authorized(); + let picker_outcome = if selected { + MacosTccCanaryOutcome::Passed + } else if session.status() == MacosProtectedSourceState::NeedsSelection { + MacosTccCanaryOutcome::Cancelled + } else { + MacosTccCanaryOutcome::TimedOut + }; + let picker = MacosTccCanaryCapabilityEvidence { + capability: MacosTccCanaryCapability::Picker, + outcome: picker_outcome, + resulting_api_state: resulting_api_state(MacosTccCanaryCapability::Picker, picker_outcome) + .to_owned(), + typed_error: (picker_outcome != MacosTccCanaryOutcome::Passed) + .then(|| "picker_did_not_select".to_owned()), + tcc_preflight_before: Some(preflight_before), + tcc_request_result: request_result, + tcc_preflight_after: Some(preflight_after), + requested_tap_mask: None, + tap_mask: None, + tap_created: None, + tap_enabled: None, + run_loop_started: None, + redacted_event_count: None, + picker_presented: Some(true), + picker_selected: Some(selected), + stream_started: stream_requested.then_some(selected), + first_complete_frame: None, + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + }; + let stream = stream_requested.then(|| { + let revoked = live_before_revocation + && revocation_preflight_observed + && resource_failed_after_revocation; + let outcome = if revoked { + MacosTccCanaryOutcome::Revoked + } else if first_frame_monotonic_ns.is_some() { + MacosTccCanaryOutcome::Passed + } else { + MacosTccCanaryOutcome::TimedOut + }; + MacosTccCanaryCapabilityEvidence { + capability: MacosTccCanaryCapability::Stream, + outcome, + resulting_api_state: resulting_api_state(MacosTccCanaryCapability::Stream, outcome) + .to_owned(), + typed_error: (outcome != MacosTccCanaryOutcome::Passed + && outcome != MacosTccCanaryOutcome::Revoked) + .then(|| "first_complete_frame_missing".to_owned()), + tcc_preflight_before: Some(preflight_before), + tcc_request_result: request_result, + tcc_preflight_after: Some(preflight_after), + requested_tap_mask: None, + tap_mask: None, + tap_created: None, + tap_enabled: None, + run_loop_started: None, + redacted_event_count: None, + picker_presented: Some(true), + picker_selected: Some(selected), + stream_started: Some(selected), + first_complete_frame: Some(first_frame_monotonic_ns.is_some()), + first_frame_monotonic_ns, + resource_live_before_revocation: (request.lifecycle_phase + == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(live_before_revocation), + resource_failed_after_revocation: (request.lifecycle_phase + == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(resource_failed_after_revocation), + } + }); + (picker, stream) +} + +#[cfg(feature = "screen-capture")] +fn post_authorization_restart_evidence( + preflight_before: bool, + request_result: Option, + preflight_after: bool, +) -> ( + MacosTccCanaryCapabilityEvidence, + Option, +) { + let picker = post_authorization_evidence( + MacosTccCanaryCapability::Picker, + MacosTccCanaryOutcome::Failed, + preflight_before, + request_result, + preflight_after, + "stream_restart_required_before_picker", + ); + let stream = post_authorization_evidence( + MacosTccCanaryCapability::Stream, + MacosTccCanaryOutcome::NeedsProcessRestart, + preflight_before, + request_result, + preflight_after, + "post_authorization_stream_requires_restart", + ); + (picker, Some(stream)) +} + +#[cfg(feature = "screen-capture")] +fn post_authorization_failure_evidence( + preflight_before: bool, + request_result: Option, + preflight_after: bool, + outcome: MacosTccCanaryOutcome, + typed_error: &str, +) -> ( + MacosTccCanaryCapabilityEvidence, + Option, +) { + let picker = post_authorization_evidence( + MacosTccCanaryCapability::Picker, + MacosTccCanaryOutcome::Failed, + preflight_before, + request_result, + preflight_after, + typed_error, + ); + let stream = post_authorization_evidence( + MacosTccCanaryCapability::Stream, + outcome, + preflight_before, + request_result, + preflight_after, + typed_error, + ); + (picker, Some(stream)) +} + +#[cfg(feature = "screen-capture")] +fn post_authorization_evidence( + capability: MacosTccCanaryCapability, + outcome: MacosTccCanaryOutcome, + preflight_before: bool, + request_result: Option, + preflight_after: bool, + typed_error: &str, +) -> MacosTccCanaryCapabilityEvidence { + MacosTccCanaryCapabilityEvidence { + capability, + outcome, + resulting_api_state: resulting_api_state(capability, outcome).to_owned(), + typed_error: Some(typed_error.to_owned()), + tcc_preflight_before: Some(preflight_before), + tcc_request_result: request_result, + tcc_preflight_after: Some(preflight_after), + requested_tap_mask: None, + tap_mask: None, + tap_created: None, + tap_enabled: None, + run_loop_started: None, + redacted_event_count: None, + picker_presented: Some(false), + picker_selected: Some(false), + stream_started: (capability == MacosTccCanaryCapability::Stream).then_some(false), + first_complete_frame: (capability == MacosTccCanaryCapability::Stream).then_some(false), + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + } +} + +#[cfg(feature = "screen-capture")] +fn failed_screen_evidence( + capability: MacosTccCanaryCapability, + preflight: bool, + typed_error: &str, +) -> MacosTccCanaryCapabilityEvidence { + MacosTccCanaryCapabilityEvidence { + capability, + outcome: if preflight { + MacosTccCanaryOutcome::Failed + } else { + MacosTccCanaryOutcome::Denied + }, + resulting_api_state: resulting_api_state( + capability, + if preflight { + MacosTccCanaryOutcome::Failed + } else { + MacosTccCanaryOutcome::Denied + }, + ) + .to_owned(), + typed_error: Some(typed_error.to_owned()), + tcc_preflight_before: Some(preflight), + tcc_request_result: None, + tcc_preflight_after: Some(MacosScreenCaptureSession::screen_authorized()), + requested_tap_mask: None, + tap_mask: None, + tap_created: None, + tap_enabled: None, + run_loop_started: None, + redacted_event_count: None, + picker_presented: Some(false), + picker_selected: Some(false), + stream_started: (capability == MacosTccCanaryCapability::Stream).then_some(false), + first_complete_frame: (capability == MacosTccCanaryCapability::Stream).then_some(false), + first_frame_monotonic_ns: None, + resource_live_before_revocation: None, + resource_failed_after_revocation: None, + } +} + +#[cfg(feature = "screen-capture")] +fn input_error_code(error: &MacosInputError) -> &'static str { + match error { + MacosInputError::UnsupportedPlatform => "unsupported_platform", + MacosInputError::NothingToCapture => "nothing_to_capture", + MacosInputError::PermissionDenied => "permission_denied", + MacosInputError::InvalidVirtualDesktop => "invalid_virtual_desktop", + MacosInputError::DisplayTopology(_) => "display_topology_failed", + MacosInputError::NoActiveDisplays => "no_active_displays", + MacosInputError::WorkerSpawn(_) => "worker_spawn_failed", + MacosInputError::WorkerReadyTimeout => "worker_ready_timeout", + MacosInputError::TapCreation(_) => "tap_creation_failed", + MacosInputError::RunLoopSource(_) => "run_loop_source_failed", + MacosInputError::TapInspection(_) => "tap_inspection_failed", + MacosInputError::AuditToken(_) => "audit_token_failed", + } +} + +#[cfg(feature = "screen-capture")] +fn inspect_signing( + executable: &Path, + pid: u32, + process_fingerprint: &str, + audit_token: Option<&str>, +) -> Result { + let static_details = bounded_command( + "/usr/bin/codesign", + &["-d", "--verbose=4", path_arg(executable)?], + )?; + let dynamic_target = format!("+{pid}"); + let dynamic_details = + bounded_command("/usr/bin/codesign", &["-d", "--verbose=4", &dynamic_target])?; + let requirement = bounded_command("/usr/bin/codesign", &["-d", "-r-", path_arg(executable)?])?; + let static_verification = bounded_command( + "/usr/bin/codesign", + &["--verify", "--strict", "--verbose=4", path_arg(executable)?], + )?; + let dynamic_verification = bounded_command( + "/usr/bin/codesign", + &dynamic_codesign_verification_args(&dynamic_target), + )?; + let entitlements = bounded_command( + "/usr/bin/codesign", + &["-d", "--entitlements", ":-", path_arg(executable)?], + )?; + let spctl = bounded_command( + "/usr/sbin/spctl", + &[ + "--assess", + "--type", + "execute", + "--verbose=4", + path_arg(executable)?, + ], + )?; + let static_detail_text = bounded_utf8(&static_details.stderr, "static codesign details")?; + let dynamic_detail_text = bounded_utf8(&dynamic_details.stderr, "dynamic codesign details")?; + let static_cdhash = details_value(static_detail_text, "CDHash=")?.to_ascii_lowercase(); + let dynamic_cdhash = details_value(dynamic_detail_text, "CDHash=")?.to_ascii_lowercase(); + let requirement_text = bounded_utf8(&requirement.stdout, "codesign requirement")?; + let designated_requirement = requirement_text + .lines() + .find_map(|line| { + line.strip_prefix("designated => ") + .or_else(|| line.strip_prefix("# designated => ")) + }) + .context("codesign omitted designated requirement")? + .to_owned(); + let requirement_digest = hex_digest(designated_requirement.as_bytes()); + let entitlement_text = bounded_utf8(&entitlements.stdout, "codesign entitlements")?; + let mut evidence = MacosTccCanarySigningEvidence { + bundle_identifier: details_value(dynamic_detail_text, "Identifier=")?.to_owned(), + team_identifier: details_value(dynamic_detail_text, "TeamIdentifier=")?.to_owned(), + designated_requirement, + designated_requirement_sha256: requirement_digest, + cdhash: dynamic_cdhash.clone(), + process_bound_pid: pid, + process_bound_fingerprint: process_fingerprint.to_owned(), + process_bound_valid: dynamic_details.success + && dynamic_verification.success + && static_cdhash == dynamic_cdhash, + audit_token_bound_valid: false, + authorities: dynamic_detail_text + .lines() + .filter_map(|line| line.strip_prefix("Authority=")) + .map(str::to_owned) + .collect(), + entitlement_keys: plist_true_keys(entitlement_text)?, + codesign_strict_valid: static_details.success + && requirement.success + && static_verification.success + && entitlements.success, + hardened_runtime: dynamic_detail_text + .lines() + .find(|line| line.starts_with("flags=")) + .is_some_and(|line| line.contains("runtime")), + secure_timestamp: dynamic_detail_text + .lines() + .any(|line| line.starts_with("Timestamp=")), + spctl_accepted: spctl.success, + }; + evidence.audit_token_bound_valid = audit_token + .is_some_and(|token| live_signing_identity_is_valid(token, executable, &evidence)); + Ok(evidence) +} + +#[cfg(feature = "screen-capture")] +fn plist_true_keys(xml: &str) -> Result> { + let mut keys = Vec::new(); + let mut remaining = xml; + while let Some(key_start) = remaining.find("") { + remaining = &remaining[key_start + "".len()..]; + let key_end = remaining + .find("") + .context("entitlement key is not terminated")?; + let key = &remaining[..key_end]; + remaining = &remaining[key_end + "".len()..]; + let value = remaining.trim_start(); + anyhow::ensure!( + value.starts_with("") || value.starts_with(""), + "entitlement {key} is not true" + ); + keys.push(key.to_owned()); + } + anyhow::ensure!( + !keys.is_empty(), + "codesign returned no Boolean entitlements" + ); + keys.sort(); + Ok(keys) +} + +#[cfg(feature = "screen-capture")] +fn inspect_launcher( + topology: MacosDaemonOwner, + daemon_signing: &MacosTccCanarySigningEvidence, +) -> Result { + let pid = std::process::id(); + let mut system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + system.refresh_processes(ProcessesToUpdate::All, true); + let current = system + .process(Pid::from_u32(pid)) + .context("current process is missing from the process table")?; + let parent_pid = current.parent().map(Pid::as_u32); + let parent_executable_path = current + .parent() + .and_then(|parent| system.process(parent)) + .and_then(|parent| parent.exe()) + .map(Path::to_path_buf); + let mut parent_signing = None; + let (actual_launcher, expected_label, launchctl_pid_matches, verified) = match topology { + MacosDaemonOwner::AppSidecar => { + parent_signing = + parent_pid + .zip(parent_executable_path.as_deref()) + .and_then(|(parent_pid, path)| { + process_fingerprint(parent_pid) + .and_then(|fingerprint| { + inspect_signing(path, parent_pid, &fingerprint, None) + }) + .ok() + }); + let parent_verified = parent_signing.as_ref().is_some_and(|signing| { + signing.bundle_identifier == "tech.hyperbliss.hypercolor" + && signing.team_identifier == daemon_signing.team_identifier + && signing.codesign_strict_valid + && signing.hardened_runtime + && signing.secure_timestamp + && signing.spctl_accepted + }); + ( + "packaged_app_supervisor".to_owned(), + None, + None, + parent_verified, + ) + } + MacosDaemonOwner::DirectLaunchd => { + let label = "tech.hyperbliss.hypercolor"; + let matches = launchctl_service_pid(label)? == Some(pid); + ( + "direct_launchd".to_owned(), + Some(label.to_owned()), + Some(matches), + matches, + ) + } + MacosDaemonOwner::Homebrew => { + let label = "homebrew.mxcl.hypercolor"; + let matches = launchctl_service_pid(label)? == Some(pid); + ( + "homebrew_services".to_owned(), + Some(label.to_owned()), + Some(matches), + matches, + ) + } + MacosDaemonOwner::Standalone => { + let direct = launchctl_service_pid("tech.hyperbliss.hypercolor")? == Some(pid); + let homebrew = launchctl_service_pid("homebrew.mxcl.hypercolor")? == Some(pid); + let terminal_parent = parent_executable_path + .as_deref() + .is_some_and(terminal_parent_is_valid); + ( + "terminal_parent".to_owned(), + None, + None, + terminal_parent && !direct && !homebrew, + ) + } + }; + Ok(MacosTccCanaryLauncherEvidence { + actual_launcher, + expected_label, + parent_pid, + parent_executable_path, + parent_signing, + launchctl_pid_matches, + verified, + }) +} + +#[cfg(feature = "screen-capture")] +fn launchctl_service_pid(label: &str) -> Result> { + let uid = bounded_command_text("/usr/bin/id", &["-u"])?; + let target = format!("gui/{uid}/{label}"); + let output = bounded_command("/bin/launchctl", &["print", &target])?; + if !output.success { + return Ok(None); + } + let output = bounded_utf8(&output.stdout, "launchctl output")?; + output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix("pid = ")) + .map(str::parse) + .transpose() + .context("launchctl returned an invalid service pid") +} + +#[cfg(feature = "screen-capture")] +fn host_architecture() -> Result { + if sysctl_flag("hw.optional.arm64")? || sysctl_flag("sysctl.proc_translated")? { + Ok("apple_silicon".to_owned()) + } else { + Ok("intel".to_owned()) + } +} + +#[cfg(feature = "screen-capture")] +fn process_fingerprint(pid: u32) -> Result { + let pid = pid.to_string(); + let identity = + bounded_command_text("/bin/ps", &["-p", &pid, "-o", "lstart=", "-o", "command="])?; + let identity = identity.split_whitespace().collect::>().join(" "); + anyhow::ensure!(!identity.is_empty(), "process identity is empty"); + Ok(hex_digest(identity.as_bytes())) +} + +#[cfg(feature = "screen-capture")] +fn sysctl_flag(name: &str) -> Result { + let output = bounded_command("/usr/sbin/sysctl", &["-in", name])?; + if !output.success { + return Ok(false); + } + match bounded_utf8(&output.stdout, "sysctl output")?.trim() { + "" | "0" => Ok(false), + "1" => Ok(true), + value => anyhow::bail!("sysctl {name} returned unexpected value {value:?}"), + } +} + +#[cfg(feature = "screen-capture")] +struct BoundedCommandOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +#[cfg(feature = "screen-capture")] +fn dynamic_codesign_verification_args(dynamic_target: &str) -> [&str; 2] { + ["--verify", dynamic_target] +} + +#[cfg(feature = "screen-capture")] +fn bounded_command(program: &str, args: &[&str]) -> Result { + const MAX_OUTPUT: usize = 64 * 1024; + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {program}"))?; + anyhow::ensure!( + output.stdout.len() <= MAX_OUTPUT && output.stderr.len() <= MAX_OUTPUT, + "{program} output exceeds 64 KiB" + ); + Ok(BoundedCommandOutput { + success: output.status.success(), + stdout: output.stdout, + stderr: output.stderr, + }) +} + +#[cfg(feature = "screen-capture")] +fn bounded_command_text(program: &str, args: &[&str]) -> Result { + let output = bounded_command(program, args)?; + anyhow::ensure!(output.success, "{program} exited unsuccessfully"); + Ok(bounded_utf8(&output.stdout, "command output")? + .trim() + .to_owned()) +} + +#[cfg(feature = "screen-capture")] +fn bounded_utf8<'a>(bytes: &'a [u8], label: &str) -> Result<&'a str> { + std::str::from_utf8(bytes).with_context(|| format!("{label} is not UTF-8")) +} + +#[cfg(feature = "screen-capture")] +fn details_value<'a>(details: &'a str, prefix: &str) -> Result<&'a str> { + details + .lines() + .find_map(|line| line.strip_prefix(prefix)) + .filter(|value| !value.is_empty()) + .with_context(|| format!("codesign omitted {prefix}")) +} + +#[cfg(feature = "screen-capture")] +fn path_arg(path: &Path) -> Result<&str> { + path.to_str().context("process path is not valid UTF-8") +} + +#[cfg(feature = "screen-capture")] +fn hex_digest(bytes: &[u8]) -> String { + hex_bytes(&Sha256::digest(bytes)) +} + +fn hex_bytes(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + bytes.iter().fold( + String::with_capacity(bytes.len().saturating_mul(2)), + |mut digest, byte| { + write!(&mut digest, "{byte:02x}").expect("writing into a String cannot fail"); + digest + }, + ) +} + +#[cfg(feature = "screen-capture")] +fn unix_time_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock predates the Unix epoch")? + .as_millis() + .try_into() + .context("system time exceeds u64 milliseconds") +} + +fn validate_receipt_set( + receipts: &[MacosTccCanaryReceipt], + witnesses: &[MacosTccCanaryWitness], +) -> MacosTccCanaryValidation { + let mut missing = BTreeSet::new(); + let mut by_row = BTreeMap::new(); + let mut witness_by_id = BTreeMap::new(); + let mut structure_valid = !receipts.is_empty(); + let run_id = receipts.first().map(|receipt| receipt.run_id.as_str()); + for receipt in receipts { + structure_valid &= receipt.schema_version == MACOS_TCC_CANARY_SCHEMA_VERSION; + structure_valid &= validate_identifier(&receipt.run_id, "run_id").is_ok(); + structure_valid &= validate_identifier(&receipt.row_id, "row_id").is_ok(); + structure_valid &= validate_identifier(&receipt.scenario_id, "scenario_id").is_ok(); + structure_valid &= validate_identifier( + &receipt.system_settings_identity_witness_id, + "system_settings_identity_witness_id", + ) + .is_ok(); + structure_valid &= receipt + .predecessor_row_id + .as_deref() + .is_none_or(|value| validate_identifier(value, "predecessor_row_id").is_ok()); + structure_valid &= receipt + .process_replacement_witness_id + .as_deref() + .is_none_or(|value| { + validate_identifier(value, "process_replacement_witness_id").is_ok() + }); + structure_valid &= receipt + .lifecycle_action_witness_id + .as_deref() + .is_none_or(|value| validate_identifier(value, "lifecycle_action_witness_id").is_ok()); + structure_valid &= receipt + .login_arbitration_witness_id + .as_deref() + .is_none_or(|value| validate_identifier(value, "login_arbitration_witness_id").is_ok()); + structure_valid &= receipt + .fresh_tcc_reset_witness_id + .as_deref() + .is_none_or(|value| validate_identifier(value, "fresh_tcc_reset_witness_id").is_ok()); + structure_valid &= receipt.installation_scenario.permits(receipt.topology); + structure_valid &= + receipt.lifecycle_phase.needs_predecessor() == receipt.predecessor_row_id.is_some(); + structure_valid &= receipt.lifecycle_phase.replaces_process() + == receipt.process_replacement_witness_id.is_some(); + structure_valid &= receipt.lifecycle_phase.needs_lifecycle_action_witness() + == receipt.lifecycle_action_witness_id.is_some(); + structure_valid &= receipt.installation_scenario.needs_repeated_login_proof() + == receipt.login_arbitration_witness_id.is_some(); + structure_valid &= receipt.login_iteration > 0; + structure_valid &= receipt.acceptance_claim == "evidence_only"; + structure_valid &= Some(receipt.run_id.as_str()) == run_id; + structure_valid &= receipt.operation_finished_unix_ms >= receipt.process_started_unix_ms; + structure_valid &= validate_observed_text(&receipt.expected_prompt_text, "prompt").is_ok(); + structure_valid &= validate_observed_text( + &receipt.expected_system_settings_entry, + "system settings entry", + ) + .is_ok(); + structure_valid &= is_sha256(&receipt.process_fingerprint); + structure_valid &= architecture_evidence_is_coherent(receipt); + structure_valid &= !receipt.capabilities.is_empty(); + structure_valid &= scored_capability_shape_is_valid( + receipt.scored_capability, + &receipt + .capabilities + .iter() + .map(|evidence| evidence.capability) + .collect(), + ); + structure_valid &= by_row.insert(receipt.row_id.as_str(), receipt).is_none(); + } + for witness in witnesses { + structure_valid &= validate_witness_structure(witness); + structure_valid &= by_row + .get(witness.row_id.as_str()) + .is_some_and(|receipt| receipt.run_id == witness.run_id); + structure_valid &= witness_by_id + .insert(witness.witness_id.as_str(), witness) + .is_none(); + } + if receipts.is_empty() { + missing.insert("no_receipts".to_owned()); + } + if !structure_valid { + missing.insert("receipt_structure".to_owned()); + } + + let identity_consistent = structure_valid + && receipts + .iter() + .all(|receipt| receipt_identity_valid(receipt, &witness_by_id)) + && matrix_identity_is_stable(receipts); + if !identity_consistent { + missing.insert("signed_launcher_identity".to_owned()); + } + + for receipt in receipts { + validate_capability_evidence(receipt, &mut missing); + validate_lifecycle_link(receipt, &by_row, &witness_by_id, &mut missing); + validate_login_arbitration(receipt, &witness_by_id, &mut missing); + } + for topology in [ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Homebrew, + MacosDaemonOwner::Standalone, + ] { + for capability in [ + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryCapability::Pointer, + MacosTccCanaryCapability::Picker, + MacosTccCanaryCapability::Stream, + ] { + for (architecture, os_family) in platform_cells() { + for phase in capability_phases(capability) + .iter() + .chain(topology_phases(topology)) + .copied() + { + if !receipts.iter().any(|receipt| { + receipt.topology == topology + && receipt.scored_capability == capability + && receipt.lifecycle_phase == phase + && platform_cell_matches(receipt, architecture) + && macos_os_family(&receipt.os_version) == Some(os_family) + && receipt.capabilities.iter().any(|evidence| { + evidence.capability == capability + && evidence.outcome == expected_outcome(phase) + }) + }) { + missing.insert( + format!( + "{topology:?}_{capability:?}_{architecture}_{os_family}_{phase:?}" + ) + .to_ascii_lowercase(), + ); + } + } + if !receipts.iter().any(|receipt| { + receipt.topology == topology + && receipt.scored_capability == capability + && platform_cell_matches(receipt, architecture) + && macos_os_family(&receipt.os_version) == Some(os_family) + && receipt + .fresh_tcc_reset_witness_id + .as_deref() + .and_then(|witness_id| { + matching_witness( + receipt, + witness_id, + MacosTccCanaryWitnessKind::FreshTccReset, + &witness_by_id, + ) + }) + .is_some_and(|witness| { + witness.fresh_tcc_database_observed == Some(true) + }) + && receipt.capabilities.iter().any(|evidence| { + evidence.capability == capability + && evidence.outcome == MacosTccCanaryOutcome::Passed + }) + }) { + missing.insert( + format!( + "{topology:?}_{capability:?}_{architecture}_{os_family}_fresh_database" + ) + .to_ascii_lowercase(), + ); + } + } + } + } + for scenario in [ + MacosTccCanaryInstallationScenario::AppOnly, + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + MacosTccCanaryInstallationScenario::HomebrewOnly, + MacosTccCanaryInstallationScenario::StandaloneOnly, + MacosTccCanaryInstallationScenario::AppDirectAppEnabledFirst, + MacosTccCanaryInstallationScenario::AppDirectDirectEnabledFirst, + MacosTccCanaryInstallationScenario::AppHomebrew, + MacosTccCanaryInstallationScenario::DirectHomebrew, + MacosTccCanaryInstallationScenario::AppDirectHomebrew, + ] { + let scenario_receipts = receipts + .iter() + .filter(|receipt| receipt.installation_scenario == scenario) + .collect::>(); + if scenario_receipts.is_empty() { + missing.insert(format!("installation_{scenario:?}").to_ascii_lowercase()); + continue; + } + if scenario.needs_repeated_login_proof() { + for (architecture, os_family) in platform_cells() { + let mut scenario_groups: BTreeMap< + &str, + (MacosDaemonOwner, BTreeMap, bool), + > = BTreeMap::new(); + for receipt in scenario_receipts.iter().copied().filter(|receipt| { + platform_cell_matches(receipt, architecture) + && macos_os_family(&receipt.os_version) == Some(os_family) + }) { + let Some(witness) = login_arbitration_witness(receipt, &witness_by_id) + .filter(|witness| login_arbitration_witness_is_valid(receipt, witness)) + else { + continue; + }; + let group = scenario_groups + .entry(receipt.scenario_id.as_str()) + .or_insert((receipt.topology, BTreeMap::new(), true)); + group.2 &= group.0 == receipt.topology; + let Some(session_id) = witness.login_session_id.as_deref() else { + continue; + }; + group.2 &= group + .1 + .insert(receipt.login_iteration, session_id) + .is_none(); + } + if !scenario_groups + .values() + .any(|(_, sessions, topology_stable)| { + *topology_stable + && sessions.len() >= 2 + && sessions.values().copied().collect::>().len() >= 2 + }) + { + missing.insert( + format!( + "installation_{scenario:?}_{architecture}_{os_family}_repeated_login" + ) + .to_ascii_lowercase(), + ); + } + } + } + } + + let capability_qualifications = [ + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryCapability::Pointer, + MacosTccCanaryCapability::Picker, + MacosTccCanaryCapability::Stream, + ] + .into_iter() + .map(|capability| { + let qualified_topologies = [ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Homebrew, + MacosDaemonOwner::Standalone, + ] + .into_iter() + .filter(|topology| { + topology_capability_qualifies(receipts, &witness_by_id, *topology, capability) + }) + .collect::>(); + let preferred_topology = qualified_topologies + .contains(&MacosDaemonOwner::AppSidecar) + .then_some(MacosDaemonOwner::AppSidecar); + MacosTccCanaryCapabilityQualification { + capability, + preferred_topology, + qualified_topologies, + app_broker_required: preferred_topology.is_none(), + } + }) + .collect::>(); + let preferred_topology_eligible = structure_valid + && identity_consistent + && missing.is_empty() + && capability_qualifications + .iter() + .all(|qualification| qualification.preferred_topology.is_some()); + MacosTccCanaryValidation { + schema_version: MACOS_TCC_CANARY_SCHEMA_VERSION, + receipt_structure_valid: structure_valid, + identity_consistent, + preferred_topology_eligible, + physical_acceptance_claimed: false, + receipt_count: receipts.len(), + capability_qualifications, + missing_requirements: missing.into_iter().collect(), + } +} + +fn launcher_identity_valid(receipt: &MacosTccCanaryReceipt) -> bool { + if !receipt.launcher.verified { + return false; + } + match receipt.topology { + MacosDaemonOwner::AppSidecar => { + receipt.launcher.actual_launcher == "packaged_app_supervisor" + && receipt.launcher.expected_label.is_none() + && receipt + .launcher + .parent_signing + .as_ref() + .is_some_and(|signing| { + signing.bundle_identifier == "tech.hyperbliss.hypercolor" + && signing.team_identifier == receipt.signing.team_identifier + && receipt.launcher.parent_pid == Some(signing.process_bound_pid) + && signing_identity_is_valid(signing) + }) + } + MacosDaemonOwner::DirectLaunchd => { + receipt.launcher.actual_launcher == "direct_launchd" + && receipt.launcher.expected_label.as_deref() == Some("tech.hyperbliss.hypercolor") + && receipt.launcher.launchctl_pid_matches == Some(true) + } + MacosDaemonOwner::Homebrew => { + receipt.launcher.actual_launcher == "homebrew_services" + && receipt.launcher.expected_label.as_deref() == Some("homebrew.mxcl.hypercolor") + && receipt.launcher.launchctl_pid_matches == Some(true) + } + MacosDaemonOwner::Standalone => { + receipt.launcher.actual_launcher == "terminal_parent" + && receipt.launcher.expected_label.is_none() + && receipt.launcher.parent_pid.is_some() + && receipt + .launcher + .parent_executable_path + .as_deref() + .is_some_and(terminal_parent_is_valid) + } + } +} + +fn receipt_identity_valid( + receipt: &MacosTccCanaryReceipt, + witnesses: &BTreeMap<&str, &MacosTccCanaryWitness>, +) -> bool { + let expected_bundle_identifier = match receipt.topology { + MacosDaemonOwner::AppSidecar => "tech.hyperbliss.hypercolor.sidecar", + MacosDaemonOwner::DirectLaunchd + | MacosDaemonOwner::Homebrew + | MacosDaemonOwner::Standalone => "tech.hyperbliss.hypercolor.daemon", + }; + launcher_identity_valid(receipt) + && receipt.signing.bundle_identifier == expected_bundle_identifier + && signing_identity_is_valid(&receipt.signing) + && receipt.signing.process_bound_pid == receipt.pid + && receipt.signing.process_bound_fingerprint == receipt.process_fingerprint + && audit_token_identity(&receipt.audit_token_identity) + .is_some_and(|identity| identity.pid == receipt.pid) + && receipt.executable_path.is_absolute() + && matching_witness( + receipt, + &receipt.system_settings_identity_witness_id, + MacosTccCanaryWitnessKind::SystemSettingsIdentity, + witnesses, + ) + .is_some_and(|witness| { + witness.observed_unix_ms >= receipt.process_started_unix_ms + && witness.prompt_text.as_deref() == Some(receipt.expected_prompt_text.as_str()) + && witness.system_settings_entry.as_deref() + == Some(receipt.expected_system_settings_entry.as_str()) + && witness.observed_pid == Some(receipt.pid) + && witness.observed_audit_token_identity.as_deref() + == Some(receipt.audit_token_identity.as_str()) + && witness.observed_signing_audit_token_identity.as_deref() + == Some(receipt.audit_token_identity.as_str()) + && witness.observed_cdhash.as_deref() == Some(receipt.signing.cdhash.as_str()) + && witness.observed_designated_requirement_sha256.as_deref() + == Some(receipt.signing.designated_requirement_sha256.as_str()) + && witness.observed_process_fingerprint.as_deref() + == Some(receipt.process_fingerprint.as_str()) + && app_parent_witness_is_valid(receipt, witness) + }) +} + +fn app_parent_witness_is_valid( + receipt: &MacosTccCanaryReceipt, + witness: &MacosTccCanaryWitness, +) -> bool { + if receipt.topology != MacosDaemonOwner::AppSidecar { + return witness.parent_pid.is_none() + && witness.parent_audit_token_identity.is_none() + && witness.parent_signing_audit_token_identity.is_none() + && witness.parent_cdhash.is_none() + && witness.parent_designated_requirement_sha256.is_none() + && witness.parent_process_fingerprint.is_none(); + } + let Some(parent_signing) = receipt.launcher.parent_signing.as_ref() else { + return false; + }; + witness.parent_pid == receipt.launcher.parent_pid + && witness.parent_pid == Some(parent_signing.process_bound_pid) + && witness + .parent_audit_token_identity + .as_deref() + .and_then(audit_token_identity) + .is_some_and(|identity| Some(identity.pid) == witness.parent_pid) + && witness.parent_signing_audit_token_identity == witness.parent_audit_token_identity + && witness.parent_cdhash.as_deref() == Some(parent_signing.cdhash.as_str()) + && witness.parent_designated_requirement_sha256.as_deref() + == Some(parent_signing.designated_requirement_sha256.as_str()) + && witness.parent_process_fingerprint.as_deref() + == Some(parent_signing.process_bound_fingerprint.as_str()) +} + +fn signing_identity_is_valid(signing: &MacosTccCanarySigningEvidence) -> bool { + signing.process_bound_valid + && signing.audit_token_bound_valid + && signing.process_bound_pid > 0 + && is_sha256(&signing.process_bound_fingerprint) + && signing.codesign_strict_valid + && signing.hardened_runtime + && signing.secure_timestamp + && signing.spctl_accepted + && !signing.bundle_identifier.is_empty() + && !signing.team_identifier.is_empty() + && signing.authorities.first().is_some_and(|authority| { + authority.starts_with("Developer ID Application:") + && authority.contains(&format!("({})", signing.team_identifier)) + }) + && signing.entitlement_keys.len() == REQUIRED_ENTITLEMENTS.len() + && signing + .entitlement_keys + .iter() + .map(String::as_str) + .eq(REQUIRED_ENTITLEMENTS) + && !signing.designated_requirement.is_empty() + && signing + .designated_requirement + .contains(&signing.bundle_identifier) + && signing.designated_requirement_sha256 + == hex_digest(signing.designated_requirement.as_bytes()) + && is_hex_with_length(&signing.cdhash, &[40, 64]) +} + +fn matrix_identity_is_stable(receipts: &[MacosTccCanaryReceipt]) -> bool { + let mut identities = BTreeMap::new(); + let team_identifier = receipts + .first() + .map(|receipt| receipt.signing.team_identifier.as_str()); + receipts.iter().all(|receipt| { + let identity = ( + receipt.signing.bundle_identifier.as_str(), + receipt.signing.team_identifier.as_str(), + receipt.signing.designated_requirement.as_str(), + receipt.signing.authorities.as_slice(), + receipt.signing.entitlement_keys.as_slice(), + receipt.launcher.parent_signing.as_ref().map(|signing| { + ( + signing.bundle_identifier.as_str(), + signing.team_identifier.as_str(), + signing.designated_requirement.as_str(), + signing.authorities.as_slice(), + signing.entitlement_keys.as_slice(), + ) + }), + ); + Some(receipt.signing.team_identifier.as_str()) == team_identifier + && identities + .entry(topology_key(receipt.topology)) + .or_insert(identity) + == &identity + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AuditTokenIdentity { + pid: u32, + pidversion: u32, +} + +fn audit_token_identity(identity: &str) -> Option { + let words = identity.split(':').collect::>(); + if words.len() != 8 + || words + .iter() + .any(|word| word.len() != 8 || !word.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + return None; + } + Some(AuditTokenIdentity { + pid: u32::from_str_radix(words[5], 16).ok()?, + pidversion: u32::from_str_radix(words[7], 16).ok()?, + }) +} + +#[cfg(feature = "screen-capture")] +fn audit_token_bytes(identity: &str) -> Option<[u8; 32]> { + let words = identity.split(':').collect::>(); + if words.len() != 8 { + return None; + } + let mut bytes = [0_u8; 32]; + for (index, word) in words.into_iter().enumerate() { + let parsed = u32::from_str_radix(word, 16).ok()?; + bytes[index * 4..index * 4 + 4].copy_from_slice(&parsed.to_ne_bytes()); + } + Some(bytes) +} + +const fn topology_key(topology: MacosDaemonOwner) -> u8 { + match topology { + MacosDaemonOwner::AppSidecar => 0, + MacosDaemonOwner::DirectLaunchd => 1, + MacosDaemonOwner::Homebrew => 2, + MacosDaemonOwner::Standalone => 3, + } +} + +fn terminal_parent_is_valid(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + matches!( + name, + "bash" | "dash" | "fish" | "nu" | "sh" | "tcsh" | "zsh" + ) + }) +} + +fn validate_capability_evidence(receipt: &MacosTccCanaryReceipt, missing: &mut BTreeSet) { + let capabilities = receipt + .capabilities + .iter() + .map(|evidence| evidence.capability) + .collect::>(); + if capabilities.len() != receipt.capabilities.len() { + missing.insert(format!("{}_duplicate_capability", receipt.row_id)); + } + let keyboard_mask = receipt + .capabilities + .iter() + .find(|evidence| evidence.capability == MacosTccCanaryCapability::Keyboard) + .and_then(|evidence| evidence.tap_mask); + let pointer_mask = receipt + .capabilities + .iter() + .find(|evidence| evidence.capability == MacosTccCanaryCapability::Pointer) + .and_then(|evidence| evidence.tap_mask); + if keyboard_mask + .zip(pointer_mask) + .is_some_and(|(keyboard, pointer)| keyboard & pointer != 0) + { + missing.insert(format!("{}_input_masks_overlap", receipt.row_id)); + } + let scored = receipt + .capabilities + .iter() + .find(|evidence| evidence.capability == receipt.scored_capability); + if scored.is_none() { + missing.insert(format!("{}_scored_capability", receipt.row_id)); + } + if let Some(scored) = scored { + let tcc_protected = receipt.scored_capability != MacosTccCanaryCapability::Pointer; + match receipt.lifecycle_phase { + MacosTccCanaryLifecyclePhase::Deny => { + if scored.outcome == MacosTccCanaryOutcome::Denied + && tcc_protected + && scored.tcc_preflight_after != Some(false) + { + missing.insert(format!("{}_denial_evidence", receipt.row_id)); + } + } + MacosTccCanaryLifecyclePhase::LaterGrant => { + if scored.outcome == MacosTccCanaryOutcome::Passed + && tcc_protected + && (scored.tcc_request_result != Some(true) + || scored.tcc_preflight_after != Some(true)) + { + missing.insert(format!("{}_later_grant_evidence", receipt.row_id)); + } + } + MacosTccCanaryLifecyclePhase::OwnerRestart + | MacosTccCanaryLifecyclePhase::AppRelaunch + | MacosTccCanaryLifecyclePhase::ServiceRestart + | MacosTccCanaryLifecyclePhase::SignedUpdate => { + if scored.outcome == MacosTccCanaryOutcome::Passed + && tcc_protected + && (scored.tcc_preflight_before != Some(true) + || scored.tcc_preflight_after != Some(true)) + { + missing.insert(format!("{}_persistent_grant_evidence", receipt.row_id)); + } + } + MacosTccCanaryLifecyclePhase::Grant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation + | MacosTccCanaryLifecyclePhase::AppLaunch + | MacosTccCanaryLifecyclePhase::ServiceInstall + | MacosTccCanaryLifecyclePhase::LoginStart => { + if scored.outcome == MacosTccCanaryOutcome::Passed + && tcc_protected + && scored.tcc_preflight_after != Some(true) + { + missing.insert(format!("{}_grant_evidence", receipt.row_id)); + } + } + MacosTccCanaryLifecyclePhase::RevokeWhileLive => {} + } + } + for evidence in &receipt.capabilities { + if evidence.resulting_api_state + != resulting_api_state(evidence.capability, evidence.outcome) + { + missing.insert(format!("{}_api_state", receipt.row_id)); + } + if evidence.outcome == MacosTccCanaryOutcome::NeedsProcessRestart + && !process_restart_evidence_is_valid(receipt, evidence) + { + missing.insert(format!("{}_process_restart_evidence", receipt.row_id)); + } + if evidence.outcome != MacosTccCanaryOutcome::Passed { + continue; + } + match evidence.capability { + MacosTccCanaryCapability::Keyboard => { + if evidence.tap_created != Some(true) + || evidence.tap_enabled != Some(true) + || evidence.run_loop_started != Some(true) + || !evidence + .requested_tap_mask + .zip(evidence.tap_mask) + .is_some_and(|(requested, installed)| { + requested != 0 && installed == requested + }) + || evidence.redacted_event_count.is_none_or(|count| count == 0) + { + missing.insert(format!("{}_keyboard_operation", receipt.row_id)); + } + } + MacosTccCanaryCapability::Pointer => { + if evidence.tap_created != Some(true) + || evidence.tap_enabled != Some(true) + || evidence.run_loop_started != Some(true) + || !evidence + .requested_tap_mask + .zip(evidence.tap_mask) + .is_some_and(|(requested, installed)| { + requested != 0 && installed == requested + }) + || evidence.redacted_event_count.is_none_or(|count| count == 0) + { + missing.insert(format!("{}_pointer_operation", receipt.row_id)); + } + } + MacosTccCanaryCapability::Picker => { + if evidence.picker_presented != Some(true) || evidence.picker_selected != Some(true) + { + missing.insert(format!("{}_picker_operation", receipt.row_id)); + } + } + MacosTccCanaryCapability::Stream => { + let picker_passed = receipt.capabilities.iter().any(|candidate| { + candidate.capability == MacosTccCanaryCapability::Picker + && candidate.outcome == MacosTccCanaryOutcome::Passed + && candidate.picker_selected == Some(true) + }); + if !picker_passed + || evidence.stream_started != Some(true) + || evidence.first_complete_frame != Some(true) + || evidence.first_frame_monotonic_ns.is_none() + { + missing.insert(format!("{}_stream_operation", receipt.row_id)); + } + } + } + } + if receipt.lifecycle_phase == MacosTccCanaryLifecyclePhase::RevokeWhileLive + && receipt + .capabilities + .iter() + .find(|evidence| evidence.capability == receipt.scored_capability) + .is_some_and(|evidence| evidence.outcome == MacosTccCanaryOutcome::Revoked) + && !receipt.capabilities.iter().any(|evidence| { + evidence.capability == receipt.scored_capability + && evidence.resource_live_before_revocation == Some(true) + && evidence.resource_failed_after_revocation == Some(true) + && evidence.tcc_preflight_after == Some(false) + }) + { + missing.insert(format!("{}_live_revocation", receipt.row_id)); + } +} + +fn process_restart_evidence_is_valid( + receipt: &MacosTccCanaryReceipt, + evidence: &MacosTccCanaryCapabilityEvidence, +) -> bool { + match evidence.capability { + MacosTccCanaryCapability::Keyboard => { + (evidence.tcc_request_result == Some(true) + || evidence.tcc_preflight_after == Some(true)) + && (evidence + .requested_tap_mask + .zip(evidence.tap_mask) + .is_some_and(|(requested, installed)| requested != 0 && installed != requested) + || (evidence.tap_created == Some(false) + && evidence.typed_error.as_deref() == Some("permission_denied"))) + } + MacosTccCanaryCapability::Stream => { + let picker = receipt + .capabilities + .iter() + .find(|candidate| candidate.capability == MacosTccCanaryCapability::Picker); + evidence.tcc_request_result == Some(true) + && evidence.tcc_preflight_after == Some(true) + && evidence.typed_error.as_deref() + == Some("post_authorization_stream_requires_restart") + && evidence.picker_presented == Some(false) + && evidence.picker_selected == Some(false) + && evidence.stream_started == Some(false) + && evidence.first_complete_frame == Some(false) + && evidence.first_frame_monotonic_ns.is_none() + && picker.is_some_and(|picker| { + picker.outcome == MacosTccCanaryOutcome::Failed + && picker.typed_error.as_deref() + == Some("stream_restart_required_before_picker") + && picker.picker_presented == Some(false) + && picker.picker_selected == Some(false) + }) + } + MacosTccCanaryCapability::Pointer | MacosTccCanaryCapability::Picker => false, + } +} + +const fn resulting_api_state( + capability: MacosTccCanaryCapability, + outcome: MacosTccCanaryOutcome, +) -> &'static str { + match outcome { + MacosTccCanaryOutcome::Passed => match capability { + MacosTccCanaryCapability::Picker => "ready_idle", + MacosTccCanaryCapability::Keyboard + | MacosTccCanaryCapability::Pointer + | MacosTccCanaryCapability::Stream => "live", + }, + MacosTccCanaryOutcome::Denied => "permission_denied", + MacosTccCanaryOutcome::Revoked => "revoked", + MacosTccCanaryOutcome::NeedsProcessRestart => "needs_process_restart", + MacosTccCanaryOutcome::Cancelled => "needs_selection", + MacosTccCanaryOutcome::TimedOut => "interrupted", + MacosTccCanaryOutcome::Failed => "failed", + } +} + +fn validate_witness_structure(witness: &MacosTccCanaryWitness) -> bool { + witness.schema_version == MACOS_TCC_CANARY_SCHEMA_VERSION + && validate_identifier(&witness.run_id, "run_id").is_ok() + && validate_identifier(&witness.row_id, "row_id").is_ok() + && validate_identifier(&witness.witness_id, "witness_id").is_ok() + && !witness.observer.is_empty() + && witness.observer.len() <= 256 + && witness.observed_unix_ms > 0 + && is_sha256(&witness.evidence_sha256) + && witness_kind_shape_is_valid(witness) +} + +fn witness_kind_shape_is_valid(witness: &MacosTccCanaryWitness) -> bool { + let has_login_fields = witness.installed_topologies.is_some() + || witness.enable_order.is_some() + || witness.selected_topology.is_some() + || witness.losing_topologies.is_some() + || witness.owner_conflict_observed.is_some() + || witness.login_iteration.is_some() + || witness.login_session_id.is_some(); + let has_observed_process_fields = witness.observed_pid.is_some() + || witness.observed_audit_token_identity.is_some() + || witness.observed_signing_audit_token_identity.is_some() + || witness.observed_cdhash.is_some() + || witness.observed_designated_requirement_sha256.is_some() + || witness.observed_process_fingerprint.is_some() + || witness.parent_pid.is_some() + || witness.parent_audit_token_identity.is_some() + || witness.parent_signing_audit_token_identity.is_some() + || witness.parent_cdhash.is_some() + || witness.parent_designated_requirement_sha256.is_some() + || witness.parent_process_fingerprint.is_some(); + let has_replacement_fields = witness.predecessor_pid.is_some() + || witness.predecessor_audit_token_identity.is_some() + || witness.predecessor_process_fingerprint.is_some() + || witness.predecessor_exit_observed.is_some() + || witness.predecessor_parent_pid.is_some() + || witness.predecessor_parent_audit_token_identity.is_some() + || witness.predecessor_parent_process_fingerprint.is_some() + || witness.predecessor_parent_exit_observed.is_some(); + match witness.kind { + MacosTccCanaryWitnessKind::FreshTccReset => { + witness.fresh_tcc_database_observed == Some(true) + && witness.prompt_text.is_none() + && witness.system_settings_entry.is_none() + && !has_observed_process_fields + && !has_replacement_fields + && witness.launcher_action.is_none() + && !has_login_fields + } + MacosTccCanaryWitnessKind::SystemSettingsIdentity => { + witness + .prompt_text + .as_deref() + .is_some_and(|text| !text.is_empty()) + && witness + .system_settings_entry + .as_deref() + .is_some_and(|entry| !entry.is_empty()) + && witness.observed_pid.is_some() + && witness + .observed_audit_token_identity + .as_deref() + .and_then(audit_token_identity) + .is_some_and(|identity| Some(identity.pid) == witness.observed_pid) + && witness.observed_signing_audit_token_identity + == witness.observed_audit_token_identity + && witness + .observed_cdhash + .as_deref() + .is_some_and(|cdhash| is_hex_with_length(cdhash, &[40, 64])) + && witness + .observed_designated_requirement_sha256 + .as_deref() + .is_some_and(is_sha256) + && witness + .observed_process_fingerprint + .as_deref() + .is_some_and(is_sha256) + && ((witness.parent_pid.is_none() + && witness.parent_audit_token_identity.is_none() + && witness.parent_signing_audit_token_identity.is_none() + && witness.parent_cdhash.is_none() + && witness.parent_designated_requirement_sha256.is_none() + && witness.parent_process_fingerprint.is_none()) + || (witness + .parent_audit_token_identity + .as_deref() + .and_then(audit_token_identity) + .is_some_and(|identity| Some(identity.pid) == witness.parent_pid) + && witness.parent_signing_audit_token_identity + == witness.parent_audit_token_identity + && witness + .parent_cdhash + .as_deref() + .is_some_and(|cdhash| is_hex_with_length(cdhash, &[40, 64])) + && witness + .parent_designated_requirement_sha256 + .as_deref() + .is_some_and(is_sha256) + && witness + .parent_process_fingerprint + .as_deref() + .is_some_and(is_sha256))) + && witness.fresh_tcc_database_observed.is_none() + && !has_replacement_fields + && witness.launcher_action.is_none() + && !has_login_fields + } + MacosTccCanaryWitnessKind::ProcessReplacement => { + witness.prompt_text.is_none() + && witness.system_settings_entry.is_none() + && !has_observed_process_fields + && witness.fresh_tcc_database_observed.is_none() + && witness.predecessor_pid.is_some() + && witness + .predecessor_audit_token_identity + .as_deref() + .and_then(audit_token_identity) + .is_some_and(|identity| Some(identity.pid) == witness.predecessor_pid) + && witness + .predecessor_process_fingerprint + .as_deref() + .is_some_and(is_sha256) + && witness.predecessor_exit_observed == Some(true) + && witness + .launcher_action + .as_deref() + .is_some_and(|action| !action.is_empty()) + && !has_login_fields + } + MacosTccCanaryWitnessKind::LifecycleAction => { + witness.prompt_text.is_none() + && witness.system_settings_entry.is_none() + && !has_observed_process_fields + && witness.fresh_tcc_database_observed.is_none() + && !has_replacement_fields + && witness + .launcher_action + .as_deref() + .is_some_and(|action| !action.is_empty()) + && !has_login_fields + } + MacosTccCanaryWitnessKind::LoginArbitration => { + witness.prompt_text.is_none() + && witness.system_settings_entry.is_none() + && !has_observed_process_fields + && witness.fresh_tcc_database_observed.is_none() + && !has_replacement_fields + && witness.launcher_action.is_none() + && has_login_fields + } + } +} + +fn matching_witness<'a>( + receipt: &MacosTccCanaryReceipt, + witness_id: &str, + kind: MacosTccCanaryWitnessKind, + witnesses: &BTreeMap<&'a str, &'a MacosTccCanaryWitness>, +) -> Option<&'a MacosTccCanaryWitness> { + witnesses.get(witness_id).copied().filter(|witness| { + witness.run_id == receipt.run_id + && witness.row_id == receipt.row_id + && witness.kind == kind + && witness.observed_unix_ms <= receipt.operation_finished_unix_ms + }) +} + +fn validate_lifecycle_link<'a>( + receipt: &MacosTccCanaryReceipt, + by_row: &BTreeMap<&'a str, &'a MacosTccCanaryReceipt>, + witnesses: &BTreeMap<&'a str, &'a MacosTccCanaryWitness>, + missing: &mut BTreeSet, +) { + if !receipt.lifecycle_phase.needs_predecessor() { + if receipt.predecessor_row_id.is_some() { + missing.insert(format!("{}_unexpected_predecessor", receipt.row_id)); + } + if receipt.lifecycle_phase.needs_lifecycle_action_witness() { + let action_witness = + receipt + .lifecycle_action_witness_id + .as_deref() + .and_then(|witness_id| { + matching_witness( + receipt, + witness_id, + MacosTccCanaryWitnessKind::LifecycleAction, + witnesses, + ) + }); + if !action_witness.is_some_and(|witness| { + witness.launcher_action.as_deref() + == expected_launcher_action(receipt.topology, receipt.lifecycle_phase) + && witness.observed_unix_ms <= receipt.process_started_unix_ms + }) { + missing.insert(format!("{}_lifecycle_action_witness", receipt.row_id)); + } + } + return; + } + let Some(predecessor) = receipt + .predecessor_row_id + .as_deref() + .and_then(|row| by_row.get(row).copied()) + else { + missing.insert(format!("{}_predecessor", receipt.row_id)); + return; + }; + if receipt.lifecycle_phase.required_predecessor() != Some(predecessor.lifecycle_phase) { + missing.insert(format!("{}_predecessor_phase", receipt.row_id)); + } + if predecessor.run_id != receipt.run_id + || predecessor.scenario_id != receipt.scenario_id + || predecessor.installation_scenario != receipt.installation_scenario + || predecessor.login_iteration != receipt.login_iteration + || predecessor.topology != receipt.topology + || predecessor.scored_capability != receipt.scored_capability + || predecessor.host_architecture != receipt.host_architecture + || predecessor.executable_slice != receipt.executable_slice + || predecessor.translated_process != receipt.translated_process + || predecessor.os_version != receipt.os_version + { + missing.insert(format!("{}_predecessor_context", receipt.row_id)); + } + if predecessor.operation_finished_unix_ms > receipt.process_started_unix_ms { + missing.insert(format!("{}_predecessor_chronology", receipt.row_id)); + } + if receipt.lifecycle_phase.replaces_process() { + let predecessor_identity = audit_token_identity(&predecessor.audit_token_identity); + let successor_identity = audit_token_identity(&receipt.audit_token_identity); + if predecessor_identity.is_none() + || successor_identity.is_none() + || predecessor_identity == successor_identity + { + missing.insert(format!("{}_process_replacement", receipt.row_id)); + } + let replacement_witness = + receipt + .process_replacement_witness_id + .as_deref() + .and_then(|witness_id| { + matching_witness( + receipt, + witness_id, + MacosTccCanaryWitnessKind::ProcessReplacement, + witnesses, + ) + }); + if !replacement_witness.is_some_and(|witness| { + witness.predecessor_pid == Some(predecessor.pid) + && witness.predecessor_audit_token_identity.as_deref() + == Some(predecessor.audit_token_identity.as_str()) + && witness.predecessor_process_fingerprint.as_deref() + == Some(predecessor.process_fingerprint.as_str()) + && witness.predecessor_exit_observed == Some(true) + && witness.launcher_action.as_deref() + == expected_launcher_action(receipt.topology, receipt.lifecycle_phase) + && witness.observed_unix_ms >= predecessor.operation_finished_unix_ms + && witness.observed_unix_ms <= receipt.process_started_unix_ms + && predecessor_parent_replacement_is_valid(receipt, predecessor, witness, witnesses) + }) { + missing.insert(format!("{}_process_replacement_witness", receipt.row_id)); + } + if predecessor.signing.bundle_identifier != receipt.signing.bundle_identifier + || predecessor.signing.team_identifier != receipt.signing.team_identifier + || predecessor.signing.designated_requirement != receipt.signing.designated_requirement + { + missing.insert(format!("{}_stable_process_identity", receipt.row_id)); + } + } + if receipt.lifecycle_phase == MacosTccCanaryLifecyclePhase::SignedUpdate { + let stable_identity = predecessor.signing.bundle_identifier + == receipt.signing.bundle_identifier + && predecessor.signing.team_identifier == receipt.signing.team_identifier + && predecessor.signing.designated_requirement == receipt.signing.designated_requirement; + let changed_artifact = predecessor.binary_version != receipt.binary_version + && predecessor.signing.cdhash != receipt.signing.cdhash; + if !stable_identity || !changed_artifact { + missing.insert(format!("{}_signed_update_identity", receipt.row_id)); + } + } +} + +fn predecessor_parent_replacement_is_valid( + receipt: &MacosTccCanaryReceipt, + predecessor: &MacosTccCanaryReceipt, + witness: &MacosTccCanaryWitness, + witnesses: &BTreeMap<&str, &MacosTccCanaryWitness>, +) -> bool { + let replaces_app_parent = receipt.topology == MacosDaemonOwner::AppSidecar + && matches!( + receipt.lifecycle_phase, + MacosTccCanaryLifecyclePhase::AppRelaunch | MacosTccCanaryLifecyclePhase::SignedUpdate + ); + if !replaces_app_parent { + return witness.predecessor_parent_pid.is_none() + && witness.predecessor_parent_audit_token_identity.is_none() + && witness.predecessor_parent_process_fingerprint.is_none() + && witness.predecessor_parent_exit_observed.is_none(); + } + let Some(parent_signing) = predecessor.launcher.parent_signing.as_ref() else { + return false; + }; + let predecessor_settings = matching_witness( + predecessor, + &predecessor.system_settings_identity_witness_id, + MacosTccCanaryWitnessKind::SystemSettingsIdentity, + witnesses, + ); + witness.predecessor_parent_pid == predecessor.launcher.parent_pid + && witness.predecessor_parent_pid == Some(parent_signing.process_bound_pid) + && witness.predecessor_parent_audit_token_identity.as_deref() + == predecessor_settings + .and_then(|settings| settings.parent_audit_token_identity.as_deref()) + && witness + .predecessor_parent_audit_token_identity + .as_deref() + .and_then(audit_token_identity) + .is_some_and(|identity| Some(identity.pid) == witness.predecessor_parent_pid) + && witness.predecessor_parent_process_fingerprint.as_deref() + == Some(parent_signing.process_bound_fingerprint.as_str()) + && witness.predecessor_parent_exit_observed == Some(true) +} + +fn validate_login_arbitration( + receipt: &MacosTccCanaryReceipt, + witnesses: &BTreeMap<&str, &MacosTccCanaryWitness>, + missing: &mut BTreeSet, +) { + if receipt.installation_scenario.needs_repeated_login_proof() { + if !login_arbitration_witness(receipt, witnesses) + .is_some_and(|witness| login_arbitration_witness_is_valid(receipt, witness)) + { + missing.insert(format!("{}_login_arbitration_witness", receipt.row_id)); + } + } else if receipt.login_arbitration_witness_id.is_some() { + missing.insert(format!("{}_unexpected_login_arbitration", receipt.row_id)); + } +} + +fn login_arbitration_witness<'a>( + receipt: &MacosTccCanaryReceipt, + witnesses: &BTreeMap<&'a str, &'a MacosTccCanaryWitness>, +) -> Option<&'a MacosTccCanaryWitness> { + receipt + .login_arbitration_witness_id + .as_deref() + .and_then(|witness_id| { + matching_witness( + receipt, + witness_id, + MacosTccCanaryWitnessKind::LoginArbitration, + witnesses, + ) + }) +} + +fn login_arbitration_witness_is_valid( + receipt: &MacosTccCanaryReceipt, + witness: &MacosTccCanaryWitness, +) -> bool { + let scenario = receipt.installation_scenario; + let expected_installed = scenario.installed_topologies(); + let expected_losers = expected_installed + .iter() + .copied() + .filter(|owner| *owner != receipt.topology) + .collect::>(); + witness + .installed_topologies + .as_deref() + .is_some_and(|installed| topology_sets_equal(installed, expected_installed)) + && witness + .enable_order + .as_deref() + .is_some_and(|order| scenario.enable_order_is_valid(order)) + && witness.selected_topology == Some(receipt.topology) + && witness + .losing_topologies + .as_deref() + .is_some_and(|losers| topology_sets_equal(losers, &expected_losers)) + && witness.owner_conflict_observed == Some(true) + && witness.login_iteration == Some(receipt.login_iteration) + && witness + .login_session_id + .as_deref() + .is_some_and(|id| validate_identifier(id, "login_session_id").is_ok()) + && witness.observed_unix_ms <= receipt.process_started_unix_ms +} + +fn topology_sets_equal(left: &[MacosDaemonOwner], right: &[MacosDaemonOwner]) -> bool { + left.len() == right.len() + && left + .iter() + .map(|owner| topology_key(*owner)) + .collect::>() + == right + .iter() + .map(|owner| topology_key(*owner)) + .collect::>() +} + +fn scored_capability_shape_is_valid( + scored: MacosTccCanaryCapability, + evidence: &BTreeSet, +) -> bool { + if scored == MacosTccCanaryCapability::Stream { + *evidence + == BTreeSet::from([ + MacosTccCanaryCapability::Picker, + MacosTccCanaryCapability::Stream, + ]) + } else { + *evidence == BTreeSet::from([scored]) + } +} + +fn topology_capability_qualifies( + receipts: &[MacosTccCanaryReceipt], + witnesses: &BTreeMap<&str, &MacosTccCanaryWitness>, + topology: MacosDaemonOwner, + capability: MacosTccCanaryCapability, +) -> bool { + platform_cells() + .into_iter() + .all(|(architecture, os_family)| { + let phases_qualify = capability_phases(capability) + .iter() + .chain(topology_phases(topology)) + .copied() + .all(|phase| { + receipts.iter().any(|receipt| { + receipt.topology == topology + && receipt.scored_capability == capability + && receipt.lifecycle_phase == phase + && platform_cell_matches(receipt, architecture) + && macos_os_family(&receipt.os_version) == Some(os_family) + && receipt.capabilities.iter().any(|evidence| { + evidence.capability == capability + && evidence.outcome == expected_outcome(phase) + }) + }) + }); + let fresh_qualifies = receipts.iter().any(|receipt| { + receipt.topology == topology + && receipt.scored_capability == capability + && platform_cell_matches(receipt, architecture) + && macos_os_family(&receipt.os_version) == Some(os_family) + && receipt.capabilities.iter().any(|evidence| { + evidence.capability == capability + && evidence.outcome == MacosTccCanaryOutcome::Passed + }) + && receipt + .fresh_tcc_reset_witness_id + .as_deref() + .and_then(|witness_id| { + matching_witness( + receipt, + witness_id, + MacosTccCanaryWitnessKind::FreshTccReset, + witnesses, + ) + }) + .is_some_and(|witness| witness.fresh_tcc_database_observed == Some(true)) + }); + phases_qualify && fresh_qualifies + }) +} + +const fn platform_cells() -> [(&'static str, &'static str); 4] { + [ + ("apple_silicon", "sequoia_15_2"), + ("apple_silicon", "tahoe_26"), + ("intel", "sequoia_15_2"), + ("intel", "tahoe_26"), + ] +} + +fn architecture_evidence_is_coherent(receipt: &MacosTccCanaryReceipt) -> bool { + matches!( + ( + receipt.host_architecture.as_str(), + receipt.executable_slice.as_str(), + receipt.translated_process, + ), + ("apple_silicon", "aarch64", false) + | ("apple_silicon", "x86_64", true) + | ("intel", "x86_64", false) + ) +} + +fn platform_cell_matches(receipt: &MacosTccCanaryReceipt, architecture: &str) -> bool { + match architecture { + "apple_silicon" => { + receipt.host_architecture == "apple_silicon" + && receipt.executable_slice == "aarch64" + && !receipt.translated_process + } + "intel" => { + receipt.host_architecture == "intel" + && receipt.executable_slice == "x86_64" + && !receipt.translated_process + } + _ => false, + } +} + +fn capability_phases( + capability: MacosTccCanaryCapability, +) -> &'static [MacosTccCanaryLifecyclePhase] { + use MacosTccCanaryLifecyclePhase::{ + Deny, Grant, GrantAfterRevocation, LaterGrant, RevokeWhileLive, + }; + + match capability { + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Stream => &[ + Grant, + Deny, + LaterGrant, + RevokeWhileLive, + GrantAfterRevocation, + ], + MacosTccCanaryCapability::Pointer => &[Grant], + MacosTccCanaryCapability::Picker => &[Grant, Deny, LaterGrant], + } +} + +fn topology_phases(topology: MacosDaemonOwner) -> &'static [MacosTccCanaryLifecyclePhase] { + use MacosTccCanaryLifecyclePhase::{ + AppLaunch, AppRelaunch, LoginStart, OwnerRestart, ServiceInstall, ServiceRestart, + SignedUpdate, + }; + + match topology { + MacosDaemonOwner::AppSidecar => &[AppLaunch, OwnerRestart, AppRelaunch, SignedUpdate], + MacosDaemonOwner::DirectLaunchd | MacosDaemonOwner::Homebrew => { + &[ServiceInstall, LoginStart, ServiceRestart, SignedUpdate] + } + MacosDaemonOwner::Standalone => &[SignedUpdate], + } +} + +const fn expected_launcher_action( + topology: MacosDaemonOwner, + phase: MacosTccCanaryLifecyclePhase, +) -> Option<&'static str> { + use MacosTccCanaryLifecyclePhase::{ + AppLaunch, AppRelaunch, GrantAfterRevocation, LaterGrant, LoginStart, OwnerRestart, + ServiceInstall, ServiceRestart, SignedUpdate, + }; + + match (topology, phase) { + (MacosDaemonOwner::AppSidecar, AppLaunch) => Some("app_minimized_launch"), + (MacosDaemonOwner::AppSidecar, OwnerRestart) => Some("app_supervisor_daemon_restart"), + (MacosDaemonOwner::AppSidecar, AppRelaunch) => Some("app_quit_then_minimized_launch"), + (MacosDaemonOwner::AppSidecar, LaterGrant | GrantAfterRevocation) => { + Some("app_supervisor_daemon_restart_after_authorization") + } + (MacosDaemonOwner::AppSidecar, SignedUpdate) => Some("signed_app_update_then_app_relaunch"), + (MacosDaemonOwner::DirectLaunchd, ServiceInstall) => Some("hypercolor_service_enable"), + (MacosDaemonOwner::DirectLaunchd, LoginStart) => Some("launchd_login_start"), + (MacosDaemonOwner::DirectLaunchd, ServiceRestart) => Some("hypercolor_service_restart"), + (MacosDaemonOwner::DirectLaunchd, LaterGrant | GrantAfterRevocation) => { + Some("hypercolor_service_restart_after_authorization") + } + (MacosDaemonOwner::DirectLaunchd, SignedUpdate) => { + Some("signed_daemon_update_then_hypercolor_service_restart") + } + (MacosDaemonOwner::Homebrew, ServiceInstall) => Some("brew_services_start"), + (MacosDaemonOwner::Homebrew, LoginStart) => Some("brew_services_login_start"), + (MacosDaemonOwner::Homebrew, ServiceRestart) => Some("brew_services_restart"), + (MacosDaemonOwner::Homebrew, LaterGrant | GrantAfterRevocation) => { + Some("brew_services_restart_after_authorization") + } + (MacosDaemonOwner::Homebrew, SignedUpdate) => { + Some("signed_daemon_update_then_brew_services_restart") + } + (MacosDaemonOwner::Standalone, LaterGrant | GrantAfterRevocation) => { + Some("terminal_successor_launch_after_authorization") + } + (MacosDaemonOwner::Standalone, SignedUpdate) => { + Some("signed_daemon_update_then_terminal_launch") + } + _ => None, + } +} + +const fn expected_outcome(phase: MacosTccCanaryLifecyclePhase) -> MacosTccCanaryOutcome { + match phase { + MacosTccCanaryLifecyclePhase::Deny => MacosTccCanaryOutcome::Denied, + MacosTccCanaryLifecyclePhase::RevokeWhileLive => MacosTccCanaryOutcome::Revoked, + MacosTccCanaryLifecyclePhase::Grant + | MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation + | MacosTccCanaryLifecyclePhase::AppLaunch + | MacosTccCanaryLifecyclePhase::OwnerRestart + | MacosTccCanaryLifecyclePhase::AppRelaunch + | MacosTccCanaryLifecyclePhase::ServiceInstall + | MacosTccCanaryLifecyclePhase::LoginStart + | MacosTccCanaryLifecyclePhase::ServiceRestart + | MacosTccCanaryLifecyclePhase::SignedUpdate => MacosTccCanaryOutcome::Passed, + } +} + +fn macos_os_family(version: &str) -> Option<&'static str> { + let mut components = version.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next().unwrap_or("0").parse::().ok()?; + match (major, minor) { + (15, minor) if minor >= 2 => Some("sequoia_15_2"), + (26, _) => Some("tahoe_26"), + _ => None, + } +} + +fn validate_identifier(value: &str, label: &str) -> Result<()> { + anyhow::ensure!( + !value.is_empty() + && value.len() <= 128 + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')), + "{label} must be 1 through 128 ASCII identifier characters" + ); + Ok(()) +} + +fn validate_observed_text(value: &str, label: &str) -> Result<()> { + anyhow::ensure!( + !value.is_empty() && value.len() <= 1_024 && !value.contains('\0'), + "{label} must be 1 through 1024 non-NUL bytes" + ); + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + is_hex_with_length(value, &[64]) +} + +fn is_hex_with_length(value: &str, lengths: &[usize]) -> bool { + lengths.contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn open_regular_file(path: &Path) -> Result<(File, fs::Metadata)> { + let path_metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect {}", path.display()))?; + anyhow::ensure!( + path_metadata.file_type().is_file() && !path_metadata.file_type().is_symlink(), + "{} must be a regular non-symlink file", + path.display() + ); + let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let file_metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened file {}", path.display()))?; + anyhow::ensure!( + file_metadata.file_type().is_file() + && path_metadata.dev() == file_metadata.dev() + && path_metadata.ino() == file_metadata.ino(), + "{} changed while it was being opened", + path.display() + ); + Ok((file, file_metadata)) +} + +fn witness_evidence_matches(receipt_dir: &Path, witness: &MacosTccCanaryWitness) -> Result { + anyhow::ensure!( + is_sha256(&witness.evidence_sha256), + "witness evidence hash is not lowercase SHA-256" + ); + let path = receipt_dir + .join("evidence") + .join(format!("{}.bin", witness.evidence_sha256)); + let (mut file, metadata) = open_regular_file(&path)?; + anyhow::ensure!( + metadata.len() <= MAX_WITNESS_EVIDENCE_BYTES, + "witness evidence exceeds {MAX_WITNESS_EVIDENCE_BYTES} bytes" + ); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 16 * 1024]; + let mut remaining = MAX_WITNESS_EVIDENCE_BYTES.saturating_add(1); + while remaining > 0 { + let read_limit = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("bounded evidence buffer length fits usize"); + let read = file + .read(&mut buffer[..read_limit]) + .with_context(|| format!("failed to read witness evidence {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + remaining = remaining.saturating_sub(u64::try_from(read).unwrap_or(u64::MAX)); + } + anyhow::ensure!(remaining > 0, "witness evidence exceeds the read bound"); + Ok(hex_bytes(&hasher.finalize()) == witness.evidence_sha256) +} + +fn read_json_bounded(path: &Path, maximum: u64) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let (file, metadata) = open_regular_file(path)?; + anyhow::ensure!(metadata.len() <= maximum, "{} is too large", path.display()); + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0)); + file.take(maximum.saturating_add(1)) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {}", path.display()))?; + anyhow::ensure!( + bytes.len() as u64 <= maximum, + "{} is too large", + path.display() + ); + serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display())) +} + +fn ensure_real_directory(path: &Path, create: bool) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "macOS TCC canary directory must be a real directory: {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => { + fs::create_dir(path).with_context(|| format!("failed to create {}", path.display()))?; + } + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())); + } + } + Ok(()) +} + +fn ensure_existing_real_directory(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "macOS TCC canary directory must be a real directory: {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())); + } + } + Ok(()) +} + +fn ensure_canary_descendant_directory(root: &Path, directory: &Path) -> Result<()> { + ensure_real_directory(root, false)?; + let relative = directory.strip_prefix(root).with_context(|| { + format!( + "macOS TCC canary directory {} escapes {}", + directory.display(), + root.display() + ) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(component) = component else { + anyhow::bail!( + "macOS TCC canary descendant contains traversal: {}", + directory.display() + ); + }; + current.push(component); + ensure_real_directory(¤t, true)?; + } + Ok(()) +} + +fn write_json_new(path: &Path, value: &T) -> Result<()> +where + T: Serialize + ?Sized, +{ + let bytes = + serde_json::to_vec_pretty(value).context("failed to encode macOS TCC canary JSON")?; + write_bytes_new(path, &[bytes.as_slice(), b"\n"].concat()) +} + +fn write_bytes_new(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .context("macOS TCC canary JSON path has no parent")?; + ensure_real_directory(parent, false)?; + anyhow::ensure!(!path.exists(), "refusing to overwrite {}", path.display()); + let mut temporary = tempfile::Builder::new() + .prefix(".macos-tcc-canary-") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("failed to create temporary file in {}", parent.display()))?; + temporary + .write_all(bytes) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("failed to write temporary JSON for {}", path.display()))?; + anyhow::ensure!(!path.exists(), "refusing to overwrite {}", path.display()); + temporary + .persist_noclobber(path) + .map_err(|error| error.error) + .with_context(|| format!("failed to atomically publish {}", path.display()))?; + sync_parent(parent) +} + +fn sync_parent(path: &Path) -> Result<()> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync {}", path.display())) +} + +#[cfg(all(test, feature = "screen-capture"))] +mod tests { + use super::*; + + #[test] + fn compact_entitlement_plist_preserves_exact_true_keys() { + let xml = concat!( + "", + "com.apple.security.device.usb", + "com.apple.security.cs.allow-jit", + "" + ); + + assert_eq!( + plist_true_keys(xml).expect("compact entitlement plist should parse"), + [ + "com.apple.security.cs.allow-jit".to_owned(), + "com.apple.security.device.usb".to_owned(), + ] + ); + } + + #[test] + fn entitlement_plist_rejects_non_true_values() { + assert!(plist_true_keys("unsafe").is_err()); + } + + #[test] + fn dynamic_codesign_verification_uses_the_nonverbose_live_pid_form() { + assert_eq!( + dynamic_codesign_verification_args("+42"), + ["--verify", "+42"] + ); + } +} diff --git a/crates/hypercolor-daemon/src/main.rs b/crates/hypercolor-daemon/src/main.rs index d433e7378..de5f39547 100644 --- a/crates/hypercolor-daemon/src/main.rs +++ b/crates/hypercolor-daemon/src/main.rs @@ -5,12 +5,40 @@ // the GUI shell path also stays clean without forcing GUI subsystem here. use anyhow::{Context, Result}; +#[cfg(target_os = "macos")] +use clap::{CommandFactory, FromArgMatches, parser::ValueSource}; 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, MacosDaemonSessionAttestation, + MacosOwnerCoordinatorOutcome, MacosOwnerIdentity, MacosOwnerRecord, MacosOwnerRecoveryRequired, + MacosOwnerStore, MacosOwnerStoreError, 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")] +mod macos_launcher_authority; + +#[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; @@ -19,6 +47,51 @@ mod windows_service; #[derive(Parser, Debug)] #[command(name = "hypercolor-daemon", about = "Hypercolor lighting daemon")] struct DaemonArgs { + /// Arm one signed macOS TCC canary row for the next matching daemon owner. + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + #[arg( + long, + hide = true, + value_name = "REQUEST_JSON", + conflicts_with = "macos_tcc_canary_validate" + )] + macos_tcc_canary_arm: Option, + + /// Validate a directory of signed macOS TCC canary receipts. + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + #[arg( + long, + hide = true, + value_name = "RECEIPT_DIR", + conflicts_with = "macos_tcc_canary_arm" + )] + macos_tcc_canary_validate: Option, + + /// Validate one macOS TCC canary request without arming it. + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + #[arg( + long, + hide = true, + value_name = "REQUEST_JSON", + conflicts_with_all = ["macos_tcc_canary_arm", "macos_tcc_canary_validate"] + )] + macos_tcc_canary_check_request: Option, + + /// Atomically publish one bounded macOS TCC canary artifact. + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + #[arg( + long, + hide = true, + value_names = ["CANARY_ROOT", "SOURCE", "DESTINATION"], + num_args = 3, + conflicts_with_all = [ + "macos_tcc_canary_arm", + "macos_tcc_canary_validate", + "macos_tcc_canary_check_request" + ] + )] + macos_tcc_canary_publish: Option>, + /// Path to the configuration file. #[arg(short, long)] config: Option, @@ -55,6 +128,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,10 +151,76 @@ 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, + macos_daemon_session_attestation: 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, } } } +#[cfg(target_os = "macos")] +impl From for MacosDaemonOwnerArg { + fn from(value: MacosDaemonOwner) -> Self { + match value { + MacosDaemonOwner::AppSidecar => Self::AppSidecar, + MacosDaemonOwner::DirectLaunchd => Self::DirectLaunchd, + MacosDaemonOwner::Homebrew => Self::Homebrew, + MacosDaemonOwner::Standalone => Self::Standalone, + } + } +} + +#[cfg(target_os = "macos")] +impl MacosDaemonOwnerArg { + const fn is_app_sidecar(self) -> bool { + matches!(self, Self::AppSidecar) + } +} + +#[cfg(target_os = "macos")] +fn configure_macos_activation_policy(owner: MacosDaemonOwnerArg) -> Result<()> { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; + + if !owner.is_app_sidecar() { + return Ok(()); + } + + let main_thread = + MainThreadMarker::new().context("daemon entrypoint is not on the main thread")?; + let application = NSApplication::sharedApplication(main_thread); + anyhow::ensure!( + application.setActivationPolicy(NSApplicationActivationPolicy::Prohibited), + "failed to suppress the app-sidecar daemon Dock icon" + ); + Ok(()) +} + #[derive(Clone, Copy, Debug, ValueEnum)] enum RenderAccelerationModeArg { Cpu, @@ -112,26 +256,598 @@ impl From for ServoGpuImportMode { } fn main() -> Result<()> { + #[cfg(target_os = "macos")] + let (mut args, macos_owner_argument) = { + let matches = DaemonArgs::command().get_matches(); + let argument_was_supplied = + matches.value_source("macos_owner") == Some(ValueSource::CommandLine); + let args = DaemonArgs::from_arg_matches(&matches) + .context("failed to parse daemon command-line arguments")?; + let argument = argument_was_supplied.then_some(args.macos_owner.into()); + (args, argument) + }; + #[cfg(not(target_os = "macos"))] let args = DaemonArgs::parse(); + #[cfg(target_os = "macos")] + let macos_daemon_executable = + std::env::current_exe().context("failed to resolve the current daemon executable")?; + #[cfg(target_os = "macos")] + let macos_daemon_requirement = designated_requirement(&macos_daemon_executable)?; + #[cfg(target_os = "macos")] + { + let evidence = macos_launcher_authority::inspect_macos_launcher_authority( + &macos_daemon_executable, + &macos_daemon_requirement, + )?; + let owner = macos_launcher_authority::resolve_macos_launcher_owner( + std::env::var_os(macos_launcher_authority::MACOS_OWNER_ENV).as_deref(), + macos_owner_argument, + evidence, + )?; + args.macos_owner = owner.into(); + } + #[cfg(target_os = "macos")] + configure_macos_activation_policy(args.macos_owner)?; + #[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(all(target_os = "macos", feature = "macos-tcc-canary"))] + if let Some(request_path) = args.macos_tcc_canary_arm.as_deref() { + let path = hypercolor_daemon::macos_tcc_canary::arm_macos_tcc_canary( + &ConfigManager::data_dir(), + request_path, + )?; + println!("macos_tcc_canary_armed={}", path.display()); + return Ok(()); + } + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + if let Some(receipt_dir) = args.macos_tcc_canary_validate.as_deref() { + let validation = + hypercolor_daemon::macos_tcc_canary::validate_macos_tcc_canary_receipts(receipt_dir)?; + println!("{}", serde_json::to_string_pretty(&validation)?); + if !validation.preferred_topology_eligible { + std::process::exit(1); + } + return Ok(()); + } + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + if let Some(request_path) = args.macos_tcc_canary_check_request.as_deref() { + hypercolor_daemon::macos_tcc_canary::validate_macos_tcc_canary_request(request_path)?; + println!("macos_tcc_canary_request_valid={}", request_path.display()); + return Ok(()); + } + #[cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + if let Some(paths) = args.macos_tcc_canary_publish.as_deref() { + let [canary_root, source, destination] = paths else { + anyhow::bail!("macOS TCC canary artifact publication requires exactly three paths"); + }; + hypercolor_daemon::macos_tcc_canary::publish_macos_tcc_canary_artifact( + canary_root, + source, + destination, + )?; + println!("macos_tcc_canary_artifact={}", destination.display()); + return Ok(()); + } + #[cfg(target_os = "macos")] + let macos_owner_identity = + current_macos_owner_identity(&macos_daemon_executable, &macos_daemon_requirement)?; + #[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 macos_owner_record = publish_macos_owner( + &macos_owner_store, + &macos_instance_guard, + macos_owner, + macos_owner_identity, + )?; + #[cfg(target_os = "macos")] + let mut owner_snapshot = macos_owner_record.snapshot(); + #[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(all(target_os = "macos", feature = "macos-tcc-canary"))] + if hypercolor_daemon::macos_tcc_canary::run_armed_macos_tcc_canary( + &ConfigManager::data_dir(), + macos_owner, + )? { + return Ok(()); + } #[cfg(target_os = "windows")] if args.windows_service { return windows_service::run(args.into_run_options()); } + let mut options = args.into_run_options(); + #[cfg(target_os = "macos")] + { + options.macos_owner_snapshot = Some(owner_snapshot); + } + #[cfg(target_os = "macos")] + { + let runtime = daemon::build_main_runtime()?; + let (prepared, authority) = prepare_macos_daemon_with_session( + &runtime, + options, + macos_owner_store, + macos_owner_record, + macos_instance_guard, + )?; + let result = run_prepared_macos_daemon(runtime, prepared); + finish_macos_daemon_run(result, authority) + } + #[cfg(not(target_os = "macos"))] + { + run_daemon(options) + } +} + +#[cfg(target_os = "macos")] +fn prepare_macos_daemon_with_session( + runtime: &tokio::runtime::Runtime, + options: DaemonRunOptions, + store: MacosOwnerStore, + owner_record: MacosOwnerRecord, + instance_guard: MacosDaemonGuard, +) -> Result<(daemon::PreparedDaemon, MacosDaemonRuntimeAuthority)> { + let mut prepared = runtime.block_on(daemon::prepare(options))?; + let listener_lease = prepared.take_api_listener_lease()?; + let mut authority = + MacosDaemonRuntimeAuthority::new(store, owner_record, instance_guard, listener_lease); + let attestation = authority + .publish_session() + .context("failed to publish the private macOS daemon session")?; + prepared.install_macos_daemon_session_attestation(attestation.clone()); + Ok((prepared, authority)) +} + +#[cfg(all(target_os = "macos", test))] +fn prepare_then_publish( + prepare: impl FnOnce() -> Result, + publish: impl FnOnce() -> Result, +) -> Result<(Prepared, Published)> { + let prepared = prepare()?; + let published = publish()?; + Ok((prepared, published)) +} + +#[cfg(target_os = "macos")] +struct MacosDaemonRuntimeAuthority { + store: MacosOwnerStore, + owner_record: MacosOwnerRecord, + attestation: Option, + instance_guard: Option, + listener_lease: Option, + session_clear_finished: bool, +} + +#[cfg(target_os = "macos")] +impl MacosDaemonRuntimeAuthority { + fn new( + store: MacosOwnerStore, + owner_record: MacosOwnerRecord, + instance_guard: MacosDaemonGuard, + listener_lease: daemon::ApiListenerLease, + ) -> Self { + Self { + store, + owner_record, + attestation: None, + instance_guard: Some(instance_guard), + listener_lease: Some(listener_lease), + session_clear_finished: false, + } + } + + fn publish_session(&mut self) -> Result { + let attestation = self.store.publish_daemon_session_attestation( + self.instance_guard + .as_ref() + .expect("runtime authority must retain its canonical guard"), + &self.owner_record.incarnation(), + )?; + self.attestation = Some(attestation.clone()); + Ok(attestation) + } + + fn clear_session(&mut self) -> Result { + let incarnation = self.owner_record.incarnation(); + let attestation = match self.attestation.clone() { + Some(attestation) => Some(attestation), + None => self + .store + .load_daemon_session_attestation()? + .filter(|attestation| attestation.owner_incarnation() == incarnation), + }; + let result = attestation.map_or(Ok(false), |attestation| { + self.store + .clear_daemon_session_attestation(&incarnation, &attestation.server_session_id) + }); + if result.is_ok() { + self.session_clear_finished = true; + } + result + } +} + +#[cfg(target_os = "macos")] +impl Drop for MacosDaemonRuntimeAuthority { + fn drop(&mut self) { + if !self.session_clear_finished + && let Err(error) = self.clear_session() + { + eprintln!( + "failed to clear the private macOS daemon session during authority release: {error}" + ); + } + drop(self.instance_guard.take()); + drop(self.listener_lease.take()); + } +} + +#[cfg(target_os = "macos")] +fn finish_macos_daemon_run( + daemon_result: Result<()>, + mut authority: MacosDaemonRuntimeAuthority, +) -> Result<()> { + let cleanup_result = authority.clear_session(); + combine_macos_daemon_result(daemon_result, cleanup_result) +} + +#[cfg(target_os = "macos")] +fn combine_macos_daemon_result( + daemon_result: Result<()>, + cleanup_result: Result, +) -> Result<()> { + match (daemon_result, cleanup_result) { + (Err(daemon_error), Err(cleanup_error)) => { + eprintln!( + "failed to clear the private macOS daemon session after daemon failure: {cleanup_error}" + ); + Err(daemon_error) + } + (Err(daemon_error), Ok(_)) => Err(daemon_error), + (Ok(()), Ok(_)) => Ok(()), + (Ok(()), Err(cleanup_error)) => { + Err(cleanup_error).context("failed to clear the private macOS daemon session") + } + } +} + +#[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) +} + +#[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( + executable_path: &std::path::Path, + requirement: &str, +) -> Result { + 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"))] +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_prepared_macos_daemon( + runtime: tokio::runtime::Runtime, + prepared: daemon::PreparedDaemon, +) -> 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 = runtime.block_on(async move { + let shutdown_rx = install_signal_handlers(); + prepared.run(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")] { @@ -154,7 +870,21 @@ mod tests { use super::{ DaemonArgs, RenderAccelerationModeArg, ServoGpuImportModeArg, daemon_instance_name, }; + #[cfg(target_os = "macos")] + use super::{ + MacosDaemonOwnerArg, MacosDaemonRuntimeAuthority, MacosOwnerContention, + arbitrate_macos_owner_contention_with, combine_macos_daemon_result, + launchd_contender_exits_zero, macos_contender_exit_code, parse_designated_requirement, + prepare_then_publish, + }; + #[cfg(target_os = "macos")] + use hypercolor_daemon::macos_owner::{ + MacosDaemonOwner, MacosOwnerIdentity, MacosOwnerStore, MacosOwnerStoreError, + 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() { @@ -226,6 +956,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!( @@ -242,6 +1210,138 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn only_the_app_sidecar_uses_background_activation_policy() { + assert!(MacosDaemonOwnerArg::AppSidecar.is_app_sidecar()); + assert!(!MacosDaemonOwnerArg::DirectLaunchd.is_app_sidecar()); + assert!(!MacosDaemonOwnerArg::Homebrew.is_app_sidecar()); + assert!(!MacosDaemonOwnerArg::Standalone.is_app_sidecar()); + } + + #[cfg(target_os = "macos")] + #[test] + fn daemon_error_remains_primary_when_session_cleanup_also_fails() { + let daemon_error = combine_macos_daemon_result( + Err(anyhow::anyhow!("daemon failed")), + Err(MacosOwnerStoreError::MissingOwnerRecord), + ) + .expect_err("daemon and cleanup failure should remain an error"); + assert_eq!(daemon_error.to_string(), "daemon failed"); + + let cleanup_error = + combine_macos_daemon_result(Ok(()), Err(MacosOwnerStoreError::MissingOwnerRecord)) + .expect_err("cleanup failure after success should be returned"); + assert!( + cleanup_error + .to_string() + .contains("failed to clear the private macOS daemon session") + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_listener_attestation_occupied_port_prevents_publication() { + let occupied = std::net::TcpListener::bind("127.0.0.1:0") + .expect("fixture should pre-bind a loopback port"); + let occupied_address = occupied + .local_addr() + .expect("occupied address should resolve"); + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard acquisition should succeed") + .expect("fixture should win the guard"); + let store = MacosOwnerStore::new(directory.path().join("store")); + let record = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + owner_identity(MacosDaemonOwner::AppSidecar, std::process::id()), + ) + .expect("owner record should publish"); + let publication_count = std::cell::Cell::new(0_u32); + + let error = prepare_then_publish( + || super::daemon::bind_api_listener(occupied_address), + || { + publication_count.set(publication_count.get() + 1); + Ok(store.publish_daemon_session_attestation(&guard, &record.incarnation())?) + }, + ) + .expect_err("occupied final API port must fail preparation"); + + assert_eq!(publication_count.get(), 0); + assert_eq!( + error + .downcast_ref::() + .map(std::io::Error::kind), + Some(std::io::ErrorKind::AddrInUse) + ); + assert!( + store + .load_daemon_session_attestation() + .expect("session state should load") + .is_none() + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_runtime_authority_unwind_clears_visible_session_before_release() { + let runtime = super::daemon::build_main_runtime().expect("runtime should build"); + let mut prepared = runtime + .block_on(super::daemon::prepare(super::DaemonRunOptions { + bind: Some("127.0.0.1:0".to_owned()), + ..super::DaemonRunOptions::default() + })) + .expect("daemon should prepare its final listener"); + let address = prepared.advertised_bind(); + let listener_lease = prepared + .take_api_listener_lease() + .expect("listener lease should transfer once"); + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard acquisition should succeed") + .expect("fixture should win the guard"); + let store = MacosOwnerStore::new(directory.path().join("store")); + let record = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + owner_identity(MacosDaemonOwner::AppSidecar, std::process::id()), + ) + .expect("owner record should publish"); + store + .publish_daemon_session_attestation(&guard, &record.incarnation()) + .expect("visible session fixture should publish before authority recovery"); + drop(prepared); + let _runtime_context = runtime.enter(); + + super::daemon::bind_api_listener(address) + .expect_err("listener lease must block takeover before authority release"); + let authority = + MacosDaemonRuntimeAuthority::new(store.clone(), record, guard, listener_lease); + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _authority = authority; + panic!("exercise runtime authority unwind"); + })); + assert!(unwind.is_err()); + assert!( + store + .load_daemon_session_attestation() + .expect("session state should load") + .is_none() + ); + + let rebound = super::daemon::bind_api_listener(address) + .expect("port should rebind only after session, guard, and lease release"); + drop(rebound); + let reacquired = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard inspection should succeed") + .expect("canonical guard should release before port takeover"); + drop(reacquired); + } + #[test] fn daemon_instance_name_is_stable() { let name = daemon_instance_name(); 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/performance.rs b/crates/hypercolor-daemon/src/performance.rs index 1d86830c6..ca21b59d5 100644 --- a/crates/hypercolor-daemon/src/performance.rs +++ b/crates/hypercolor-daemon/src/performance.rs @@ -4,6 +4,8 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; const FRAME_HISTORY_CAPACITY: usize = 120; +const LATENCY_BUCKET_WIDTH_US: u32 = 100; +const LATENCY_BUCKET_COUNT: usize = 4096; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) enum CompositorBackendKind { @@ -93,6 +95,7 @@ pub(crate) struct FrameTimeline { )] pub(crate) struct LatestFrameMetrics { pub timestamp_ms: u32, + pub input_sampled: bool, pub input_us: u32, /// Time finalizing the *previous* frame's deferred GPU zone readback, /// which runs before composition starts. Reported separately because it @@ -271,9 +274,116 @@ pub(crate) struct PerformanceSnapshot { pub latest_frame: Option, pub frame_count: u32, pub frame_time: FrameTimeSummary, + pub input_time: FrameTimeSummary, + pub input_time_sample_count: u64, pub delivered_fps: f64, pub pacing: PacingSummary, pub effect_health: EffectHealthSummary, + pub full_frame_copy_count_total: u64, + pub full_frame_copy_frames_total: u64, + pub full_frame_copy_bytes_total: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LatencyHistogramBucketSnapshot { + pub bucket_index: u32, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LatencyHistogramSnapshot { + pub bucket_width_us: u32, + pub overflow_bucket_index: u32, + pub buckets: Vec, +} + +#[derive(Debug)] +struct LatencyHistogram { + buckets: Box<[u64]>, + samples: u64, + total_us: u64, + max_us: u32, +} + +impl Default for LatencyHistogram { + fn default() -> Self { + Self { + buckets: vec![0; LATENCY_BUCKET_COUNT + 1].into_boxed_slice(), + samples: 0, + total_us: 0, + max_us: 0, + } + } +} + +impl LatencyHistogram { + fn record(&mut self, sample_us: u32) { + let bucket_upper_bound = if sample_us == 0 { + 0 + } else { + sample_us + .saturating_sub(1) + .checked_div(LATENCY_BUCKET_WIDTH_US) + .unwrap_or_default() + .saturating_add(1) + }; + let bucket = usize::try_from(bucket_upper_bound) + .unwrap_or(usize::MAX) + .min(LATENCY_BUCKET_COUNT); + self.buckets[bucket] = self.buckets[bucket].saturating_add(1); + self.samples = self.samples.saturating_add(1); + self.total_us = self.total_us.saturating_add(u64::from(sample_us)); + self.max_us = self.max_us.max(sample_us); + } + + fn summary(&self) -> FrameTimeSummary { + if self.samples == 0 { + return FrameTimeSummary::default(); + } + let average_us = self.total_us.saturating_add(self.samples / 2) / self.samples; + FrameTimeSummary { + avg_ms: micros_to_ms(average_us), + p95_ms: micros_to_ms(u64::from(self.percentile_upper_bound_us(95))), + p99_ms: micros_to_ms(u64::from(self.percentile_upper_bound_us(99))), + max_ms: micros_to_ms(u64::from(self.max_us)), + } + } + + fn percentile_upper_bound_us(&self, percentile: u64) -> u32 { + let rank = self.samples.saturating_mul(percentile).saturating_add(99) / 100; + let mut observed = 0_u64; + for (index, count) in self.buckets.iter().copied().enumerate() { + observed = observed.saturating_add(count); + if observed >= rank { + if index == LATENCY_BUCKET_COUNT { + return self.max_us; + } + return u32::try_from(index) + .unwrap_or(u32::MAX) + .saturating_mul(LATENCY_BUCKET_WIDTH_US) + .min(self.max_us); + } + } + self.max_us + } + + fn snapshot(&self) -> LatencyHistogramSnapshot { + LatencyHistogramSnapshot { + bucket_width_us: LATENCY_BUCKET_WIDTH_US, + overflow_bucket_index: u32::try_from(LATENCY_BUCKET_COUNT).unwrap_or(u32::MAX), + buckets: self + .buckets + .iter() + .copied() + .enumerate() + .filter(|(_, count)| *count > 0) + .map(|(bucket_index, count)| LatencyHistogramBucketSnapshot { + bucket_index: u32::try_from(bucket_index).unwrap_or(u32::MAX), + count, + }) + .collect(), + } + } } /// Rolling performance tracker updated by the render thread. @@ -287,8 +397,12 @@ pub struct PerformanceTracker { wake_delay_us: VecDeque, push_us: VecDeque, publish_us: VecDeque, + input_time: LatencyHistogram, pacing_history: VecDeque, effect_health: EffectHealthSummary, + full_frame_copy_count_total: u64, + full_frame_copy_frames_total: u64, + full_frame_copy_bytes_total: u64, } impl PerformanceTracker { @@ -306,6 +420,9 @@ impl PerformanceTracker { self.wake_delay_us.push_back(metrics.wake_late_us); self.push_us.push_back(metrics.push_us); self.publish_us.push_back(metrics.publish_us); + if metrics.input_sampled { + self.input_time.record(metrics.input_us); + } self.pacing_history.push_back(FramePacingSample { inputs: metrics.reused_inputs, canvas: metrics.reused_canvas, @@ -333,6 +450,15 @@ impl PerformanceTracker { .producer_gpu_readback_failures_total .saturating_add(1); } + if metrics.full_frame_copy_count > 0 { + self.full_frame_copy_frames_total = self.full_frame_copy_frames_total.saturating_add(1); + } + self.full_frame_copy_count_total = self + .full_frame_copy_count_total + .saturating_add(u64::from(metrics.full_frame_copy_count)); + self.full_frame_copy_bytes_total = self + .full_frame_copy_bytes_total + .saturating_add(u64::from(metrics.full_frame_copy_bytes)); if self.frame_times_us.len() > FRAME_HISTORY_CAPACITY { let _ = self.frame_times_us.pop_front(); @@ -377,6 +503,8 @@ impl PerformanceTracker { latest_frame: self.latest_frame, frame_count: u32::try_from(self.frame_times_us.len()).unwrap_or(u32::MAX), frame_time: summarize_frame_times(&self.frame_times_us), + input_time: self.input_time.summary(), + input_time_sample_count: self.input_time.samples, delivered_fps: delivered_fps( &self.frame_intervals_us, self.last_frame_recorded_at @@ -390,9 +518,16 @@ impl PerformanceTracker { &self.pacing_history, ), effect_health: self.effect_health, + full_frame_copy_count_total: self.full_frame_copy_count_total, + full_frame_copy_frames_total: self.full_frame_copy_frames_total, + full_frame_copy_bytes_total: self.full_frame_copy_bytes_total, } } + pub(crate) fn input_time_histogram_snapshot(&self) -> LatencyHistogramSnapshot { + self.input_time.snapshot() + } + /// Record one deduplicated effect-render failure observed by the daemon. pub(crate) fn record_effect_error(&mut self) { self.effect_health.errors_total = self.effect_health.errors_total.saturating_add(1); @@ -710,3 +845,72 @@ fn delivered_fps( fn duration_micros_u64(duration: Duration) -> u64 { u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) } + +#[cfg(test)] +mod tests { + use super::{LatencyHistogram, LatestFrameMetrics, PerformanceTracker}; + + #[test] + fn input_latency_histogram_reports_session_percentiles() { + let mut histogram = LatencyHistogram::default(); + for sample in 1..=100_u32 { + histogram.record(sample.saturating_mul(100)); + } + + let summary = histogram.summary(); + assert_eq!(summary.avg_ms, 5.05); + assert_eq!(summary.p95_ms, 9.5); + assert_eq!(summary.p99_ms, 9.9); + assert_eq!(summary.max_ms, 10.0); + } + + #[test] + fn input_latency_percentiles_never_exceed_the_observed_maximum() { + let mut histogram = LatencyHistogram::default(); + histogram.record(1); + + let summary = histogram.summary(); + assert_eq!(summary.p95_ms, 0.001); + assert_eq!(summary.p99_ms, 0.001); + assert_eq!(summary.max_ms, 0.001); + } + + #[test] + fn performance_snapshot_retains_input_and_full_frame_copy_contracts() { + let mut tracker = PerformanceTracker::default(); + tracker.record_frame(&LatestFrameMetrics { + input_sampled: true, + input_us: 740, + full_frame_copy_count: 2, + full_frame_copy_bytes: 4096, + ..LatestFrameMetrics::default() + }); + tracker.record_frame(&LatestFrameMetrics { + input_sampled: true, + input_us: 980, + ..LatestFrameMetrics::default() + }); + tracker.record_frame(&LatestFrameMetrics::default()); + tracker.clear_frame_timings(); + + let snapshot = tracker.snapshot(); + let histogram = tracker.input_time_histogram_snapshot(); + assert_eq!(snapshot.frame_count, 0); + assert_eq!(snapshot.input_time.p95_ms, 0.98); + assert_eq!(snapshot.input_time.p99_ms, 0.98); + assert_eq!(snapshot.input_time_sample_count, 2); + assert_eq!(snapshot.full_frame_copy_count_total, 2); + assert_eq!(snapshot.full_frame_copy_frames_total, 1); + assert_eq!(snapshot.full_frame_copy_bytes_total, 4096); + assert_eq!(histogram.bucket_width_us, 100); + assert_eq!(histogram.overflow_bucket_index, 4096); + assert_eq!( + histogram + .buckets + .iter() + .map(|bucket| (bucket.bucket_index, bucket.count)) + .collect::>(), + vec![(8, 1), (10, 1)] + ); + } +} 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/capture_demand.rs b/crates/hypercolor-daemon/src/render_thread/capture_demand.rs index cf815d65b..2b387f4f2 100644 --- a/crates/hypercolor-daemon/src/render_thread/capture_demand.rs +++ b/crates/hypercolor-daemon/src/render_thread/capture_demand.rs @@ -1,6 +1,15 @@ +use std::time::{Duration, Instant}; + use hypercolor_core::input::{InputManager, ScreenCaptureDemand}; use tracing::warn; +/// How often a persistently failing demand application repeats its warning. +/// Only the log line is paced: the application itself retries on every +/// reconcile tick, because delaying the retry would hold capture inactive +/// for the pacing interval after a transient failure (a nerf the render +/// pipeline's latency tests reject). +const FAILED_DEMAND_WARN_INTERVAL: Duration = Duration::from_secs(1); + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct CaptureDemand { audio_active: bool, @@ -37,6 +46,12 @@ struct CaptureDemandKey { screen_demand: ScreenCaptureDemand, } +#[derive(Clone, Copy)] +struct FailedCaptureDemand { + key: CaptureDemandKey, + warn_again_at: Instant, +} + #[derive(Clone, Copy)] enum CaptureDomain { Audio, @@ -68,6 +83,9 @@ pub(crate) struct CaptureDemandState { audio: Option, screen: Option, interaction: Option, + failed_audio: Option, + failed_screen: Option, + failed_interaction: Option, } impl CaptureDemandState { @@ -91,6 +109,9 @@ impl CaptureDemandState { CaptureDomain::Interaction, ]; let mut succeeded = [false; 3]; + let mut failed = [false; 3]; + let mut warned = [false; 3]; + let now = Instant::now(); for (index, domain) in domains.into_iter().enumerate() { let desired_active = demand.is_active(domain); @@ -103,12 +124,18 @@ impl CaptureDemandState { match domain.apply(manager, demand) { Ok(()) => succeeded[index] = true, - Err(error) => warn!( - domain = domain.name(), - desired_active, - %error, - "Failed to update capture demand" - ), + Err(error) => { + failed[index] = true; + if self.failure_warn_due(domain, desired_key, now) { + warned[index] = true; + warn!( + domain = domain.name(), + desired_active, + %error, + "Failed to update capture demand" + ); + } + } } } @@ -116,10 +143,27 @@ impl CaptureDemandState { for (index, domain) in domains.into_iter().enumerate() { if succeeded[index] { self.set_cached_key(domain, Self::key(resulting_generation, demand, domain)); + self.set_failed_attempt(domain, None); + } else if failed[index] && warned[index] { + // Refresh the marker only when a warning fired, so a + // persistent failure keeps warning once per interval + // instead of sliding the window forever. + self.set_failed_attempt( + domain, + Some(FailedCaptureDemand { + key: Self::key(resulting_generation, demand, domain), + warn_again_at: now + FAILED_DEMAND_WARN_INTERVAL, + }), + ); } } } + fn failure_warn_due(&self, domain: CaptureDomain, key: CaptureDemandKey, now: Instant) -> bool { + self.failed_attempt(domain) + .is_none_or(|attempt| attempt.key != key || now >= attempt.warn_again_at) + } + fn key( graph_generation: u64, demand: CaptureDemand, @@ -151,4 +195,113 @@ impl CaptureDemandState { CaptureDomain::Interaction => self.interaction = Some(key), } } + + fn failed_attempt(&self, domain: CaptureDomain) -> Option { + match domain { + CaptureDomain::Audio => self.failed_audio, + CaptureDomain::Screen => self.failed_screen, + CaptureDomain::Interaction => self.failed_interaction, + } + } + + fn set_failed_attempt(&mut self, domain: CaptureDomain, attempt: Option) { + match domain { + CaptureDomain::Audio => self.failed_audio = attempt, + CaptureDomain::Screen => self.failed_screen = attempt, + CaptureDomain::Interaction => self.failed_interaction = attempt, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use hypercolor_core::input::{InputData, InputSource}; + + use super::*; + + struct FailingScreenSource { + attempts: Arc, + fail: Arc, + } + + impl InputSource for FailingScreenSource { + fn name(&self) -> &'static str { + "failing_screen" + } + + 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 { + true + } + + fn is_screen_source(&self) -> bool { + true + } + + fn set_screen_capture_demand(&mut self, demand: ScreenCaptureDemand) -> anyhow::Result<()> { + if !demand.is_active() { + return Ok(()); + } + self.attempts.fetch_add(1, Ordering::AcqRel); + if self.fail.load(Ordering::Acquire) { + anyhow::bail!("injected screen demand failure"); + } + Ok(()) + } + } + + #[test] + fn failed_demand_retries_every_tick_and_paces_only_the_warning() { + let attempts = Arc::new(AtomicUsize::new(0)); + let fail = Arc::new(AtomicBool::new(true)); + let mut manager = InputManager::new(); + manager.add_source(Box::new(FailingScreenSource { + attempts: Arc::clone(&attempts), + fail: Arc::clone(&fail), + })); + + let extent = hypercolor_core::input::screen::PixelExtent::new(640, 480) + .expect("fixture extent is valid"); + let demand = CaptureDemand::new(false, ScreenCaptureDemand::active(extent), false); + let mut state = CaptureDemandState::default(); + + state.reconcile(&mut manager, demand); + assert_eq!(attempts.load(Ordering::Acquire), 1); + assert!(!state.is_current(manager.source_graph_generation(), demand)); + let first_warn_window = state + .failed_screen + .expect("failed attempt is recorded") + .warn_again_at; + + // The application retries on the very next tick; only the warn is + // suppressed inside the pacing window. + state.reconcile(&mut manager, demand); + assert_eq!(attempts.load(Ordering::Acquire), 2); + assert_eq!( + state + .failed_screen + .expect("suppressed failure keeps the original warn window") + .warn_again_at, + first_warn_window + ); + + // Recovery is immediate once the failure clears. + fail.store(false, Ordering::Release); + state.reconcile(&mut manager, demand); + assert_eq!(attempts.load(Ordering::Acquire), 3); + assert!(state.is_current(manager.source_graph_generation(), demand)); + assert!(state.failed_screen.is_none()); + } } diff --git a/crates/hypercolor-daemon/src/render_thread/frame_composer.rs b/crates/hypercolor-daemon/src/render_thread/frame_composer.rs index 26f67a271..e7d4937a1 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_composer.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_composer.rs @@ -126,6 +126,14 @@ fn producer_frame_requires_composition_for_preview( impl ComposeContext<'_> { async fn compose(&mut self) -> RenderStageStats { + let observed_invalidation_epoch = self.inputs.screen_invalidation_epoch; + if synchronize_screen_invalidation_epoch( + self.compose.screen_queue, + &mut self.inputs.screen_compositor_epoch, + observed_invalidation_epoch, + ) { + self.compose.sparkleflinger.release_native_screen_caches(); + } self.compose_render_group_frame_set(Instant::now()).await } @@ -477,7 +485,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 +532,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 +739,29 @@ pub(super) fn synchronize_screen_plan_generation( changed } -#[cfg(any(test, all(feature = "wgpu", target_os = "windows")))] +fn synchronize_screen_invalidation_epoch( + screen_queue: &mut ProducerQueue, + current_epoch: &mut u64, + observed_epoch: u64, +) -> bool { + if observed_epoch <= *current_epoch { + return false; + } + let _ = screen_queue.clear_latest(); + *current_epoch = observed_epoch; + true +} + +#[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() } @@ -851,3 +893,28 @@ fn scene_canvas_forces_full_surface( #[cfg(test)] mod tests; + +#[cfg(test)] +mod h21_tests { + use hypercolor_core::types::canvas::Canvas; + + use super::{ProducerFrame, ProducerQueue, synchronize_screen_invalidation_epoch}; + + #[test] + fn invalidation_epoch_clears_old_output_before_fresh_publication() { + let mut queue = ProducerQueue::new(); + let mut epoch = 0; + queue.submit_latest(ProducerFrame::Canvas(Canvas::new(4, 4))); + + assert!(synchronize_screen_invalidation_epoch( + &mut queue, &mut epoch, 1 + )); + assert!(!queue.has_latest()); + + queue.submit_latest(ProducerFrame::Canvas(Canvas::new(4, 4))); + assert!(!synchronize_screen_invalidation_epoch( + &mut queue, &mut epoch, 1 + )); + assert!(queue.has_latest()); + } +} diff --git a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs index cbe0cba88..69e4960b2 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs @@ -326,15 +326,6 @@ pub(crate) async fn execute_frame( state, render.sparkleflinger.screen_native_execution_target(), ); - let screen_plan_generation = frame_loop.inputs.observe_screen_plan( - state, - render.sparkleflinger.screen_native_execution_target(), - ); - super::frame_composer::synchronize_screen_plan_generation( - &mut render.sparkleflinger, - &mut render.screen_queue, - screen_plan_generation.get(), - ); let mut screen_input_active = frame_loop.has_screen_input_demand(); scene_snapshot.effect_demand.screen_capture_active = screen_input_active; if !screen_input_active { @@ -416,6 +407,10 @@ pub(crate) async fn execute_frame( } let input_start = Instant::now(); + let screen_plan_generation = frame_loop.inputs.observe_screen_plan( + state, + render.sparkleflinger.screen_native_execution_target(), + ); let inputs = frame_loop .inputs .inputs_for_frame(state, skip_decision, delta_secs); @@ -431,9 +426,24 @@ pub(crate) async fn execute_frame( ®istry, )) }; - 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); + let input_snapshot_done_at = Instant::now(); + let input_us = micros_between(input_start, input_snapshot_done_at); + let input_done_us = micros_between(frame_start, input_snapshot_done_at); + super::frame_composer::synchronize_screen_plan_generation( + &mut render.sparkleflinger, + &mut render.screen_queue, + screen_plan_generation.get(), + ); + #[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 deferred_sample_start = Instant::now(); let PendingSamplingWork { completed: completed_deferred_sampling, stale: stale_deferred_sampling, @@ -444,7 +454,8 @@ pub(crate) async fn execute_frame( "Deferred GPU spatial sampling finalize failed; dropping deferred sample result", ) }; - let deferred_sample_us = micros_between(input_done_at, Instant::now()); + let deferred_sample_done_at = Instant::now(); + let deferred_sample_us = micros_between(deferred_sample_start, deferred_sample_done_at); let canvas_preview_due = frame_loop.publication_cadence.canvas_preview_due( scene_snapshot.elapsed_ms, state.preview_canvas_receiver_count(), @@ -466,6 +477,8 @@ pub(crate) async fn execute_frame( .await; scene_snapshot.effect_demand.screen_capture_active = screen_input_active; + let render_started_at = Instant::now(); + let render_started_us = micros_between(frame_start, render_started_at); let mut render_stage = compose_frame(ComposeRequest { state, compose: render.compose_runtime(), @@ -741,6 +754,7 @@ pub(crate) async fn execute_frame( producer_full_frame_copy: render_stage.producer_full_frame_copy, input_us, deferred_sample_us, + render_started_us, producer_us: render_stage.producer_us, producer_render_us: render_stage.producer_render_us, producer_scene_compose_us: render_stage.producer_scene_compose_us, diff --git a/crates/hypercolor-daemon/src/render_thread/frame_metrics.rs b/crates/hypercolor-daemon/src/render_thread/frame_metrics.rs index ce16f5440..dc006f7bc 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_metrics.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_metrics.rs @@ -21,6 +21,7 @@ pub(crate) struct ActiveFrameMetricsInput<'a> { pub(crate) producer_full_frame_copy: FullFrameCopyMetrics, pub(crate) input_us: u32, pub(crate) deferred_sample_us: u32, + pub(crate) render_started_us: u32, pub(crate) producer_us: u32, pub(crate) producer_render_us: u32, pub(crate) producer_scene_compose_us: u32, @@ -100,6 +101,7 @@ pub(crate) fn build_active_frame_metrics(input: ActiveFrameMetricsInput<'_>) -> producer_full_frame_copy, input_us, deferred_sample_us, + render_started_us, producer_us, producer_render_us, producer_scene_compose_us, @@ -163,6 +165,7 @@ pub(crate) fn build_active_frame_metrics(input: ActiveFrameMetricsInput<'_>) -> LatestFrameMetrics { timestamp_ms: u64_to_u32(scene_snapshot.elapsed_ms), + input_sampled: !reused_inputs, input_us, deferred_sample_us, producer_us, @@ -254,8 +257,8 @@ pub(crate) fn build_active_frame_metrics(input: ActiveFrameMetricsInput<'_>) -> scene_snapshot, scene_snapshot_done_us, input_done_us, - input_done_us.saturating_add(producer_done_us), - input_done_us.saturating_add(composition_done_us), + render_started_us.saturating_add(producer_done_us), + render_started_us.saturating_add(composition_done_us), sample_done_us, output_done_us, publish_done_us, @@ -292,6 +295,7 @@ pub(crate) fn build_throttle_frame_metrics( } = input; LatestFrameMetrics { timestamp_ms: u64_to_u32(scene_snapshot.elapsed_ms), + input_sampled: false, input_us: 0, deferred_sample_us: 0, producer_us: 0, @@ -564,6 +568,7 @@ mod tests { }, input_us: 120, deferred_sample_us: 60, + render_started_us: 100, producer_us: 220, producer_render_us: 140, producer_scene_compose_us: 80, @@ -623,6 +628,7 @@ mod tests { assert_eq!(summary.metrics.publication_full_frame_copy.count, 2); assert_eq!(summary.metrics.full_frame_copy_count, 3); assert_eq!(summary.metrics.full_frame_copy_bytes, 12_288); + assert!(!summary.metrics.input_sampled); assert!(summary.metrics.preview_surface); assert!(summary.metrics.scene_canvas_forced_surface); assert_eq!( @@ -635,6 +641,9 @@ mod tests { assert_eq!(summary.metrics.devices_written, 5); assert_eq!(summary.metrics.total_leds, 321); assert_eq!(summary.metrics.output_errors, 3); + assert_eq!(summary.metrics.timeline.input_done_us, 60); + assert_eq!(summary.metrics.timeline.producer_done_us, 145); + assert_eq!(summary.metrics.timeline.composition_done_us, 155); assert_eq!(summary.admission.total_us, summary.metrics.total_us); assert_eq!(summary.admission.producer_us, summary.metrics.producer_us); assert_eq!( diff --git a/crates/hypercolor-daemon/src/render_thread/input_publication.rs b/crates/hypercolor-daemon/src/render_thread/input_publication.rs index a4f4c6a95..e111b00b6 100644 --- a/crates/hypercolor-daemon/src/render_thread/input_publication.rs +++ b/crates/hypercolor-daemon/src/render_thread/input_publication.rs @@ -1163,10 +1163,13 @@ async fn run_pump( // changes; one warning per streak keeps the log honest // without flooding it at the retry cadence. if exact_screen_failure_streak == 0 { - tracing::warn!(%error, "exact screen publication transition failed"); + tracing::warn!( + error = format!("{error:#}"), + "exact screen publication transition failed" + ); } else if exact_screen_failure_streak.is_multiple_of(60) { tracing::warn!( - %error, + error = format!("{error:#}"), suppressed_failures = exact_screen_failure_streak, "exact screen publication still failing" ); 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 206102d77..da9f0da3e 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, ScreenBranchDeliveryState, 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,10 @@ pub(crate) struct FrameInputs { pub(crate) interaction: hypercolor_core::input::InteractionData, pub(crate) screen_data: Option, pub(crate) screen_publication: Option>, + pub(crate) screen_delivery_state: Option, + pub(crate) screen_invalidation_epoch: u64, + pub(crate) screen_compositor_epoch: u64, + pub(crate) screen_descriptor: Option, pub(crate) sensors: Arc, pub(crate) input_availability: InputSourceAvailability, empty_sensors: Arc, @@ -163,8 +170,13 @@ impl InputReuseState { ) -> ScreenPlanGeneration { let screen_extent = PixelExtent::new(state.canvas_dims.width(), state.canvas_dims.height()) .expect("render canvas dimensions are non-empty"); - let (generation, publication) = self.routes.read_screen(screen_target, screen_extent); + let (generation, publication, delivery_state) = + self.routes.read_screen(screen_target, screen_extent); self.cached_inputs.screen_publication = publication; + self.cached_inputs.screen_invalidation_epoch = + delivery_state.map_or(0, ScreenBranchDeliveryState::invalidation_epoch); + self.cached_inputs.screen_delivery_state = delivery_state; + self.cached_inputs.screen_descriptor = self.routes.screen_descriptor().cloned(); generation } @@ -284,7 +296,11 @@ impl InputRouteCache { &mut self, target: Option<&ScreenNativeExecutionTarget>, extent: PixelExtent, - ) -> (ScreenPlanGeneration, Option>) { + ) -> ( + ScreenPlanGeneration, + Option>, + Option, + ) { let target_id = target.map(ScreenNativeExecutionTarget::id); let (plan_generation, observed_lease) = self.reader.screen_observation(target, extent); let route_is_current = self.screen_publication_route.as_ref().is_some_and(|route| { @@ -300,12 +316,20 @@ impl InputRouteCache { lease: observed_lease, }); } - let publication = self + let observation = self .screen_publication_route .as_ref() .and_then(|route| route.lease.as_ref()) - .and_then(ScreenBranchLease::read); - (plan_generation, publication) + .map(|lease| lease.observe(Instant::now())); + let (publication, delivery_state) = observation.unzip(); + (plan_generation, publication.flatten(), delivery_state) + } + + 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( @@ -621,7 +645,6 @@ impl FrameInputs { self.media = None; self.net = None; self.lighting = None; - self.screen_publication = None; self.screen_surface = None; self.screen_sector_grid.clear(); } @@ -642,6 +665,10 @@ impl FrameInputs { interaction: InteractionData::default(), screen_data: None, screen_publication: None, + screen_delivery_state: None, + screen_invalidation_epoch: 0, + screen_compositor_epoch: 0, + screen_descriptor: None, sensors: Arc::clone(&empty_sensors), input_availability: InputSourceAvailability::default(), empty_sensors, @@ -1361,6 +1388,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 +1877,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,9 +2141,11 @@ 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(); - Self::new_with_gpu_device( + let pipeline = Self::new_with_gpu_device( state.canvas_dims.width(), state.canvas_dims.height(), initial_spatial_engine, @@ -2105,12 +2153,23 @@ 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, 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)] @@ -2123,6 +2182,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, @@ -2131,6 +2193,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(), @@ -2146,6 +2210,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, @@ -2207,6 +2273,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")] @@ -2251,7 +2319,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 +2918,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 +2932,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-daemon/src/render_thread/producer_queue.rs b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs index bb6a7150a..4f1334db7 100644 --- a/crates/hypercolor-daemon/src/render_thread/producer_queue.rs +++ b/crates/hypercolor-daemon/src/render_thread/producer_queue.rs @@ -1,16 +1,81 @@ #[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; +#[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 { @@ -28,6 +93,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 +122,92 @@ 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, + _shared_target_lifetime: Option, + _capture_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, + 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, + } + } +} +#[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 +504,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() } @@ -426,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 27c757c0b..b3c941e3a 100644 --- a/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs +++ b/crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs @@ -1,17 +1,33 @@ -#[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}; +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +use std::time::Instant; use anyhow::{Context, Result}; #[cfg(test)] @@ -25,10 +41,32 @@ 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::{ + CapturePixelFormat, CaptureRotation, CaptureTransferFunction, LED_TONE_MAP_ALGORITHM_REVISION, + MacosNativeTargetManifest, PlatformGpuApi, PreparedLedToneMap, ResolvedScreenColorTransform, + ResolvedScreenPublicationDescriptor, ScreenBranchPayload, ScreenBranchPublication, + ScreenCaptureBackend, ScreenColorTransformCapabilities, ScreenLetterboxFill, + ScreenNativeExecutionTarget, ScreenNativeExecutionTargetId, ScreenNativePreparationPayload, + ScreenNativeRetentionQuote, ScreenNativeTargetPreparation, ScreenNativeTargetPreparer, + ScreenPhysicalGpuDeviceIdentity, ScreenPhysicalReductionDescriptor, ScreenPlanGeneration, + ScreenPublicationKind, ScreenReductionFilter, ScreenResourceApi, ScreenSourceReflection, +}; 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, MacosNativeColorTransform, MacosNativeLetterboxFill, + MacosNativeOutputTransfer, MacosNativeReducer, MacosNativeReductionDescriptor, + MacosNativeReductionFilter, MacosNativeReductionTarget, MacosNativeTargetFormat, + 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::{ @@ -53,8 +91,10 @@ 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::{ - GpuSamplingPlan, GpuSamplingPreparation, GpuSpatialSampler, + GpuSampleSource, GpuSamplingPlan, GpuSamplingPreparation, GpuSpatialSampler, }; mod compositor; @@ -124,7 +164,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 +383,468 @@ struct PreparedWindowsScreenTarget { storage_id: u64, } +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +struct MacosScreenBridge { + device: wgpu::Device, + interop: MacosInteropScreenBridge, + reducer: MacosNativeReducer, + storage_ids: Mutex>, + physical_targets: Mutex< + Vec<( + ScreenPlanGeneration, + ScreenPhysicalReductionDescriptor, + Weak, + )>, + >, +} + +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +struct MacosScreenTargetPreparer { + 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(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"))] +impl MacosScreenBridge { + fn import_frame( + &self, + device: &wgpu::Device, + resource_generation: u64, + frame: Arc, + ) -> Result<(ImportedMacosScreenFrame, u64)> { + let imported = self + .interop + .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 + .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 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_capture_caches(&self) { + self.interop.clear_capture_caches(); + 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 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]>() + .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_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( + &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)?; + let bridge = self + .bridge + .upgrade() + .context("macOS screen renderer was retired during target preparation")?; + let prepared = bridge.prepare_target(descriptor, platform.plan_generation())?; + Ok(ScreenNativeTargetPreparation::with_retention( + ScreenNativePreparationPayload::new( + descriptor, + platform.plan_generation(), + Arc::new(prepared), + ), + prepared_macos_screen_target_retention(descriptor)?, + )) + } +} + +#[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 +879,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 +924,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 +1170,74 @@ 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 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()), + physical_targets: Mutex::new(Vec::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), + }), + ) + .with_color_capabilities(ScreenColorTransformCapabilities::new( + true, + true, + true, + LED_TONE_MAP_ALGORITHM_REVISION, + )), + ) +} + pub(crate) struct GpuSparkleFlinger { _render_device: GpuRenderDevice, device: wgpu::Device, @@ -692,6 +1275,15 @@ 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(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)] @@ -720,6 +1312,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 { @@ -745,6 +1345,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 { @@ -756,6 +1359,8 @@ impl FrameInFlight { generation, encoder: EncoderStage::Building(Some(encoder)), readbacks, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases, } } @@ -771,6 +1376,8 @@ impl FrameInFlight { readback: preview_readback, stage: ReadbackStage::Submitted(submission_index), }], + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: Vec::new(), } } @@ -846,7 +1453,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(); @@ -855,7 +1462,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)] @@ -875,6 +1491,8 @@ impl FrameInFlight { }, stage: ReadbackStage::Encoded, }], + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + native_screen_leases: Vec::new(), } } } @@ -1052,6 +1670,11 @@ 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); + #[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, @@ -1090,6 +1713,14 @@ 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(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)] @@ -1115,6 +1746,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)] { @@ -1210,7 +1846,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 +2007,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 +2035,18 @@ 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_capture_caches(); + } + } + #[cfg(target_os = "windows")] + { + self.screen_storage_id = None; + } + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + self.release_completed_native_screen_leases(); } #[cfg(target_os = "windows")] @@ -1476,6 +2132,203 @@ 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 (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 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")?; + 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 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) = match imported { + Ok(imported) => imported, + Err(error) => { + self.release_native_screen_caches(); + return Err(error); + } + }; + anyhow::ensure!( + 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 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(); + let mut submitted_native_reduction = false; + 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"), + }); + let reduction = bridge.reducer.encode( + &imported, + &physical.target, + macos_reduction_descriptor(descriptor)?, + &mut encoder, + ); + if let Err(error) = reduction { + self.release_native_screen_caches(); + return Err(error.into()); + } + 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); + } + drop(physical_sequence); + + let target = 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"), + }); + let 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, + ); + if let Err(error) = materialization { + self.release_native_screen_caches(); + return Err(error.into()); + } + 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); + } + ( + 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(), + ) + }; + 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!( + 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, + 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, + shared_target_lifetime, + capture_lifetime, + )), + })) + } + pub(crate) fn can_sample_zone_plan(&mut self, prepared_zones: &[PreparedZonePlan]) -> bool { let dimensions = self .surfaces @@ -1535,9 +2388,53 @@ 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, })) } + 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, + )?; + #[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); + } + 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>, @@ -1997,6 +2894,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 +2919,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 +2978,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 +3092,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, }) } @@ -2201,7 +3106,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(); } @@ -2211,7 +3121,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)] @@ -2225,6 +3135,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(), @@ -2234,9 +3167,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() @@ -2308,6 +3292,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 517a392ce..6fc04c56b 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,13 @@ 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") +))] +use crate::render_thread::producer_queue::NativeScreenCacheLease; use crate::render_thread::producer_queue::{ GpuTextureFrame, GpuTextureFrameLease, GpuTextureFrameOrigin, ProducerFrame, }; @@ -198,6 +203,8 @@ impl GpuSparkleFlinger { requires_cpu_sampling_canvas, preview_surface_request, None, + #[cfg(all(target_os = "macos", feature = "screen-capture"))] + Vec::new(), None, ); } @@ -302,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 { @@ -315,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, ); } @@ -400,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 @@ -421,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, ); @@ -443,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); @@ -466,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); @@ -492,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()); } @@ -503,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, @@ -518,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, ) } @@ -776,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 { @@ -786,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"); @@ -805,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()) @@ -835,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 @@ -843,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(), @@ -932,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 @@ -946,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, }; @@ -982,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 { @@ -1024,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 @@ -1043,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(); } } @@ -1138,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 @@ -1186,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, @@ -1244,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(); } @@ -1290,8 +1405,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 +1597,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 +1655,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 +1751,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 +1790,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 +1808,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/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/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..dbdeceabf 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,13 @@ 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") +))] +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 +62,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 +78,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 +103,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()); } } @@ -114,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 { @@ -155,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); @@ -249,6 +272,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 +315,24 @@ 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(), + } + } + + #[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(), } } } @@ -318,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) { @@ -331,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; @@ -355,8 +400,15 @@ 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, + >, ) { - if frame.needs_shader_copy() { + #[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, queue, @@ -374,8 +426,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..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,15 +1,43 @@ +#[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") + 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::{ + 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, }; +#[cfg(all(feature = "screen-capture", target_os = "macos"))] +use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + MacosPixelExtent, MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + MacosYuvMatrix, +}; use hypercolor_types::config::RenderAccelerationMode; use hypercolor_types::device::{DeviceId, DisplayFrameFormat}; use hypercolor_types::event::ZoneColors; @@ -37,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::{ @@ -1593,6 +1629,1036 @@ 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() + .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, + })), + ); + 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"))] +#[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"); + 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 +2861,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/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 8b0bbdf23..fea895685 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; @@ -676,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, @@ -781,7 +795,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 +809,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, @@ -1554,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/macos_owner_watch.rs b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs new file mode 100644 index 000000000..7e92e5798 --- /dev/null +++ b/crates/hypercolor-daemon/src/startup/macos_owner_watch.rs @@ -0,0 +1,780 @@ +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, +} + +#[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, + 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(MacosOwnerPublication::without_identity(fallback)); + }; + self.reconciled_fingerprint = Some(MacosOwnerIdentityFingerprint::from(&record)); + Ok(MacosOwnerPublication::from_record( + &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(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, publication) + { + 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, + publication: MacosOwnerPublication, +) -> anyhow::Result<()> { + 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, + 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 { + active: capability_owner(conflict.active_owner), + 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); + 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(MacosOwnerPublication::from_record( + &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, 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::{ + 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() + .as_ref() + .expect("updated snapshot should remain installed") + .snapshot + .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, + MacosOwnerPublication { + snapshot, + designated_requirement_hash: Some(Arc::from("deadbeef")), + }, + |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 + .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") + .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..6cefef0be 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; @@ -75,7 +77,7 @@ pub use config::{default_config, load_config, parse_config_toml}; pub use discovery_worker::{ collect_unmapped_driver_layout_targets, collect_unmapped_prefixed_layout_targets, }; -pub use signals::install_signal_handlers; +pub use signals::{SUPERVISED_PARENT_PID_ENV, install_signal_handlers}; /// The top-level daemon state, holding all subsystems. /// @@ -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>, @@ -293,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() } diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index e3afe4628..95efc2590 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -8,8 +8,8 @@ use std::sync::atomic::AtomicBool; use std::time::Instant; use anyhow::{Context, Result}; -use arc_swap::ArcSwap; -#[cfg(any(target_os = "linux", target_os = "windows"))] +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}; use tracing::{info, warn}; @@ -27,21 +27,22 @@ 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; -#[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; @@ -87,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 @@ -101,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() @@ -166,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"); @@ -294,7 +325,31 @@ impl DaemonState { info!("Device lifecycle manager created"); // ── Input Manager ─────────────────────────────────────────────── + #[cfg(target_os = "macos")] + 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(publication) = macos_owner_publication { + super::macos_owner_watch::publish_owner_snapshot( + &macos_daemon_ownership, + &mut built_input_manager, + &event_bus, + publication, + )?; + } let interaction_routing = InteractionRoutingControl::new( browser_input.registry(), 1, @@ -304,6 +359,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, @@ -583,6 +642,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)), @@ -649,9 +711,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(), @@ -689,7 +751,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( @@ -699,7 +761,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), @@ -710,14 +772,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 @@ -728,7 +790,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 { @@ -736,7 +798,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, @@ -750,7 +812,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, @@ -770,7 +832,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()), @@ -783,7 +845,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, @@ -800,7 +862,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, @@ -810,7 +872,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, @@ -831,7 +893,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, @@ -846,7 +908,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, @@ -867,6 +929,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")?; @@ -874,7 +938,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, @@ -913,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")] @@ -920,8 +989,6 @@ enum CaptureConfigPersistenceUpdate { configured: Option, resolved: Option, }, - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - Unsupported, } impl CaptureConfigPersistenceGate { @@ -960,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 @@ -977,6 +1063,7 @@ impl CaptureConfigPersistenceGate { source_identity(&state) } + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn publish(&self, update: CaptureConfigPersistenceUpdate) { let persistence = { let mut state = self @@ -1067,6 +1154,7 @@ impl CaptureConfigPersistenceGate { self.inner.config_manager.revoke_capture_persistence(epoch); } + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn persist( &self, epoch: CapturePersistenceEpoch, @@ -1079,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 @@ -1095,14 +1187,16 @@ impl CaptureConfigPersistenceGate { snapshot.capture.restore_token != *resolved } } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - CaptureConfigPersistenceUpdate::Unsupported => false, }; if !should_persist { return; } 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; @@ -1111,8 +1205,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), @@ -1132,6 +1224,17 @@ impl CaptureConfigPersistenceGate { } } } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + fn persist( + &self, + _epoch: CapturePersistenceEpoch, + _source: Option, + update: CaptureConfigPersistenceUpdate, + _deferred: bool, + ) { + match update {} + } } fn source_identity(state: &CaptureConfigPersistenceState) -> Option { @@ -1148,22 +1251,28 @@ 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")] CaptureConfigPersistenceUpdate::RestoreToken { .. } => false, - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - CaptureConfigPersistenceUpdate::Unsupported => false, } } +#[cfg(not(any(target_os = "linux", target_os = "macos", 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| { @@ -1187,12 +1296,23 @@ 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. /// -/// 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 +1341,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 +1352,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")] @@ -1260,17 +1387,27 @@ 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 { 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, @@ -1283,6 +1420,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(), }) @@ -1296,7 +1438,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, @@ -1332,7 +1474,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)] @@ -1372,3 +1517,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/crates/hypercolor-daemon/src/startup/services/tests.rs b/crates/hypercolor-daemon/src/startup/services/tests.rs index edeb017bd..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() { @@ -226,6 +320,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 +339,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-daemon/src/startup/signals.rs b/crates/hypercolor-daemon/src/startup/signals.rs index c2207be46..ae52dd8c7 100644 --- a/crates/hypercolor-daemon/src/startup/signals.rs +++ b/crates/hypercolor-daemon/src/startup/signals.rs @@ -2,11 +2,19 @@ use tracing::info; +/// Environment variable a supervising app sets to its own pid when it +/// spawns the daemon as a managed child. Arms the parent-death watch so a +/// supervisor that dies without reaping (crash, SIGKILL, exit paths that +/// skip destructors) cannot leave an orphaned daemon holding the port and +/// the ownership guard. +pub const SUPERVISED_PARENT_PID_ENV: &str = "HYPERCOLOR_SUPERVISED_PARENT_PID"; + /// Install OS signal handlers for graceful shutdown. /// /// Returns a watch receiver that flips to `true` when a shutdown signal -/// (Ctrl+C / `SIGTERM`) is received. The spawned task is fire-and-forget; -/// it exits after the first signal. +/// (Ctrl+C / `SIGTERM`) is received, or when the supervising parent +/// process named by [`SUPERVISED_PARENT_PID_ENV`] dies. The spawned tasks +/// are fire-and-forget; each exits after its first trigger. #[must_use] pub fn install_signal_handlers() -> tokio::sync::watch::Receiver { install_platform_signal_handlers() @@ -15,6 +23,9 @@ pub fn install_signal_handlers() -> tokio::sync::watch::Receiver { #[cfg(unix)] fn install_platform_signal_handlers() -> tokio::sync::watch::Receiver { let (tx, rx) = tokio::sync::watch::channel(false); + let tx = std::sync::Arc::new(tx); + + install_supervised_parent_watch(std::sync::Arc::clone(&tx)); tokio::spawn(async move { let mut terminate = @@ -52,6 +63,47 @@ fn install_platform_signal_handlers() -> tokio::sync::watch::Receiver { rx } +/// Watch the supervising parent process and shut down when it dies. +/// +/// A dead parent reparents this process to launchd, so a change in +/// `getppid` is the death signal. The watch arms only when the claimed +/// supervisor pid matches the live parent: a mismatch means the claim was +/// inherited through an exec chain and watching would track the wrong +/// process. +#[cfg(unix)] +fn install_supervised_parent_watch(tx: std::sync::Arc>) { + let Some(claimed) = std::env::var(SUPERVISED_PARENT_PID_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + else { + return; + }; + let initial = std::os::unix::process::parent_id(); + if initial != claimed { + tracing::warn!( + claimed, + observed = initial, + "supervised parent claim does not match the live parent; parent-death watch disarmed" + ); + return; + } + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(1)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + if std::os::unix::process::parent_id() != initial { + info!( + supervisor_pid = initial, + "supervising app exited; daemon shutting down" + ); + let _ = tx.send(true); + return; + } + } + }); +} + #[cfg(not(unix))] fn install_platform_signal_handlers() -> tokio::sync::watch::Receiver { let (tx, rx) = tokio::sync::watch::channel(false); diff --git a/crates/hypercolor-daemon/tests/api_tests.rs b/crates/hypercolor-daemon/tests/api_tests.rs index d4fff6b48..431e7bde2 100644 --- a/crates/hypercolor-daemon/tests/api_tests.rs +++ b/crates/hypercolor-daemon/tests/api_tests.rs @@ -301,6 +301,12 @@ fn test_app_with_state(state: Arc) -> axum::Router { api::build_router(state, None) } +/// Build a trusted in-process API for tests exercising privacy-bearing +/// config keys, which require the protected-control credential. +fn trusted_api(state: Arc) -> api::local::TrustedLocalApi { + api::local::TrustedLocalApi::new(state) +} + fn test_state_with_temp_config_manager() -> (Arc, Arc, tempfile::TempDir) { let (mut state, dir) = isolated_state_with_tempdir(); let manager = Arc::new( @@ -1475,10 +1481,7 @@ async fn status_returns_200_with_envelope() { .is_some_and(|s| !s.is_empty()), "cache_dir should be a non-empty string" ); - assert!( - json["data"]["audio_available"].is_boolean(), - "audio_available should be a bool" - ); + assert_eq!(json["data"]["audio_available"], false); assert_eq!( json["data"]["capture_available"], serde_json::json!( @@ -1488,6 +1491,32 @@ async fn status_returns_200_with_envelope() { ); } +#[tokio::test] +async fn status_derives_audio_availability_from_registered_sources() { + let state = Arc::new(isolated_state()); + let (source, _) = ObservableInputSource::new("available_audio", false, Duration::from_secs(1)); + state + .input_manager + .lock() + .await + .add_source(Box::new(source)); + let app = test_app_with_state(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/status") + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("failed to execute request"); + + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert_eq!(json["data"]["audio_available"], true); +} + #[tokio::test] async fn status_reports_stale_source_health_without_captured_contents() { const PRIVACY_SENTINEL: &str = "capture_secret_73_do_not_expose"; @@ -1917,10 +1946,10 @@ async fn config_set_audio_device_persists_without_live_rebuild_by_default() { let mut state = isolated_state(); state.config_manager = Some(config_manager); let state = Arc::new(state); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2081,7 +2110,7 @@ async fn config_set_driver_registry_key_rejects_non_routable_ip() { #[tokio::test] async fn config_set_rejects_invalid_capture_boundaries_before_persistence() { let (state, manager, _tempdir) = test_state_with_temp_config_manager(); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); for (key, value) in [ ("capture.capture_fps", "0"), @@ -2090,8 +2119,7 @@ async fn config_set_rejects_invalid_capture_boundaries_before_persistence() { ("capture.gamma", "nan"), ] { let response = app - .clone() - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2120,10 +2148,10 @@ async fn config_set_rejects_invalid_capture_boundaries_before_persistence() { #[tokio::test] async fn config_set_rejects_capture_resource_plan_before_persistence() { let (state, manager, _tempdir) = test_state_with_temp_config_manager(); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2147,7 +2175,7 @@ async fn config_set_rejects_capture_resource_plan_before_persistence() { #[tokio::test] async fn config_set_applies_windows_capture_settings_source_and_disable_live() { let (state, manager, _tempdir) = test_state_with_temp_config_manager(); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let source = r"monitor:\\?\DISPLAY#TEST#stable"; for (key, value) in [ @@ -2158,8 +2186,7 @@ async fn config_set_applies_windows_capture_settings_source_and_disable_live() { ), ] { let response = app - .clone() - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2179,7 +2206,7 @@ async fn config_set_applies_windows_capture_settings_source_and_disable_live() { } let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2213,10 +2240,10 @@ async fn config_set_audio_device_rebuilds_live_input_manager_when_requested() { let mut state = isolated_state(); state.config_manager = Some(config_manager); let state = Arc::new(state); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2268,10 +2295,10 @@ async fn config_set_legacy_audio_alias_persists_canonical_device_id() { let mut state = isolated_state(); state.config_manager = Some(config_manager); let state = Arc::new(state); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2318,10 +2345,10 @@ async fn config_set_legacy_audio_alias_skips_live_rebuild_when_already_canonical let mut state = isolated_state(); state.config_manager = Some(config_manager); let state = Arc::new(state); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") @@ -2366,10 +2393,10 @@ async fn config_set_identical_audio_value_skips_live_rebuild() { let mut state = isolated_state(); state.config_manager = Some(config_manager); let state = Arc::new(state); - let app = test_app_with_state(Arc::clone(&state)); + let app = trusted_api(Arc::clone(&state)); let response = app - .oneshot( + .execute( Request::builder() .method("POST") .uri("/api/v1/config/set") 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..eae72b054 --- /dev/null +++ b/crates/hypercolor-daemon/tests/macos_owner_tests.rs @@ -0,0 +1,793 @@ +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, + 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)) + .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)) + .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)) + .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 + .load_owner_record() + .expect("owner record should load") + .expect("owner record should exist"), + third + ); +} + +#[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)) + .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)) + .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)) + .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)) + .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)), + 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 + ); +} + +#[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"); + 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)), + 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)) + .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)) + .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)) + .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(_) => {} + } +} diff --git a/crates/hypercolor-daemon/tests/macos_tcc_canary_tests.rs b/crates/hypercolor-daemon/tests/macos_tcc_canary_tests.rs new file mode 100644 index 000000000..17f47d19b --- /dev/null +++ b/crates/hypercolor-daemon/tests/macos_tcc_canary_tests.rs @@ -0,0 +1,1985 @@ +#![cfg(all(target_os = "macos", feature = "macos-tcc-canary"))] + +use std::fmt::Write as _; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use hypercolor_daemon::macos_tcc_canary::{ + MACOS_TCC_CANARY_SCHEMA_VERSION, MacosTccCanaryCapability, MacosTccCanaryCapabilityEvidence, + MacosTccCanaryInstallationScenario, MacosTccCanaryLauncherEvidence, + MacosTccCanaryLifecyclePhase, MacosTccCanaryOutcome, MacosTccCanaryReceipt, + MacosTccCanaryRequest, MacosTccCanarySigningEvidence, MacosTccCanaryValidation, + MacosTccCanaryWitness, MacosTccCanaryWitnessKind, arm_macos_tcc_canary, + publish_macos_tcc_canary_artifact, validate_macos_tcc_canary_receipts, +}; +use hypercolor_macos_owner::MacosDaemonOwner; +use sha2::{Digest, Sha256}; + +const RUN_ID: &str = "signed-acceptance-run"; + +fn base_request() -> MacosTccCanaryRequest { + MacosTccCanaryRequest { + schema_version: MACOS_TCC_CANARY_SCHEMA_VERSION, + run_id: RUN_ID.to_owned(), + row_id: "app-keyboard-grant".to_owned(), + scenario_id: "app-only".to_owned(), + installation_scenario: MacosTccCanaryInstallationScenario::AppOnly, + login_iteration: 1, + expected_topology: MacosDaemonOwner::AppSidecar, + lifecycle_phase: MacosTccCanaryLifecyclePhase::Grant, + predecessor_row_id: None, + process_replacement_witness_id: None, + lifecycle_action_witness_id: None, + login_arbitration_witness_id: None, + scored_capability: MacosTccCanaryCapability::Keyboard, + capabilities: vec![MacosTccCanaryCapability::Keyboard], + allow_input_prompt: true, + allow_screen_prompt: false, + allow_picker: false, + operation_timeout_ms: 30_000, + fresh_tcc_reset_witness_id: Some("fresh-app-keyboard".to_owned()), + system_settings_identity_witness_id: "settings-app-keyboard".to_owned(), + expected_prompt_text: "Hypercolor requests access".to_owned(), + expected_system_settings_entry: "Hypercolor".to_owned(), + } +} + +#[test] +fn canary_request_closes_capability_and_lifecycle_shapes() { + base_request() + .validate() + .expect("baseline request should pass"); + + let mut stream_without_picker = base_request(); + stream_without_picker.scored_capability = MacosTccCanaryCapability::Stream; + stream_without_picker.capabilities = vec![MacosTccCanaryCapability::Stream]; + stream_without_picker.allow_picker = true; + assert!(stream_without_picker.validate().is_err()); + + let mut restart_without_links = base_request(); + restart_without_links.lifecycle_phase = MacosTccCanaryLifecyclePhase::OwnerRestart; + assert!(restart_without_links.validate().is_err()); + + let mut wrong_topology_phase = base_request(); + wrong_topology_phase.expected_topology = MacosDaemonOwner::Homebrew; + wrong_topology_phase.installation_scenario = MacosTccCanaryInstallationScenario::HomebrewOnly; + wrong_topology_phase.lifecycle_phase = MacosTccCanaryLifecyclePhase::AppLaunch; + assert!(wrong_topology_phase.validate().is_err()); + + let mut mixed_without_login_witness = base_request(); + mixed_without_login_witness.installation_scenario = + MacosTccCanaryInstallationScenario::AppHomebrew; + assert!(mixed_without_login_witness.validate().is_err()); + + let mut later_grant = base_request(); + later_grant.lifecycle_phase = MacosTccCanaryLifecyclePhase::LaterGrant; + later_grant.predecessor_row_id = Some("denied-row".to_owned()); + later_grant.process_replacement_witness_id = Some("replacement-row".to_owned()); + later_grant + .validate() + .expect("later grant runs in a replacement process"); + + let mut grant_with_predecessor = base_request(); + grant_with_predecessor.predecessor_row_id = Some("unexpected-row".to_owned()); + assert!(grant_with_predecessor.validate().is_err()); + + let mut app_launch = base_request(); + app_launch.lifecycle_phase = MacosTccCanaryLifecyclePhase::AppLaunch; + assert!(app_launch.validate().is_err()); + app_launch.lifecycle_action_witness_id = Some("app-launch-action".to_owned()); + app_launch + .validate() + .expect("app launch with an exact action witness should pass"); + + for invalid in [".", ".."] { + let mut invalid_request = base_request(); + invalid_request.run_id = invalid.to_owned(); + assert!(invalid_request.validate().is_err()); + + let mut invalid_request = base_request(); + invalid_request.row_id = invalid.to_owned(); + assert!(invalid_request.validate().is_err()); + } +} + +#[test] +fn arming_uses_private_modes_and_never_overwrites() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let request_path = directory.path().join("row.json"); + fs::write( + &request_path, + serde_json::to_vec(&base_request()).expect("request should encode"), + ) + .expect("request should write"); + + let armed = + arm_macos_tcc_canary(directory.path(), &request_path).expect("valid request should arm"); + let mode = fs::metadata(&armed) + .expect("armed request should exist") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let root_mode = fs::metadata( + armed + .parent() + .expect("armed request should have a canary root"), + ) + .expect("canary root should exist") + .permissions() + .mode() + & 0o777; + assert_eq!(root_mode, 0o700); + assert!( + fs::read_dir( + armed + .parent() + .expect("armed request should have a canary root") + ) + .expect("canary root should read") + .all(|entry| { + !entry + .expect("canary entry should read") + .file_name() + .to_string_lossy() + .contains(".tmp") + }) + ); + assert!(arm_macos_tcc_canary(directory.path(), &request_path).is_err()); +} + +#[test] +fn artifact_publication_is_atomic_synced_and_never_overwrites() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let canary_root = directory.path().join("macos-tcc-canary"); + let receipt_dir = canary_root.join("receipts/run"); + fs::create_dir_all(&receipt_dir).expect("receipt directory should create"); + let source = directory.path().join("witness.json"); + let destination = receipt_dir.join("witness.json"); + fs::write(&source, b"first").expect("source should write"); + + publish_macos_tcc_canary_artifact(&canary_root, &source, &destination) + .expect("artifact should publish"); + assert_eq!( + fs::read(&destination).expect("artifact should read"), + b"first" + ); + assert!( + fs::read_dir(&receipt_dir) + .expect("receipt directory should read") + .all(|entry| !entry + .expect("receipt entry should read") + .file_name() + .to_string_lossy() + .contains(".tmp")) + ); + + fs::write(&source, b"second").expect("replacement source should write"); + assert!(publish_macos_tcc_canary_artifact(&canary_root, &source, &destination).is_err()); + assert_eq!( + fs::read(&destination).expect("artifact should read"), + b"first" + ); +} + +#[test] +fn arming_rejects_symlinked_request_files() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let request_path = directory.path().join("row.json"); + let request_link = directory.path().join("row-link.json"); + fs::write( + &request_path, + serde_json::to_vec(&base_request()).expect("request should encode"), + ) + .expect("request should write"); + std::os::unix::fs::symlink(&request_path, &request_link) + .expect("request symlink should create"); + + assert!(arm_macos_tcc_canary(directory.path(), &request_link).is_err()); +} + +#[test] +fn arming_rejects_a_symlinked_canary_root() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let redirected = tempfile::tempdir().expect("redirect directory should exist"); + let request_path = directory.path().join("row.json"); + fs::write( + &request_path, + serde_json::to_vec(&base_request()).expect("request should encode"), + ) + .expect("request should write"); + std::os::unix::fs::symlink(redirected.path(), directory.path().join("macos-tcc-canary")) + .expect("canary root symlink should create"); + + assert!(arm_macos_tcc_canary(directory.path(), &request_path).is_err()); + assert!(!redirected.path().join("request.json").exists()); +} + +#[test] +fn arming_rejects_symlinked_reserved_descendants() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let redirected = tempfile::tempdir().expect("redirect directory should exist"); + let canary_root = directory.path().join("macos-tcc-canary"); + fs::create_dir(&canary_root).expect("canary root should create"); + std::os::unix::fs::symlink(redirected.path(), canary_root.join("receipts")) + .expect("reserved descendant symlink should create"); + let request_path = directory.path().join("row.json"); + write_json(&request_path, &base_request()); + + assert!(arm_macos_tcc_canary(directory.path(), &request_path).is_err()); + assert!(!canary_root.join("request.json").exists()); +} + +#[derive(Default)] +struct MatrixFixture { + next_row: u32, + failed_row: Option<( + MacosDaemonOwner, + MacosTccCanaryCapability, + MacosTccCanaryLifecyclePhase, + )>, +} + +impl MatrixFixture { + fn add_receipt( + &mut self, + directory: &Path, + topology: MacosDaemonOwner, + capability: MacosTccCanaryCapability, + phase: MacosTccCanaryLifecyclePhase, + architecture: &str, + os_version: &str, + scenario: MacosTccCanaryInstallationScenario, + scenario_id: &str, + login_iteration: u32, + predecessor: Option<&MacosTccCanaryReceipt>, + ) -> MacosTccCanaryReceipt { + self.next_row += 1; + let row_id = format!("row-{:04}", self.next_row); + let pid = 10_000 + self.next_row; + let process_started_unix_ms = 1_000_000 + u64::from(self.next_row) * 100; + let expected_outcome = if self.failed_row == Some((topology, capability, phase)) { + MacosTccCanaryOutcome::Failed + } else { + expected_outcome(phase) + }; + let capabilities = evidence_for(capability, expected_outcome, phase); + let settings_witness_id = format!("settings-{row_id}"); + let fresh_witness_id = + (phase == MacosTccCanaryLifecyclePhase::Grant).then(|| format!("fresh-{row_id}")); + let replacement_witness_id = (predecessor.is_some() && phase_replaces_process(phase)) + .then(|| format!("replacement-{row_id}")); + let lifecycle_action_witness_id = + phase_needs_lifecycle_action_witness(phase).then(|| format!("lifecycle-{row_id}")); + let login_witness_id = + scenario_needs_login_witness(scenario).then(|| format!("login-{row_id}")); + let signed_update = phase == MacosTccCanaryLifecyclePhase::SignedUpdate; + let receipt = MacosTccCanaryReceipt { + schema_version: MACOS_TCC_CANARY_SCHEMA_VERSION, + run_id: RUN_ID.to_owned(), + row_id: row_id.clone(), + scenario_id: scenario_id.to_owned(), + installation_scenario: scenario, + login_iteration, + topology, + lifecycle_phase: phase, + predecessor_row_id: predecessor.map(|receipt| receipt.row_id.clone()), + process_replacement_witness_id: replacement_witness_id.clone(), + lifecycle_action_witness_id: lifecycle_action_witness_id.clone(), + login_arbitration_witness_id: login_witness_id.clone(), + scored_capability: capability, + fresh_tcc_reset_witness_id: fresh_witness_id.clone(), + system_settings_identity_witness_id: settings_witness_id.clone(), + expected_prompt_text: "Hypercolor requests access".to_owned(), + expected_system_settings_entry: "Hypercolor".to_owned(), + host_architecture: architecture.to_owned(), + executable_slice: if architecture == "intel" { + "x86_64" + } else { + "aarch64" + } + .to_owned(), + translated_process: false, + os_version: os_version.to_owned(), + binary_version: if signed_update { "2.0.0" } else { "1.0.0" }.to_owned(), + pid, + process_fingerprint: format!("{pid:064x}"), + audit_token_identity: format!( + "00000000:00000000:00000000:00000000:00000000:{pid:08x}:00000000:{pid:08x}" + ), + executable_path: PathBuf::from( + "/Applications/Hypercolor.app/Contents/MacOS/hypercolor-daemon", + ), + process_started_unix_ms, + operation_finished_unix_ms: process_started_unix_ms + 50, + launcher: launcher(topology), + signing: signing(topology, signed_update, pid, &format!("{pid:064x}")), + capabilities, + acceptance_claim: "evidence_only".to_owned(), + }; + write_witness( + directory, + witness( + &receipt, + settings_witness_id, + MacosTccCanaryWitnessKind::SystemSettingsIdentity, + None, + ), + ); + if let Some(witness_id) = fresh_witness_id { + write_witness( + directory, + witness( + &receipt, + witness_id, + MacosTccCanaryWitnessKind::FreshTccReset, + None, + ), + ); + } + if let (Some(witness_id), Some(predecessor)) = (replacement_witness_id, predecessor) { + write_witness( + directory, + witness( + &receipt, + witness_id, + MacosTccCanaryWitnessKind::ProcessReplacement, + Some(predecessor), + ), + ); + } + if let Some(witness_id) = lifecycle_action_witness_id { + write_witness( + directory, + witness( + &receipt, + witness_id, + MacosTccCanaryWitnessKind::LifecycleAction, + None, + ), + ); + } + if let Some(witness_id) = login_witness_id { + write_witness( + directory, + witness( + &receipt, + witness_id, + MacosTccCanaryWitnessKind::LoginArbitration, + None, + ), + ); + } + write_json(&directory.join(format!("{row_id}.receipt.json")), &receipt); + receipt + } +} + +fn validate_full_signed_matrix( + failed_row: Option<( + MacosDaemonOwner, + MacosTccCanaryCapability, + MacosTccCanaryLifecyclePhase, + )>, +) -> MacosTccCanaryValidation { + let directory = full_signed_matrix_directory(failed_row); + validate_macos_tcc_canary_receipts(directory.path()).expect("complete fixture should validate") +} + +fn full_signed_matrix_directory( + failed_row: Option<( + MacosDaemonOwner, + MacosTccCanaryCapability, + MacosTccCanaryLifecyclePhase, + )>, +) -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut fixture = MatrixFixture { + failed_row, + ..MatrixFixture::default() + }; + + for topology in topologies() { + for capability in capabilities() { + let scenario = single_scenario(topology); + for (architecture, os_version) in platform_cells() { + let scenario_id = + format!("single-{topology:?}-{capability:?}-{architecture}-{os_version}"); + let grant = fixture.add_receipt( + directory.path(), + topology, + capability, + MacosTccCanaryLifecyclePhase::Grant, + architecture, + os_version, + scenario, + &scenario_id, + 1, + None, + ); + let deny = if capability == MacosTccCanaryCapability::Pointer { + None + } else { + Some(fixture.add_receipt( + directory.path(), + topology, + capability, + MacosTccCanaryLifecyclePhase::Deny, + architecture, + os_version, + scenario, + &scenario_id, + 1, + None, + )) + }; + let revoke = matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Stream + ) + .then(|| { + fixture.add_receipt( + directory.path(), + topology, + capability, + MacosTccCanaryLifecyclePhase::RevokeWhileLive, + architecture, + os_version, + scenario, + &scenario_id, + 1, + None, + ) + }); + if let Some(deny) = deny.as_ref() { + fixture.add_receipt( + directory.path(), + topology, + capability, + MacosTccCanaryLifecyclePhase::LaterGrant, + architecture, + os_version, + scenario, + &scenario_id, + 1, + Some(deny), + ); + } + if let Some(revoke) = revoke.as_ref() { + fixture.add_receipt( + directory.path(), + topology, + capability, + MacosTccCanaryLifecyclePhase::GrantAfterRevocation, + architecture, + os_version, + scenario, + &scenario_id, + 1, + Some(revoke), + ); + } + for phase in topology_phases(topology) { + let predecessor = phase_needs_predecessor(phase).then_some(&grant); + fixture.add_receipt( + directory.path(), + topology, + capability, + phase, + architecture, + os_version, + scenario, + &scenario_id, + 1, + predecessor, + ); + } + } + } + } + for (scenario, topology) in mixed_scenarios() { + for (architecture, os_version) in platform_cells() { + let scenario_id = format!("mixed-{scenario:?}-{architecture}-{os_version}"); + for iteration in [1, 2] { + fixture.add_receipt( + directory.path(), + topology, + MacosTccCanaryCapability::Pointer, + MacosTccCanaryLifecyclePhase::Grant, + architecture, + os_version, + scenario, + &scenario_id, + iteration, + None, + ); + } + } + } + + directory +} + +#[test] +fn full_signed_matrix_qualifies_preferred_sidecar_without_minting_acceptance() { + let validation = validate_full_signed_matrix(None); + assert!(validation.receipt_structure_valid); + assert!(validation.identity_consistent); + assert!(validation.preferred_topology_eligible); + assert!(!validation.physical_acceptance_claimed); + assert!(validation.missing_requirements.is_empty()); + assert_eq!(validation.capability_qualifications.len(), 4); + assert!( + validation + .capability_qualifications + .iter() + .all(|qualification| { + qualification.preferred_topology == Some(MacosDaemonOwner::AppSidecar) + && !qualification.app_broker_required + }) + ); +} + +#[test] +fn every_lifecycle_phase_must_pass_in_every_native_platform_cell() { + let directory = full_signed_matrix_directory(None); + let (path, mut receipt) = receipt_matching(directory.path(), |receipt| { + receipt.topology == MacosDaemonOwner::AppSidecar + && receipt.scored_capability == MacosTccCanaryCapability::Keyboard + && receipt.lifecycle_phase == MacosTccCanaryLifecyclePhase::OwnerRestart + && receipt.host_architecture == "intel" + && receipt.os_version == "26.0" + }); + receipt.capabilities[0].outcome = MacosTccCanaryOutcome::Failed; + receipt.capabilities[0].resulting_api_state = "failed".to_owned(); + write_json(&path, &receipt); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated matrix should remain bounded"); + assert!( + validation + .missing_requirements + .contains(&"appsidecar_keyboard_intel_tahoe_26_ownerrestart".to_owned()) + ); + assert!(!validation.preferred_topology_eligible); +} + +#[test] +fn system_settings_witness_is_exact_current_row_process_evidence() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "settings-identity", + 1, + None, + ); + let witness_path = directory.path().join(format!( + "{}.witness.json", + receipt.system_settings_identity_witness_id + )); + let mut witness: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&witness_path).expect("settings witness should read")) + .expect("settings witness should decode"); + witness.observed_unix_ms = receipt.process_started_unix_ms.saturating_sub(1); + witness.system_settings_entry = Some("another process".to_owned()); + witness.observed_audit_token_identity = + Some("00000000:00000000:00000000:00000000:00000000:ffffffff:00000000:00000001".to_owned()); + write_json(&witness_path, &witness); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated witness should remain bounded"); + assert!(!validation.identity_consistent); + assert!( + validation + .missing_requirements + .contains(&"signed_launcher_identity".to_owned()) + ); +} + +#[test] +fn signing_identity_must_be_bound_to_the_observed_live_process() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "process-bound-signing", + 1, + None, + ); + receipt.signing.process_bound_pid = receipt.pid + 1; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated receipt should remain bounded"); + assert!(!validation.identity_consistent); +} + +#[test] +fn signing_identity_requires_a_successful_live_audit_token_check() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + "audit-token-bound-signing", + 1, + None, + ); + receipt.signing.audit_token_bound_valid = false; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated receipt should remain bounded"); + assert!(!validation.identity_consistent); +} + +#[test] +fn app_parent_signing_must_be_bound_to_its_audit_token_process() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "parent-process-bound-signing", + 1, + None, + ); + receipt + .launcher + .parent_signing + .as_mut() + .expect("app parent signing should exist") + .process_bound_pid = 2; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated receipt should remain bounded"); + assert!(!validation.identity_consistent); +} + +#[test] +fn app_parent_signing_requires_a_successful_live_audit_token_check() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "parent-audit-token-check", + 1, + None, + ); + receipt + .launcher + .parent_signing + .as_mut() + .expect("app parent signing should exist") + .audit_token_bound_valid = false; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated receipt should remain bounded"); + assert!(!validation.identity_consistent); +} + +#[test] +fn app_parent_signing_observation_rejects_a_different_pidversion() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "parent-audit-token-bound-signing", + 1, + None, + ); + let witness_path = directory.path().join(format!( + "{}.witness.json", + receipt.system_settings_identity_witness_id + )); + let mut witness: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&witness_path).expect("settings witness should read")) + .expect("settings witness should decode"); + witness.parent_signing_audit_token_identity = + Some("00000000:00000000:00000000:00000000:00000000:00000001:00000000:00000002".to_owned()); + write_json(&witness_path, &witness); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated witness should remain bounded"); + assert!(!validation.identity_consistent); +} + +#[test] +fn failing_direct_grant_does_not_erase_sidecar_qualification() { + let validation = validate_full_signed_matrix(Some(( + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + ))); + let keyboard = validation + .capability_qualifications + .iter() + .find(|qualification| qualification.capability == MacosTccCanaryCapability::Keyboard) + .expect("keyboard qualification should exist"); + assert_eq!( + keyboard.preferred_topology, + Some(MacosDaemonOwner::AppSidecar) + ); + assert!( + keyboard + .qualified_topologies + .contains(&MacosDaemonOwner::AppSidecar) + ); + assert!( + !keyboard + .qualified_topologies + .contains(&MacosDaemonOwner::DirectLaunchd) + ); + assert!(!keyboard.app_broker_required); +} + +#[test] +fn a_single_signed_receipt_never_claims_physical_acceptance() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "one-row", + 1, + None, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("bounded receipt should parse"); + assert!(validation.receipt_structure_valid); + assert!(!validation.preferred_topology_eligible); + assert!(!validation.physical_acceptance_claimed); + assert!(!validation.missing_requirements.is_empty()); +} + +#[test] +fn corrupted_witness_evidence_is_rejected_before_validation() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + let evidence_dir = directory.path().join("evidence"); + fs::create_dir(&evidence_dir).expect("evidence directory should create"); + MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "one-row", + 1, + None, + ); + let evidence_path = fs::read_dir(&evidence_dir) + .expect("evidence directory should read") + .next() + .expect("evidence file should exist") + .expect("evidence entry should read") + .path(); + fs::write(evidence_path, b"corrupted witness evidence") + .expect("evidence corruption should write"); + + assert!(validate_macos_tcc_canary_receipts(directory.path()).is_err()); +} + +#[test] +fn receipt_identity_rejects_wrong_bundle_and_requirement_hash() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "identity-row", + 1, + None, + ); + receipt.signing.bundle_identifier = "tech.hyperbliss.hypercolor.daemon".to_owned(); + receipt.signing.designated_requirement_sha256 = "f".repeat(64); + receipt.signing.authorities.clear(); + receipt.signing.entitlement_keys.pop(); + receipt.audit_token_identity = + "00000000:00000000:00000000:00000000:00000000:ffffffff:00000000:00000000".to_owned(); + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("bounded receipt should parse"); + assert!(!validation.identity_consistent); + assert!(!validation.preferred_topology_eligible); + assert!( + validation + .missing_requirements + .contains(&"signed_launcher_identity".to_owned()) + ); +} + +#[test] +fn lifecycle_links_reject_cross_context_predecessors_and_null_input_proof() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut fixture = MatrixFixture::default(); + let mut denied = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Deny, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "denial-scenario", + 1, + None, + ); + let mut later = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::LaterGrant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "denial-scenario", + 1, + Some(&denied), + ); + later.scenario_id = "another-scenario".to_owned(); + later.capabilities[0].tap_mask = None; + later.capabilities[0].redacted_event_count = None; + denied.process_fingerprint = "e".repeat(64); + write_json( + &directory + .path() + .join(format!("{}.receipt.json", denied.row_id)), + &denied, + ); + write_json( + &directory + .path() + .join(format!("{}.receipt.json", later.row_id)), + &later, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("receipts should parse"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_predecessor_context", later.row_id)) + ); + assert!( + validation + .missing_requirements + .contains(&format!("{}_keyboard_operation", later.row_id)) + ); + assert!( + validation + .missing_requirements + .contains(&format!("{}_process_replacement_witness", later.row_id)) + ); +} + +#[test] +fn lifecycle_requires_predecessor_completion_and_the_exact_launcher_action() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut fixture = MatrixFixture::default(); + let mut denied = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Deny, + "intel", + "26.0", + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + "ordered-replacement", + 1, + None, + ); + let later = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::LaterGrant, + "intel", + "26.0", + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + "ordered-replacement", + 1, + Some(&denied), + ); + denied.operation_finished_unix_ms = later.process_started_unix_ms + 1; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", denied.row_id)), + &denied, + ); + let witness_path = directory.path().join(format!( + "{}.witness.json", + later + .process_replacement_witness_id + .as_deref() + .expect("replacement witness should exist") + )); + let mut replacement: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&witness_path).expect("replacement witness should read")) + .expect("replacement witness should decode"); + replacement.launcher_action = Some("launchctl_kickstart".to_owned()); + write_json(&witness_path, &replacement); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated lifecycle should remain bounded"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_predecessor_chronology", later.row_id)) + ); + assert!( + validation + .missing_requirements + .contains(&format!("{}_process_replacement_witness", later.row_id)) + ); +} + +#[test] +fn full_app_relaunch_requires_the_predecessor_app_to_exit() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut fixture = MatrixFixture::default(); + let grant = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "app-relaunch-parent", + 1, + None, + ); + let relaunch = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::AppRelaunch, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "app-relaunch-parent", + 1, + Some(&grant), + ); + let witness_path = directory.path().join(format!( + "{}.witness.json", + relaunch + .process_replacement_witness_id + .as_deref() + .expect("replacement witness should exist") + )); + let mut replacement: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&witness_path).expect("replacement witness should read")) + .expect("replacement witness should decode"); + replacement.predecessor_parent_exit_observed = Some(false); + write_json(&witness_path, &replacement); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("mutated lifecycle should remain bounded"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_process_replacement_witness", relaunch.row_id)) + ); +} + +#[test] +fn replacement_identity_allows_pid_reuse_when_pidversion_changes() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut fixture = MatrixFixture::default(); + let denied = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Deny, + "intel", + "26.0", + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + "pid-reuse", + 1, + None, + ); + let mut later = fixture.add_receipt( + directory.path(), + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::LaterGrant, + "intel", + "26.0", + MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + "pid-reuse", + 1, + Some(&denied), + ); + later.pid = denied.pid; + later.audit_token_identity = format!( + "00000000:00000000:00000000:00000000:00000000:{:08x}:00000000:ffffffff", + denied.pid + ); + later.signing.process_bound_pid = denied.pid; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", later.row_id)), + &later, + ); + let settings_path = directory.path().join(format!( + "{}.witness.json", + later.system_settings_identity_witness_id + )); + let mut settings: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&settings_path).expect("settings witness should read")) + .expect("settings witness should decode"); + settings.observed_pid = Some(later.pid); + settings.observed_audit_token_identity = Some(later.audit_token_identity.clone()); + write_json(&settings_path, &settings); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("PID-reuse receipts should remain bounded"); + assert!( + !validation + .missing_requirements + .contains(&format!("{}_process_replacement", later.row_id)) + ); +} + +#[test] +fn rosetta_receipt_never_qualifies_an_apple_silicon_cell() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "rosetta-row", + 1, + None, + ); + receipt.executable_slice = "x86_64".to_owned(); + receipt.translated_process = true; + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("receipt should parse"); + assert!(validation.receipt_structure_valid); + assert!( + validation + .missing_requirements + .contains(&"appsidecar_keyboard_apple_silicon_tahoe_26_grant".to_owned()) + ); +} + +#[test] +fn a_future_macos_major_does_not_substitute_for_tahoe_26() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "27.0", + MacosTccCanaryInstallationScenario::AppOnly, + "future-major", + 1, + None, + ); + + let validation = validate_macos_tcc_canary_receipts(directory.path()) + .expect("future-major receipt should remain bounded"); + assert!( + validation + .missing_requirements + .contains(&"appsidecar_keyboard_apple_silicon_tahoe_26_grant".to_owned()) + ); +} + +#[test] +fn keyboard_receipt_requires_the_complete_requested_tap_mask() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "incomplete-mask", + 1, + None, + ); + receipt.capabilities[0].requested_tap_mask = Some(0b111); + receipt.capabilities[0].tap_mask = Some(0b001); + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("receipt should parse"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_keyboard_operation", receipt.row_id)) + ); + assert!(!validation.preferred_topology_eligible); +} + +#[test] +fn stream_restart_receipt_requires_the_post_authorization_probe_shape() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let mut receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Stream, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppOnly, + "stream-restart-row", + 1, + None, + ); + let stream = receipt + .capabilities + .iter_mut() + .find(|evidence| evidence.capability == MacosTccCanaryCapability::Stream) + .expect("stream evidence should exist"); + stream.outcome = MacosTccCanaryOutcome::NeedsProcessRestart; + stream.resulting_api_state = "needs_process_restart".to_owned(); + stream.typed_error = Some("post_authorization_stream_requires_restart".to_owned()); + stream.tcc_request_result = Some(true); + stream.tcc_preflight_after = Some(true); + stream.picker_presented = Some(false); + stream.picker_selected = Some(false); + stream.stream_started = Some(false); + stream.first_complete_frame = Some(false); + stream.first_frame_monotonic_ns = None; + let picker = receipt + .capabilities + .iter_mut() + .find(|evidence| evidence.capability == MacosTccCanaryCapability::Picker) + .expect("picker evidence should exist"); + picker.outcome = MacosTccCanaryOutcome::Failed; + picker.resulting_api_state = "failed".to_owned(); + picker.typed_error = Some("stream_restart_required_before_picker".to_owned()); + picker.picker_presented = Some(false); + picker.picker_selected = Some(false); + write_json( + &directory + .path() + .join(format!("{}.receipt.json", receipt.row_id)), + &receipt, + ); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("receipt should parse"); + assert!( + !validation + .missing_requirements + .contains(&format!("{}_process_restart_evidence", receipt.row_id)) + ); + + stream_restart_receipt_mutation_is_rejected(directory.path(), receipt); +} + +fn stream_restart_receipt_mutation_is_rejected( + directory: &Path, + mut receipt: MacosTccCanaryReceipt, +) { + let path = directory.join(format!("{}.receipt.json", receipt.row_id)); + fs::remove_file(&path).expect("original receipt should remove"); + let picker = receipt + .capabilities + .iter_mut() + .find(|evidence| evidence.capability == MacosTccCanaryCapability::Picker) + .expect("picker evidence should exist"); + picker.outcome = MacosTccCanaryOutcome::Passed; + "ready_idle".clone_into(&mut picker.resulting_api_state); + picker.typed_error = None; + picker.picker_presented = Some(true); + picker.picker_selected = Some(true); + write_json(&path, &receipt); + + let validation = validate_macos_tcc_canary_receipts(directory).expect("receipt should parse"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_process_restart_evidence", receipt.row_id)) + ); +} + +#[test] +fn mixed_installation_requires_login_bound_arbitration_evidence() { + let directory = tempfile::tempdir().expect("temporary directory should exist"); + fs::create_dir(directory.path().join("evidence")).expect("evidence directory should create"); + let receipt = MatrixFixture::default().add_receipt( + directory.path(), + MacosDaemonOwner::AppSidecar, + MacosTccCanaryCapability::Pointer, + MacosTccCanaryLifecyclePhase::Grant, + "apple_silicon", + "26.0", + MacosTccCanaryInstallationScenario::AppDirectAppEnabledFirst, + "mixed-login", + 1, + None, + ); + let witness_id = receipt + .login_arbitration_witness_id + .as_deref() + .expect("mixed row should name a login witness"); + let witness_path = directory.path().join(format!("{witness_id}.witness.json")); + let mut witness: MacosTccCanaryWitness = + serde_json::from_slice(&fs::read(&witness_path).expect("witness should read")) + .expect("witness should decode"); + witness.selected_topology = Some(MacosDaemonOwner::DirectLaunchd); + write_json(&witness_path, &witness); + + let validation = + validate_macos_tcc_canary_receipts(directory.path()).expect("receipt should parse"); + assert!( + validation + .missing_requirements + .contains(&format!("{}_login_arbitration_witness", receipt.row_id)) + ); + assert!(validation.missing_requirements.contains( + &"installation_appdirectappenabledfirst_apple_silicon_tahoe_26_repeated_login".to_owned() + )); +} + +fn evidence_for( + capability: MacosTccCanaryCapability, + outcome: MacosTccCanaryOutcome, + phase: MacosTccCanaryLifecyclePhase, +) -> Vec { + if capability == MacosTccCanaryCapability::Stream { + let picker_outcome = if outcome == MacosTccCanaryOutcome::Denied { + MacosTccCanaryOutcome::Denied + } else { + MacosTccCanaryOutcome::Passed + }; + vec![ + evidence(MacosTccCanaryCapability::Picker, picker_outcome, phase), + evidence(capability, outcome, phase), + ] + } else { + vec![evidence(capability, outcome, phase)] + } +} + +fn evidence( + capability: MacosTccCanaryCapability, + outcome: MacosTccCanaryOutcome, + phase: MacosTccCanaryLifecyclePhase, +) -> MacosTccCanaryCapabilityEvidence { + let passed = outcome == MacosTccCanaryOutcome::Passed; + let denied = outcome == MacosTccCanaryOutcome::Denied; + let revoked = outcome == MacosTccCanaryOutcome::Revoked; + let tcc_protected = capability != MacosTccCanaryCapability::Pointer; + let persistent = matches!( + phase, + MacosTccCanaryLifecyclePhase::OwnerRestart + | MacosTccCanaryLifecyclePhase::AppRelaunch + | MacosTccCanaryLifecyclePhase::ServiceRestart + | MacosTccCanaryLifecyclePhase::SignedUpdate + ); + MacosTccCanaryCapabilityEvidence { + capability, + outcome, + resulting_api_state: api_state(capability, outcome).to_owned(), + typed_error: None, + tcc_preflight_before: tcc_protected.then_some(persistent || revoked), + tcc_request_result: (tcc_protected && phase == MacosTccCanaryLifecyclePhase::LaterGrant) + .then_some(true), + tcc_preflight_after: tcc_protected.then_some(passed), + requested_tap_mask: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(if capability == MacosTccCanaryCapability::Keyboard { + 1 + } else { + 2 + }), + tap_mask: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(if capability == MacosTccCanaryCapability::Keyboard { + 1 + } else { + 2 + }), + tap_created: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(passed), + tap_enabled: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(passed), + run_loop_started: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(passed), + redacted_event_count: matches!( + capability, + MacosTccCanaryCapability::Keyboard | MacosTccCanaryCapability::Pointer + ) + .then_some(u64::from(passed)), + picker_presented: matches!( + capability, + MacosTccCanaryCapability::Picker | MacosTccCanaryCapability::Stream + ) + .then_some(!denied), + picker_selected: matches!( + capability, + MacosTccCanaryCapability::Picker | MacosTccCanaryCapability::Stream + ) + .then_some(!denied), + stream_started: (capability == MacosTccCanaryCapability::Stream) + .then_some(passed || revoked), + first_complete_frame: (capability == MacosTccCanaryCapability::Stream) + .then_some(passed || revoked), + first_frame_monotonic_ns: (capability == MacosTccCanaryCapability::Stream + && (passed || revoked)) + .then_some(1_000_000), + resource_live_before_revocation: (phase == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(revoked), + resource_failed_after_revocation: (phase == MacosTccCanaryLifecyclePhase::RevokeWhileLive) + .then_some(revoked), + } +} + +fn witness( + receipt: &MacosTccCanaryReceipt, + witness_id: String, + kind: MacosTccCanaryWitnessKind, + predecessor: Option<&MacosTccCanaryReceipt>, +) -> MacosTccCanaryWitness { + let evidence = format!("{RUN_ID}:{}:{witness_id}", receipt.row_id); + let login_witness = kind == MacosTccCanaryWitnessKind::LoginArbitration; + let settings_witness = kind == MacosTccCanaryWitnessKind::SystemSettingsIdentity; + let replacement_witness = kind == MacosTccCanaryWitnessKind::ProcessReplacement; + let lifecycle_witness = kind == MacosTccCanaryWitnessKind::LifecycleAction; + let replaces_app_parent = replacement_witness + && receipt.topology == MacosDaemonOwner::AppSidecar + && matches!( + receipt.lifecycle_phase, + MacosTccCanaryLifecyclePhase::AppRelaunch | MacosTccCanaryLifecyclePhase::SignedUpdate + ); + let installed_topologies = + login_witness.then(|| scenario_topologies(receipt.installation_scenario).to_vec()); + let enable_order = login_witness.then(|| scenario_enable_order(receipt.installation_scenario)); + let losing_topologies = login_witness.then(|| { + scenario_topologies(receipt.installation_scenario) + .iter() + .copied() + .filter(|topology| *topology != receipt.topology) + .collect() + }); + MacosTccCanaryWitness { + schema_version: MACOS_TCC_CANARY_SCHEMA_VERSION, + run_id: RUN_ID.to_owned(), + row_id: receipt.row_id.clone(), + witness_id, + kind, + observer: "fixture-observer".to_owned(), + observed_unix_ms: if settings_witness { + receipt.process_started_unix_ms + 1 + } else if let Some(predecessor) = predecessor { + predecessor.operation_finished_unix_ms + 1 + } else { + receipt.process_started_unix_ms.saturating_sub(1) + }, + evidence_sha256: hex_digest(evidence.as_bytes()), + prompt_text: settings_witness.then(|| receipt.expected_prompt_text.clone()), + system_settings_entry: settings_witness + .then(|| receipt.expected_system_settings_entry.clone()), + observed_pid: settings_witness.then_some(receipt.pid), + observed_audit_token_identity: settings_witness + .then(|| receipt.audit_token_identity.clone()), + observed_signing_audit_token_identity: settings_witness + .then(|| receipt.audit_token_identity.clone()), + observed_cdhash: settings_witness.then(|| receipt.signing.cdhash.clone()), + observed_designated_requirement_sha256: settings_witness + .then(|| receipt.signing.designated_requirement_sha256.clone()), + observed_process_fingerprint: settings_witness.then(|| receipt.process_fingerprint.clone()), + parent_pid: (settings_witness && receipt.topology == MacosDaemonOwner::AppSidecar) + .then_some(1), + parent_audit_token_identity: (settings_witness + && receipt.topology == MacosDaemonOwner::AppSidecar) + .then(|| { + "00000000:00000000:00000000:00000000:00000000:00000001:00000000:00000001".to_owned() + }), + parent_signing_audit_token_identity: (settings_witness + && receipt.topology == MacosDaemonOwner::AppSidecar) + .then(|| { + "00000000:00000000:00000000:00000000:00000000:00000001:00000000:00000001".to_owned() + }), + parent_cdhash: (settings_witness && receipt.topology == MacosDaemonOwner::AppSidecar) + .then(|| "c".repeat(40)), + parent_designated_requirement_sha256: (settings_witness + && receipt.topology == MacosDaemonOwner::AppSidecar) + .then(|| { + receipt + .launcher + .parent_signing + .as_ref() + .expect("app parent signing should exist") + .designated_requirement_sha256 + .clone() + }), + parent_process_fingerprint: (settings_witness + && receipt.topology == MacosDaemonOwner::AppSidecar) + .then(|| "d".repeat(64)), + fresh_tcc_database_observed: (kind == MacosTccCanaryWitnessKind::FreshTccReset) + .then_some(true), + predecessor_pid: predecessor.map(|receipt| receipt.pid), + predecessor_audit_token_identity: predecessor + .map(|receipt| receipt.audit_token_identity.clone()), + predecessor_process_fingerprint: predecessor + .map(|receipt| receipt.process_fingerprint.clone()), + predecessor_exit_observed: predecessor.map(|_| true), + predecessor_parent_pid: if replaces_app_parent { + predecessor.and_then(|receipt| receipt.launcher.parent_pid) + } else { + None + }, + predecessor_parent_audit_token_identity: replaces_app_parent.then(|| { + "00000000:00000000:00000000:00000000:00000000:00000001:00000000:00000001".to_owned() + }), + predecessor_parent_process_fingerprint: replaces_app_parent.then(|| { + predecessor + .and_then(|receipt| receipt.launcher.parent_signing.as_ref()) + .expect("app predecessor parent signing should exist") + .process_bound_fingerprint + .clone() + }), + predecessor_parent_exit_observed: replaces_app_parent.then_some(true), + launcher_action: (replacement_witness || lifecycle_witness).then(|| { + expected_launcher_action(receipt.topology, receipt.lifecycle_phase).to_owned() + }), + installed_topologies, + enable_order, + selected_topology: login_witness.then_some(receipt.topology), + losing_topologies, + owner_conflict_observed: login_witness.then_some(true), + login_iteration: login_witness.then_some(receipt.login_iteration), + login_session_id: login_witness.then(|| { + format!( + "login-session-{}-{}", + receipt.scenario_id, receipt.login_iteration + ) + }), + } +} + +fn write_witness(directory: &Path, witness: MacosTccCanaryWitness) { + let evidence = format!("{RUN_ID}:{}:{}", witness.row_id, witness.witness_id); + assert_eq!(hex_digest(evidence.as_bytes()), witness.evidence_sha256); + let evidence_path = directory + .join("evidence") + .join(format!("{}.bin", witness.evidence_sha256)); + if !evidence_path.exists() { + fs::write(&evidence_path, evidence).expect("witness evidence should write"); + } + write_json( + &directory.join(format!("{}.witness.json", witness.witness_id)), + &witness, + ); +} + +fn write_json(path: &Path, value: &impl serde::Serialize) { + fs::write( + path, + serde_json::to_vec_pretty(value).expect("JSON should encode"), + ) + .expect("JSON should write"); +} + +fn receipt_matching( + directory: &Path, + predicate: impl Fn(&MacosTccCanaryReceipt) -> bool, +) -> (PathBuf, MacosTccCanaryReceipt) { + fs::read_dir(directory) + .expect("fixture directory should read") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".receipt.json")) + }) + .find_map(|path| { + let receipt = serde_json::from_slice::( + &fs::read(&path).expect("receipt should read"), + ) + .expect("receipt should decode"); + predicate(&receipt).then_some((path, receipt)) + }) + .expect("matching fixture receipt should exist") +} + +fn launcher(topology: MacosDaemonOwner) -> MacosTccCanaryLauncherEvidence { + let (actual_launcher, expected_label, parent_signing) = match topology { + MacosDaemonOwner::AppSidecar => ( + "packaged_app_supervisor", + None, + Some(app_signing(1, &"d".repeat(64))), + ), + MacosDaemonOwner::DirectLaunchd => ( + "direct_launchd", + Some("tech.hyperbliss.hypercolor".to_owned()), + None, + ), + MacosDaemonOwner::Homebrew => ( + "homebrew_services", + Some("homebrew.mxcl.hypercolor".to_owned()), + None, + ), + MacosDaemonOwner::Standalone => ("terminal_parent", None, None), + }; + MacosTccCanaryLauncherEvidence { + actual_launcher: actual_launcher.to_owned(), + expected_label, + parent_pid: Some(1), + parent_executable_path: Some(if topology == MacosDaemonOwner::Standalone { + PathBuf::from("/bin/zsh") + } else { + PathBuf::from("/Applications/Hypercolor.app/Contents/MacOS/Hypercolor") + }), + parent_signing, + launchctl_pid_matches: Some(true), + verified: true, + } +} + +fn app_signing(pid: u32, process_fingerprint: &str) -> MacosTccCanarySigningEvidence { + let designated_requirement = "identifier tech.hyperbliss.hypercolor and anchor apple generic"; + MacosTccCanarySigningEvidence { + bundle_identifier: "tech.hyperbliss.hypercolor".to_owned(), + team_identifier: "TEAMID1234".to_owned(), + designated_requirement: designated_requirement.to_owned(), + designated_requirement_sha256: hex_digest(designated_requirement.as_bytes()), + cdhash: "c".repeat(40), + process_bound_pid: pid, + process_bound_fingerprint: process_fingerprint.to_owned(), + process_bound_valid: true, + audit_token_bound_valid: true, + authorities: vec!["Developer ID Application: Hypercolor (TEAMID1234)".to_owned()], + entitlement_keys: required_entitlements(), + codesign_strict_valid: true, + hardened_runtime: true, + secure_timestamp: true, + spctl_accepted: true, + } +} + +fn signing( + topology: MacosDaemonOwner, + signed_update: bool, + pid: u32, + process_fingerprint: &str, +) -> MacosTccCanarySigningEvidence { + let designated_requirement = match topology { + MacosDaemonOwner::AppSidecar => { + "identifier tech.hyperbliss.hypercolor.sidecar and anchor apple generic" + } + MacosDaemonOwner::DirectLaunchd + | MacosDaemonOwner::Homebrew + | MacosDaemonOwner::Standalone => { + "identifier tech.hyperbliss.hypercolor.daemon and anchor apple generic" + } + }; + MacosTccCanarySigningEvidence { + bundle_identifier: match topology { + MacosDaemonOwner::AppSidecar => "tech.hyperbliss.hypercolor.sidecar", + MacosDaemonOwner::DirectLaunchd + | MacosDaemonOwner::Homebrew + | MacosDaemonOwner::Standalone => "tech.hyperbliss.hypercolor.daemon", + } + .to_owned(), + team_identifier: "TEAMID1234".to_owned(), + designated_requirement: designated_requirement.to_owned(), + designated_requirement_sha256: hex_digest(designated_requirement.as_bytes()), + cdhash: if signed_update { "b" } else { "a" }.repeat(40), + process_bound_pid: pid, + process_bound_fingerprint: process_fingerprint.to_owned(), + process_bound_valid: true, + audit_token_bound_valid: true, + authorities: vec!["Developer ID Application: Hypercolor (TEAMID1234)".to_owned()], + entitlement_keys: required_entitlements(), + codesign_strict_valid: true, + hardened_runtime: true, + secure_timestamp: true, + spctl_accepted: true, + } +} + +fn expected_outcome(phase: MacosTccCanaryLifecyclePhase) -> MacosTccCanaryOutcome { + match phase { + MacosTccCanaryLifecyclePhase::Deny => MacosTccCanaryOutcome::Denied, + MacosTccCanaryLifecyclePhase::RevokeWhileLive => MacosTccCanaryOutcome::Revoked, + _ => MacosTccCanaryOutcome::Passed, + } +} + +fn api_state(capability: MacosTccCanaryCapability, outcome: MacosTccCanaryOutcome) -> &'static str { + match outcome { + MacosTccCanaryOutcome::Passed if capability == MacosTccCanaryCapability::Picker => { + "ready_idle" + } + MacosTccCanaryOutcome::Passed => "live", + MacosTccCanaryOutcome::Denied => "permission_denied", + MacosTccCanaryOutcome::Revoked => "revoked", + MacosTccCanaryOutcome::NeedsProcessRestart => "needs_process_restart", + MacosTccCanaryOutcome::Cancelled => "needs_selection", + MacosTccCanaryOutcome::TimedOut => "interrupted", + MacosTccCanaryOutcome::Failed => "failed", + } +} + +fn topologies() -> [MacosDaemonOwner; 4] { + [ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Homebrew, + MacosDaemonOwner::Standalone, + ] +} + +fn capabilities() -> [MacosTccCanaryCapability; 4] { + [ + MacosTccCanaryCapability::Keyboard, + MacosTccCanaryCapability::Pointer, + MacosTccCanaryCapability::Picker, + MacosTccCanaryCapability::Stream, + ] +} + +fn single_scenario(topology: MacosDaemonOwner) -> MacosTccCanaryInstallationScenario { + match topology { + MacosDaemonOwner::AppSidecar => MacosTccCanaryInstallationScenario::AppOnly, + MacosDaemonOwner::DirectLaunchd => MacosTccCanaryInstallationScenario::DirectLaunchdOnly, + MacosDaemonOwner::Homebrew => MacosTccCanaryInstallationScenario::HomebrewOnly, + MacosDaemonOwner::Standalone => MacosTccCanaryInstallationScenario::StandaloneOnly, + } +} + +fn topology_phases(topology: MacosDaemonOwner) -> Vec { + match topology { + MacosDaemonOwner::AppSidecar => vec![ + MacosTccCanaryLifecyclePhase::AppLaunch, + MacosTccCanaryLifecyclePhase::OwnerRestart, + MacosTccCanaryLifecyclePhase::AppRelaunch, + MacosTccCanaryLifecyclePhase::SignedUpdate, + ], + MacosDaemonOwner::DirectLaunchd | MacosDaemonOwner::Homebrew => vec![ + MacosTccCanaryLifecyclePhase::ServiceInstall, + MacosTccCanaryLifecyclePhase::LoginStart, + MacosTccCanaryLifecyclePhase::ServiceRestart, + MacosTccCanaryLifecyclePhase::SignedUpdate, + ], + MacosDaemonOwner::Standalone => vec![MacosTccCanaryLifecyclePhase::SignedUpdate], + } +} + +fn phase_needs_predecessor(phase: MacosTccCanaryLifecyclePhase) -> bool { + matches!( + phase, + MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation + | MacosTccCanaryLifecyclePhase::OwnerRestart + | MacosTccCanaryLifecyclePhase::AppRelaunch + | MacosTccCanaryLifecyclePhase::ServiceRestart + | MacosTccCanaryLifecyclePhase::SignedUpdate + ) +} + +fn phase_replaces_process(phase: MacosTccCanaryLifecyclePhase) -> bool { + phase_needs_predecessor(phase) +} + +fn phase_needs_lifecycle_action_witness(phase: MacosTccCanaryLifecyclePhase) -> bool { + matches!( + phase, + MacosTccCanaryLifecyclePhase::AppLaunch + | MacosTccCanaryLifecyclePhase::ServiceInstall + | MacosTccCanaryLifecyclePhase::LoginStart + ) +} + +fn expected_launcher_action( + topology: MacosDaemonOwner, + phase: MacosTccCanaryLifecyclePhase, +) -> &'static str { + match (topology, phase) { + (MacosDaemonOwner::AppSidecar, MacosTccCanaryLifecyclePhase::AppLaunch) => { + "app_minimized_launch" + } + (MacosDaemonOwner::AppSidecar, MacosTccCanaryLifecyclePhase::OwnerRestart) => { + "app_supervisor_daemon_restart" + } + (MacosDaemonOwner::AppSidecar, MacosTccCanaryLifecyclePhase::AppRelaunch) => { + "app_quit_then_minimized_launch" + } + ( + MacosDaemonOwner::AppSidecar, + MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation, + ) => "app_supervisor_daemon_restart_after_authorization", + (MacosDaemonOwner::AppSidecar, MacosTccCanaryLifecyclePhase::SignedUpdate) => { + "signed_app_update_then_app_relaunch" + } + (MacosDaemonOwner::DirectLaunchd, MacosTccCanaryLifecyclePhase::ServiceInstall) => { + "hypercolor_service_enable" + } + (MacosDaemonOwner::DirectLaunchd, MacosTccCanaryLifecyclePhase::LoginStart) => { + "launchd_login_start" + } + (MacosDaemonOwner::DirectLaunchd, MacosTccCanaryLifecyclePhase::ServiceRestart) => { + "hypercolor_service_restart" + } + ( + MacosDaemonOwner::DirectLaunchd, + MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation, + ) => "hypercolor_service_restart_after_authorization", + (MacosDaemonOwner::DirectLaunchd, MacosTccCanaryLifecyclePhase::SignedUpdate) => { + "signed_daemon_update_then_hypercolor_service_restart" + } + (MacosDaemonOwner::Homebrew, MacosTccCanaryLifecyclePhase::ServiceInstall) => { + "brew_services_start" + } + (MacosDaemonOwner::Homebrew, MacosTccCanaryLifecyclePhase::LoginStart) => { + "brew_services_login_start" + } + (MacosDaemonOwner::Homebrew, MacosTccCanaryLifecyclePhase::ServiceRestart) => { + "brew_services_restart" + } + ( + MacosDaemonOwner::Homebrew, + MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation, + ) => "brew_services_restart_after_authorization", + (MacosDaemonOwner::Homebrew, MacosTccCanaryLifecyclePhase::SignedUpdate) => { + "signed_daemon_update_then_brew_services_restart" + } + ( + MacosDaemonOwner::Standalone, + MacosTccCanaryLifecyclePhase::LaterGrant + | MacosTccCanaryLifecyclePhase::GrantAfterRevocation, + ) => "terminal_successor_launch_after_authorization", + (MacosDaemonOwner::Standalone, MacosTccCanaryLifecyclePhase::SignedUpdate) => { + "signed_daemon_update_then_terminal_launch" + } + _ => panic!("fixture requested an action for an inapplicable lifecycle phase"), + } +} + +fn platform_cells() -> [(&'static str, &'static str); 4] { + [ + ("apple_silicon", "15.2"), + ("apple_silicon", "26.0"), + ("intel", "15.2"), + ("intel", "26.0"), + ] +} + +fn scenario_needs_login_witness(scenario: MacosTccCanaryInstallationScenario) -> bool { + !matches!( + scenario, + MacosTccCanaryInstallationScenario::AppOnly + | MacosTccCanaryInstallationScenario::DirectLaunchdOnly + | MacosTccCanaryInstallationScenario::HomebrewOnly + | MacosTccCanaryInstallationScenario::StandaloneOnly + ) +} + +fn scenario_topologies( + scenario: MacosTccCanaryInstallationScenario, +) -> &'static [MacosDaemonOwner] { + match scenario { + MacosTccCanaryInstallationScenario::AppOnly => &[MacosDaemonOwner::AppSidecar], + MacosTccCanaryInstallationScenario::DirectLaunchdOnly => &[MacosDaemonOwner::DirectLaunchd], + MacosTccCanaryInstallationScenario::HomebrewOnly => &[MacosDaemonOwner::Homebrew], + MacosTccCanaryInstallationScenario::StandaloneOnly => &[MacosDaemonOwner::Standalone], + MacosTccCanaryInstallationScenario::AppDirectAppEnabledFirst + | MacosTccCanaryInstallationScenario::AppDirectDirectEnabledFirst => &[ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + ], + MacosTccCanaryInstallationScenario::AppHomebrew => { + &[MacosDaemonOwner::AppSidecar, MacosDaemonOwner::Homebrew] + } + MacosTccCanaryInstallationScenario::DirectHomebrew => { + &[MacosDaemonOwner::DirectLaunchd, MacosDaemonOwner::Homebrew] + } + MacosTccCanaryInstallationScenario::AppDirectHomebrew => &[ + MacosDaemonOwner::AppSidecar, + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::Homebrew, + ], + } +} + +fn scenario_enable_order(scenario: MacosTccCanaryInstallationScenario) -> Vec { + match scenario { + MacosTccCanaryInstallationScenario::AppDirectDirectEnabledFirst => vec![ + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::AppSidecar, + ], + _ => scenario_topologies(scenario).to_vec(), + } +} + +fn required_entitlements() -> Vec { + [ + "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", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn mixed_scenarios() -> [(MacosTccCanaryInstallationScenario, MacosDaemonOwner); 5] { + [ + ( + MacosTccCanaryInstallationScenario::AppDirectAppEnabledFirst, + MacosDaemonOwner::AppSidecar, + ), + ( + MacosTccCanaryInstallationScenario::AppDirectDirectEnabledFirst, + MacosDaemonOwner::DirectLaunchd, + ), + ( + MacosTccCanaryInstallationScenario::AppHomebrew, + MacosDaemonOwner::AppSidecar, + ), + ( + MacosTccCanaryInstallationScenario::DirectHomebrew, + MacosDaemonOwner::DirectLaunchd, + ), + ( + MacosTccCanaryInstallationScenario::AppDirectHomebrew, + MacosDaemonOwner::Homebrew, + ), + ] +} + +fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .fold(String::with_capacity(64), |mut output, byte| { + write!(&mut output, "{byte:02x}").expect("writing to a string should succeed"); + output + }) +} 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..4f3daf657 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() @@ -61,6 +75,51 @@ async fn loopback_origin_receives_cors_headers() { assert!(response.headers().contains_key(header::VARY)); } +#[tokio::test] +async fn exact_bundled_tauri_origins_receive_cors_headers() { + for origin in [ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + ] { + let response = test_app() + .oneshot( + Request::builder() + .uri("/api/v1/status") + .header(header::ORIGIN, origin) + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], + origin + ); + } + + for origin in ["tauri://attacker.example", "https://tauri.localhost.evil"] { + let response = test_app() + .oneshot( + Request::builder() + .uri("/api/v1/status") + .header(header::ORIGIN, origin) + .body(Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + assert!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); + } +} + #[tokio::test] async fn public_origin_does_not_receive_cors_headers() { let response = test_app() @@ -107,3 +166,138 @@ 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_reject_unauthenticated_loopback_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 response = app + .clone() + .oneshot(request_from(Ipv4Addr::LOCALHOST, method, path)) + .await + .expect("local protected request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } +} + +#[tokio::test] +async fn privacy_bearing_config_and_diagnose_reject_unauthenticated_loopback_clients() { + let app = test_app(); + for (path, body) in [ + ( + "/api/v1/config/set", + r#"{"key":"capture.enabled","value":"true"}"#, + ), + ("/api/v1/config/reset", "{}"), + ("/api/v1/diagnose", r#"{"checks":["macos_screen_parity"]}"#), + ] { + let mut request = Request::builder() + .method(Method::POST) + .uri(path) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from((Ipv4Addr::LOCALHOST, 9420)))); + + let response = app + .clone() + .oneshot(request) + .await + .expect("local privacy-bearing request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } +} + +#[tokio::test] +async fn protected_capture_routes_accept_trusted_in_process_control() { + let api = api::local::TrustedLocalApi::new(Arc::new(isolated_state())); + 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 response = api + .execute( + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("trusted request should build"), + ) + .await + .expect("trusted protected request should complete"); + + assert_ne!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } +} diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml new file mode 100644 index 000000000..8f82d02b7 --- /dev/null +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -0,0 +1,105 @@ +[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 } +tracing = { workspace = true } +uuid = { 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", + "CFData", + "CFNumber", + "CFString", + "CFURL", + "CFUUID", + "objc2", +] } +objc2-core-graphics = { workspace = true, features = [ + "std", + "CGBitmapContext", + "CGColorSpace", + "CGContext", + "CGDataProvider", + "CGGeometry", + "CGImage", + "CGToneMapping", + "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", + "SCScreenshotManager", + "SCStream", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", +] } + +[[test]] +name = "capture_contract_tests" +path = "tests/capture_contract_tests.rs" +required-features = ["capture-fixtures"] 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/examples/dump_macos_frame.rs b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs new file mode 100644 index 000000000..d7c75a94a --- /dev/null +++ b/crates/hypercolor-macos-capture/examples/dump_macos_frame.rs @@ -0,0 +1,572 @@ +//! 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; + +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; +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())?; + 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 + .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}"))?; + 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/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/cpu.rs b/crates/hypercolor-macos-capture/src/cpu.rs new file mode 100644 index 000000000..754051dac --- /dev/null +++ b/crates/hypercolor-macos-capture/src/cpu.rs @@ -0,0 +1,466 @@ +use crate::{ + 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 { + /// 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> { + let row_bytes = usize::try_from(self.storage_extent.width) + .ok() + .and_then(|width| width.checked_mul(RGBA_CHANNELS * size_of::())) + .ok_or(MacosCaptureError::ArithmeticOverflow)?; + 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 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, + )); + } + 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[0]; + 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 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, + 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( + 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(()) +} + +fn normalize_u8(value: u8) -> f32 { + f32::from(value) / 255.0 +} + +fn read_msb_10(bytes: &[u8]) -> u16 { + u16::from_le_bytes([bytes[0], bytes[1]]) >> 6 +} + +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), + }; + 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 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 decoded = match exponent { + 0x1f => sign | 0x7f80_0000 | (fraction << 13), + _ => sign | ((exponent + 112) << 23) | (fraction << 13), + }; + f32::from_bits(decoded) +} diff --git a/crates/hypercolor-macos-capture/src/diagnostics.rs b/crates/hypercolor-macos-capture/src/diagnostics.rs new file mode 100644 index 000000000..778a3e969 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/diagnostics.rs @@ -0,0 +1,607 @@ +#[cfg(any(target_os = "macos", test))] +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(any(target_os = "macos", test))] +use std::time::{Duration, Instant}; + +#[cfg(any(target_os = "macos", test))] +use crate::MacosCaptureError; + +#[cfg(any(target_os = "macos", test))] +const TIMING_BUCKET_WIDTH_NS: u64 = 100_000; +#[cfg(any(target_os = "macos", test))] +const TIMING_BUCKET_COUNT: usize = 4096; + +#[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, + Resource = 8, +} + +impl MacosFrameDropReason { + pub const ALL: [Self; 9] = [ + Self::InvalidSample, + Self::DataNotReady, + Self::UnexpectedOutput, + Self::Attachment, + Self::UnsupportedFormat, + Self::ColorMetadata, + Self::Surface, + Self::Validation, + Self::Resource, + ]; + + #[cfg(any(target_os = "macos", test))] + 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(_) + | MacosCaptureError::UnsupportedConfiguredDynamicRange(_) => Self::UnsupportedFormat, + MacosCaptureError::ColorMetadataMismatch + | MacosCaptureError::MissingYuvColorMetadata + | MacosCaptureError::MissingColorAttachment(_) + | MacosCaptureError::UnsupportedColorAttachment(_) + | MacosCaptureError::MalformedLuminanceAttachment(_) => Self::ColorMetadata, + MacosCaptureError::MissingFramePayload + | MacosCaptureError::InvalidSurface + | MacosCaptureError::MissingIoSurface + | MacosCaptureError::NativeSurfaceUnavailable => Self::Surface, + MacosCaptureError::InvalidCadence(_) + | MacosCaptureError::NotMainThread + | MacosCaptureError::ScreenCapturePermissionRequired + | MacosCaptureError::InvalidSourceSelector(_) + | MacosCaptureError::NativeOperation { .. } + | MacosCaptureError::RetainNativeFilterFailed + | MacosCaptureError::CaptureWorkerStartFailed(_) + | MacosCaptureError::CaptureWorkerPanicked + | MacosCaptureError::StreamStopCompletionLost + | MacosCaptureError::DisplayUuidUnavailable(_) + | MacosCaptureError::DisplaySourceUnavailable(_) + | MacosCaptureError::MissingShareableContent + | MacosCaptureError::PlaneCount { .. } + | MacosCaptureError::InvalidPlaneIndex { .. } + | MacosCaptureError::InvalidPlaneExtent { .. } + | MacosCaptureError::StrideTooSmall { .. } + | MacosCaptureError::PlaneLengthTooSmall { .. } + | MacosCaptureError::ArithmeticOverflow + | MacosCaptureError::AllocationTooSmall { .. } + | MacosCaptureError::GeometryOutsideStorage(_) + | MacosCaptureError::CpuMappingUnavailable + | MacosCaptureError::CpuPlaneLayoutMismatch + | MacosCaptureError::PixelBufferLockFailed(_) + | MacosCaptureError::PixelBufferUnlockFailed(_) + | MacosCaptureError::FixturePlaneCount { .. } + | MacosCaptureError::FixturePlaneLength { .. } + | MacosCaptureError::PixelBufferFixtureCreateFailed(_) + | MacosCaptureError::MissingCpuPlaneAddress(_) + | MacosCaptureError::UnsupportedCpuPixelFormat(_) + | MacosCaptureError::CpuPixelOutsideStorage { .. } + | MacosCaptureError::InvalidCpuDestinationStride { .. } + | MacosCaptureError::CpuDestinationTooSmall { .. } + | MacosCaptureError::SequenceExhausted + | 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, + } + } +} + +#[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, + pub malformed_frames: u64, + pub callback_sample_count: u64, + pub callback_total_ns: u64, + pub callback_max_ns: u64, + pub callback_p95_ns: u64, + pub callback_p99_ns: u64, + pub retain_sample_count: u64, + pub retain_total_ns: u64, + pub retain_max_ns: u64, + pub retain_p95_ns: u64, + pub retain_p99_ns: u64, + pub enqueue_sample_count: u64, + pub enqueue_total_ns: u64, + pub enqueue_max_ns: u64, + pub enqueue_p95_ns: u64, + pub enqueue_p99_ns: u64, + pub conversion_sample_count: u64, + pub conversion_total_ns: u64, + pub conversion_max_ns: u64, + pub conversion_p95_ns: u64, + pub conversion_p99_ns: u64, + pub publication_sample_count: u64, + pub publication_total_ns: u64, + pub publication_max_ns: u64, + pub publication_p95_ns: u64, + pub publication_p99_ns: 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.iter().sum() + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Default)] +pub(crate) struct CallbackCounters { + frames_received: AtomicU64, + frames_published: AtomicU64, + lifecycle_events: AtomicU64, + native_samples_superseded: AtomicU64, + malformed_frames: AtomicU64, + callback_timing: TimingCounters, + retain_timing: TimingCounters, + enqueue_timing: TimingCounters, + conversion_timing: TimingCounters, + publication_timing: TimingCounters, + dropped: [AtomicU64; MacosFrameDropReason::ALL.len()], +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Debug)] +struct TimingCounters { + buckets: Box<[AtomicU64]>, + generation: AtomicU64, + sample_count: AtomicU64, + total_ns: AtomicU64, + max_ns: AtomicU64, +} + +#[cfg(any(target_os = "macos", test))] +impl Default for TimingCounters { + fn default() -> Self { + Self { + buckets: (0..=TIMING_BUCKET_COUNT) + .map(|_| AtomicU64::new(0)) + .collect(), + generation: AtomicU64::new(0), + sample_count: AtomicU64::new(0), + total_ns: AtomicU64::new(0), + max_ns: AtomicU64::new(0), + } + } +} + +#[cfg(any(target_os = "macos", test))] +impl TimingCounters { + fn record(&self, elapsed: Duration) { + self.record_with_hook(elapsed, || {}); + } + + fn record_with_hook(&self, elapsed: Duration, before_complete: impl FnOnce()) { + let generation = self.begin_write(); + let nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let bucket = usize::try_from(nanos / TIMING_BUCKET_WIDTH_NS) + .unwrap_or(usize::MAX) + .min(TIMING_BUCKET_COUNT); + self.buckets[bucket].fetch_add(1, Ordering::Relaxed); + let _ = self + .total_ns + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| { + Some(total.saturating_add(nanos)) + }); + self.max_ns.fetch_max(nanos, Ordering::Relaxed); + before_complete(); + self.sample_count.fetch_add(1, Ordering::Relaxed); + self.generation + .store(generation.wrapping_add(1), Ordering::Release); + } + + fn begin_write(&self) -> u64 { + let mut generation = self.generation.load(Ordering::Relaxed); + loop { + if generation & 1 == 1 { + std::hint::spin_loop(); + generation = self.generation.load(Ordering::Acquire); + continue; + } + let started = generation.wrapping_add(1); + match self.generation.compare_exchange_weak( + generation, + started, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return started, + Err(observed) => generation = observed, + } + } + } + + fn percentile_upper_bound_ns(&self, percentile: u64, sample_count: u64, maximum: u64) -> u64 { + if sample_count == 0 { + return 0; + } + let rank = sample_count.saturating_mul(percentile).saturating_add(99) / 100; + let mut observed = 0_u64; + for (index, count) in self.buckets.iter().enumerate() { + observed = observed.saturating_add(count.load(Ordering::Relaxed)); + if observed >= rank { + if index == TIMING_BUCKET_COUNT { + return maximum; + } + return u64::try_from(index.saturating_add(1)) + .unwrap_or(u64::MAX) + .saturating_mul(TIMING_BUCKET_WIDTH_NS) + .min(maximum); + } + } + maximum + } + + fn snapshot(&self) -> (u64, u64, u64, u64, u64) { + self.snapshot_with_hooks(|| {}, || {}) + } + + fn snapshot_with_hooks( + &self, + mut retrying: impl FnMut(), + mut after_p95: impl FnMut(), + ) -> (u64, u64, u64, u64, u64) { + loop { + let generation = self.generation.load(Ordering::Acquire); + if generation & 1 == 1 { + retrying(); + std::hint::spin_loop(); + continue; + } + let sample_count = self.sample_count.load(Ordering::Relaxed); + let total_ns = self.total_ns.load(Ordering::Relaxed); + let max_ns = self.max_ns.load(Ordering::Relaxed); + let p95_ns = self.percentile_upper_bound_ns(95, sample_count, max_ns); + after_p95(); + let p99_ns = self.percentile_upper_bound_ns(99, sample_count, max_ns); + std::sync::atomic::fence(Ordering::Acquire); + if self.generation.load(Ordering::Relaxed) == generation { + return (sample_count, total_ns, max_ns, p95_ns, p99_ns); + } + retrying(); + } + } +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) struct TimingObservation<'a> { + counters: &'a TimingCounters, + started: Instant, +} + +#[cfg(any(target_os = "macos", test))] +impl Drop for TimingObservation<'_> { + fn drop(&mut self) { + self.counters.record(self.started.elapsed()); + } +} + +#[cfg(any(target_os = "macos", test))] +impl CallbackCounters { + #[cfg(target_os = "macos")] + pub(crate) fn observe_callback(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.callback_timing, + started: Instant::now(), + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn observe_retain(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.retain_timing, + started: Instant::now(), + } + } + + pub(crate) fn observe_enqueue(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.enqueue_timing, + started: Instant::now(), + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn observe_conversion(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.conversion_timing, + started: Instant::now(), + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn observe_publication(&self) -> TimingObservation<'_> { + TimingObservation { + counters: &self.publication_timing, + started: Instant::now(), + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn record_received(&self) { + self.frames_received.fetch_add(1, Ordering::Relaxed); + } + + #[cfg(target_os = "macos")] + pub(crate) fn record_published(&self) { + self.frames_published.fetch_add(1, Ordering::Relaxed); + } + + #[cfg(target_os = "macos")] + pub(crate) fn record_lifecycle(&self) { + self.lifecycle_events.fetch_add(1, Ordering::Relaxed); + } + + #[cfg(target_os = "macos")] + 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) { + // Counters alone made a stream-killing first-frame error invisible + // at every log level; the exact variant must be readable in the + // debug log without a custom build. + tracing::debug!(%error, "macOS capture frame dropped"); + 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_sample_count, + callback_total_ns, + callback_max_ns, + callback_p95_ns, + callback_p99_ns, + ) = self.callback_timing.snapshot(); + let (retain_sample_count, retain_total_ns, retain_max_ns, retain_p95_ns, retain_p99_ns) = + self.retain_timing.snapshot(); + let ( + enqueue_sample_count, + enqueue_total_ns, + enqueue_max_ns, + enqueue_p95_ns, + enqueue_p99_ns, + ) = self.enqueue_timing.snapshot(); + let ( + conversion_sample_count, + conversion_total_ns, + conversion_max_ns, + conversion_p95_ns, + conversion_p99_ns, + ) = self.conversion_timing.snapshot(); + let ( + publication_sample_count, + publication_total_ns, + publication_max_ns, + publication_p95_ns, + publication_p99_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_sample_count, + callback_total_ns, + callback_max_ns, + callback_p95_ns, + callback_p99_ns, + retain_sample_count, + retain_total_ns, + retain_max_ns, + retain_p95_ns, + retain_p99_ns, + enqueue_sample_count, + enqueue_total_ns, + enqueue_max_ns, + enqueue_p95_ns, + enqueue_p99_ns, + conversion_sample_count, + conversion_total_ns, + conversion_max_ns, + conversion_p95_ns, + conversion_p99_ns, + publication_sample_count, + publication_total_ns, + publication_max_ns, + publication_p95_ns, + publication_p99_ns, + dropped: std::array::from_fn(|index| self.dropped[index].load(Ordering::Relaxed)), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, mpsc}; + use std::thread; + use std::time::Duration; + + use crate::MacosStreamDeliveryRejection; + + use super::{CallbackCounters, 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 + ); + } + + #[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); + } + + #[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(), (2, 110, 70, 70, 70)); + } + + #[test] + fn timing_percentiles_are_bounded_by_the_exact_maximum() { + let timing = super::TimingCounters::default(); + timing.record(Duration::from_nanos(1)); + + assert_eq!(timing.snapshot(), (1, 1, 1, 1, 1)); + } + + #[test] + fn timing_snapshot_retries_when_population_changes_between_percentiles() { + let timing = super::TimingCounters::default(); + timing.record(Duration::from_nanos(40)); + let mut injected = false; + + let snapshot = timing.snapshot_with_hooks( + || {}, + || { + if !injected { + timing.record(Duration::from_nanos(70)); + injected = true; + } + }, + ); + + assert_eq!(snapshot, (2, 110, 70, 70, 70)); + } + + #[test] + fn timing_snapshot_waits_for_an_in_progress_observation() { + let timing = Arc::new(super::TimingCounters::default()); + let (writer_started_tx, writer_started_rx) = mpsc::channel(); + let (release_writer_tx, release_writer_rx) = mpsc::channel(); + let writer_timing = Arc::clone(&timing); + let writer = thread::spawn(move || { + writer_timing.record_with_hook(Duration::from_nanos(70), || { + writer_started_tx + .send(()) + .expect("writer-start signal is received"); + release_writer_rx + .recv() + .expect("writer release signal is sent"); + }); + }); + writer_started_rx + .recv() + .expect("writer reaches its incomplete population"); + + let (retry_tx, retry_rx) = mpsc::channel(); + let (snapshot_tx, snapshot_rx) = mpsc::channel(); + let snapshot_timing = Arc::clone(&timing); + let snapshot = thread::spawn(move || { + let mut signaled = false; + let value = snapshot_timing.snapshot_with_hooks( + || { + if !signaled { + retry_tx.send(()).expect("retry signal is received"); + signaled = true; + } + }, + || {}, + ); + snapshot_tx + .send(value) + .expect("snapshot result is received"); + }); + retry_rx + .recv() + .expect("snapshot observes the in-progress population"); + assert!(matches!( + snapshot_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + release_writer_tx + .send(()) + .expect("writer is released after the retry"); + writer.join().expect("writer thread completes"); + snapshot.join().expect("snapshot thread completes"); + assert_eq!( + snapshot_rx.recv().expect("coherent snapshot is published"), + (1, 70, 70, 70, 70) + ); + } + + #[test] + fn enqueue_observation_is_reported_separately_from_callback_work() { + let counters = CallbackCounters::default(); + { + let _observation = counters.observe_enqueue(); + } + + let diagnostics = counters.snapshot(0); + assert_eq!(diagnostics.enqueue_sample_count, 1); + assert_eq!(diagnostics.callback_sample_count, 0); + assert!(diagnostics.enqueue_p99_ns <= diagnostics.enqueue_max_ns); + } +} diff --git a/crates/hypercolor-macos-capture/src/frame.rs b/crates/hypercolor-macos-capture/src/frame.rs new file mode 100644 index 000000000..426aa1549 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/frame.rs @@ -0,0 +1,1269 @@ +use std::fmt; +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, CVPixelBufferGetBytesPerRowOfPlane, + CVPixelBufferGetHeightOfPlane, CVPixelBufferGetWidthOfPlane, + kCVPixelBufferIOSurfacePropertiesKey, +}; +use thiserror::Error; + +use crate::geometry::{ + MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosScale, +}; +use crate::{MacosCaptureDynamicRange, MacosDeliveredFrameMetadata, MacosStreamDeliveryRejection}; + +pub const MACOS_STREAM_QUEUE_DEPTH: usize = 8; + +const BGRA8: u32 = 0x4247_5241; +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; +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), + } + } + + 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)], + 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, + Rec709, + Rec2020, + 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, + #[cfg(any(target_os = "macos", feature = "capture-fixtures"))] + owner: Arc, + delivery_metadata: Option, + #[cfg(any(target_os = "macos", feature = "capture-fixtures"))] + _admission_lifetime: Option>, +} + +/// 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 { + /// 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_fixture( + extent: MacosPixelExtent, + 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. + 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, + format.fourcc(color.range)?, + 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 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 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 surface = Self::from_pixel_buffer_with_delivery_metadata(pixel_buffer, None, None)?; + 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")] + 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 { + fixture_id, + planes: None, + }), + delivery_metadata: None, + _admission_lifetime: 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()), + }), + delivery_metadata: None, + _admission_lifetime: None, + }) + } + + #[cfg(feature = "capture-fixtures")] + pub fn with_delivery_metadata( + mut self, + delivery_metadata: MacosDeliveredFrameMetadata, + ) -> Result { + let validated = MacosDeliveredFrameMetadata::new( + delivery_metadata.pixel_format, + delivery_metadata.color, + delivery_metadata.source_reference_white_nits, + delivery_metadata.content_headroom, + )?; + if validated.dynamic_range != delivery_metadata.dynamic_range { + return Err( + MacosStreamDeliveryRejection::MissingOrInvalidDeliveryMetadata("dynamic_range"), + ); + } + self.delivery_metadata = Some(validated); + Ok(self) + } + + #[cfg(target_os = "macos")] + 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)?; + 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 }), + 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 { + #[cfg(any(target_os = "macos", feature = "capture-fixtures"))] + { + Arc::strong_count(&self.owner) + } + #[cfg(not(any(target_os = "macos", feature = "capture-fixtures")))] + { + 0 + } + } + + /// 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 { + 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 { + #[cfg(any(target_os = "macos", feature = "capture-fixtures"))] + 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)) + } + } + #[cfg(not(any(target_os = "macos", feature = "capture-fixtures")))] + { + let _ = (lengths, operation); + Err(MacosCaptureError::CpuMappingUnavailable) + } + } +} + +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() + } +} + +#[cfg(any(target_os = "macos", feature = "capture-fixtures"))] +enum MacosRetainedPixelBuffer { + #[cfg(target_os = "macos")] + Native { + pixel_buffer: CFRetained, + }, + #[cfg(feature = "capture-fixtures")] + Fixture { + fixture_id: u64, + planes: Option]>>, + }, +} + +#[cfg(any(target_os = "macos", feature = "capture-fixtures"))] +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 {} + +#[cfg(target_os = "macos")] +struct PixelBufferReadLock<'a> { + pixel_buffer: &'a CVPixelBuffer, + 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 { + // 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, + 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, +} + +impl MacosCaptureFrame { + #[must_use] + pub const fn delivered_metadata(&self) -> Option { + self.surface.delivery_metadata() + } +} + +#[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), + RecoverableError(Box), +} + +#[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) +} + +#[cfg(target_os = "macos")] +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, +) -> 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 { + // Dirty rects are a damage hint, not frame data. When the + // attachment is absent or undecodable (macOS 26 reshaped the + // encoding once already), the conservative answer is that the + // whole content changed; dropping the frame turns a lost + // optimization into a dead capture pipeline. + MacosAttachment::Missing | MacosAttachment::Malformed => { + Ok(vec![geometry.content_rect_pixels]) + } + 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("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("invalid macOS capture source selector: {0}")] + InvalidSourceSelector(String), + #[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}")] + MalformedAttachment(&'static str), + #[error("unknown ScreenCaptureKit frame status {0}")] + 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}")] + 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(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}")] + MalformedLuminanceAttachment(&'static str), + #[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("capture surface has no native pixel buffer")] + NativeSurfaceUnavailable, + #[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")] + 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")] + 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}")] + 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")] + 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("exact BGRA copy requires BGRA8 input, got {0:?}")] + UnsupportedCpuPixelFormat(MacosCapturePixelFormat), + #[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")] + 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/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..b15ca1cdd --- /dev/null +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -0,0 +1,61 @@ +//! 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 clock; +mod cpu; +mod diagnostics; +mod frame; +mod geometry; +mod mailbox; +#[cfg(target_os = "macos")] +mod native; +#[cfg(target_os = "macos")] +mod screenshot; +mod session; +mod stream_contract; +#[cfg(any(target_os = "macos", test))] +mod worker; + +#[cfg(target_os = "macos")] +pub use native::{ + MacosNativeTransactionError, MacosNativeTransactionPhase, MacosScreenCaptureSession, + MacosStreamDiagnosticTransaction, MacosStreamRequestTransaction, +}; +#[cfg(target_os = "macos")] +pub use screenshot::{ + MAX_MACOS_SCREENSHOT_REFERENCE_BYTES, MacosScreenshotPixelCopy, + MacosScreenshotPreferredDynamicRange, MacosScreenshotReferenceCapability, + MacosScreenshotReferenceCapture, MacosScreenshotReferenceImage, + MacosScreenshotReferenceMetadata, MacosScreenshotReferenceSet, +}; + +pub use clock::{MacosDisplayClock, MacosDisplayClockError}; +pub use cpu::MacosCpuSourceView; +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, + MacosChromaLocation, MacosColorPrimaries, MacosColorRange, MacosFrameDecoder, MacosFrameEvent, + MacosFrameStatus, MacosProtectedSourceState, MacosRawCapturePlane, MacosRawCaptureSample, + MacosRawCompleteFrame, MacosRawFrameAttachments, MacosTransferFunction, MacosYuvMatrix, +}; +pub use geometry::{ + MacosCaptureGeometry, MacosGeometryError, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosScale, +}; +pub use mailbox::MacosFrameMailbox; +pub use session::{ + MacosCaptureCadence, MacosCaptureContentStyle, MacosCaptureSelection, MacosCaptureSelector, + MacosStreamRequest, +}; +pub use stream_contract::{ + MacosCaptureCapabilities, MacosCaptureDynamicRange, MacosConfiguredStream, + MacosDeliveredFrameMetadata, MacosHostArchitecture, MacosRuntimeCapability, + MacosStreamDeliveryRejection, MacosStreamDeliveryState, MacosStreamDeliveryValidator, + MacosStreamPreset, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, + MacosTahoeSelectionCapabilities, MacosValidatedStreamDelivery, +}; diff --git a/crates/hypercolor-macos-capture/src/mailbox.rs b/crates/hypercolor-macos-capture/src/mailbox.rs new file mode 100644 index 000000000..8c288713f --- /dev/null +++ b/crates/hypercolor-macos-capture/src/mailbox.rs @@ -0,0 +1,254 @@ +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use crate::{MacosCaptureError, MacosFrameEvent, MacosFrameStatus}; + +#[derive(Debug, Clone, Default)] +pub struct MacosFrameMailbox { + inner: Arc, +} + +#[derive(Debug, Default)] +struct MailboxInner { + state: Mutex, + ready: Condvar, +} + +#[derive(Debug, Default)] +struct MailboxState { + control: Option, + frame: Option, + diagnostic: Option, + superseded: u64, + wake_generation: u64, + delivery_revision: u64, + invalidation_generation: u64, +} + +#[derive(Debug)] +struct PendingDelivery { + revision: u64, + invalidation_generation: u64, + delivery: Result, +} + +impl MacosFrameMailbox { + pub fn new() -> Self { + Self::default() + } + + pub fn publish(&self, delivery: Result) { + let mut state = self.lock(); + state.delivery_revision = state + .delivery_revision + .checked_add(1) + .expect("macOS mailbox delivery revision must remain monotonic"); + let terminal = matches!( + &delivery, + Err(_) + | Ok(MacosFrameEvent::Lifecycle( + MacosFrameStatus::Suspended | MacosFrameStatus::Stopped + )) + ); + if terminal { + state.invalidation_generation = state + .invalidation_generation + .checked_add(1) + .expect("macOS mailbox invalidation generation must remain monotonic"); + } + let pending = PendingDelivery { + revision: state.delivery_revision, + invalidation_generation: state.invalidation_generation, + delivery, + }; + let lane = match &pending.delivery { + Ok(MacosFrameEvent::Frame(_)) => &mut state.frame, + Ok(MacosFrameEvent::RecoverableError(_)) => &mut state.diagnostic, + Ok(MacosFrameEvent::Lifecycle(_)) | Err(_) => &mut state.control, + }; + if lane.replace(pending).is_some() { + state.superseded = state.superseded.saturating_add(1); + } + drop(state); + self.inner.ready.notify_one(); + } + + pub fn take_latest(&self) -> Option> { + Self::take_next(&mut self.lock()).map(|(_, _, delivery)| delivery) + } + + pub fn take_latest_with_generation( + &self, + ) -> Option<(u64, u64, Result)> { + Self::take_next(&mut self.lock()) + } + + pub fn has_pending(&self) -> bool { + Self::has_pending_state(&self.lock()) + } + + pub fn superseded_count(&self) -> u64 { + self.lock().superseded + } + + 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> { + self.wait_latest_with_generation_while(timeout, keep_waiting) + .map(|(_, _, delivery)| delivery) + } + + pub fn wait_latest_with_generation_while( + &self, + timeout: Duration, + keep_waiting: impl Fn() -> bool, + ) -> Option<(u64, u64, Result)> { + self.wait_latest_while_with_hook(timeout, keep_waiting, || {}) + } + + fn wait_latest_while_with_hook( + &self, + timeout: Duration, + keep_waiting: impl Fn() -> bool, + mut before_wait: impl FnMut(), + ) -> Option<(u64, u64, Result)> { + let started = Instant::now(); + let mut state = self.lock(); + let wake_generation = state.wake_generation; + while !Self::has_pending_state(&state) + && state.wake_generation == wake_generation + && keep_waiting() + { + before_wait(); + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + let (next, timeout_result) = self + .inner + .ready + .wait_timeout(state, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = next; + if timeout_result.timed_out() { + break; + } + } + Self::take_next(&mut state) + } + + pub fn wake(&self) { + let mut state = self.lock(); + state.wake_generation = state.wake_generation.wrapping_add(1); + drop(state); + self.inner.ready.notify_all(); + } + + fn lock(&self) -> MutexGuard<'_, MailboxState> { + self.inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn has_pending_state(state: &MailboxState) -> bool { + state.control.is_some() || state.frame.is_some() || state.diagnostic.is_some() + } + + fn take_next( + state: &mut MailboxState, + ) -> Option<(u64, u64, Result)> { + state + .control + .take() + .or_else(|| match (&state.frame, &state.diagnostic) { + (Some(frame), Some(diagnostic)) if diagnostic.revision < frame.revision => { + state.diagnostic.take() + } + (Some(_), _) => state.frame.take(), + (None, Some(_)) => state.diagnostic.take(), + (None, None) => None, + }) + .map(|pending| { + ( + pending.revision, + pending.invalidation_generation, + pending.delivery, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + use std::time::Duration; + + use super::MacosFrameMailbox; + + #[test] + fn wake_generation_closes_the_post_predicate_wait_window() { + let mailbox = MacosFrameMailbox::new(); + let worker_mailbox = mailbox.clone(); + let (predicate_tx, predicate_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut paused = false; + let delivery = worker_mailbox.wait_latest_while_with_hook( + Duration::from_secs(5), + || true, + || { + if !paused { + paused = true; + predicate_tx + .send(()) + .expect("post-predicate pause should be observable"); + resume_rx + .recv() + .expect("condition wait setup should resume"); + } + }, + ); + done_tx + .send(delivery.is_none()) + .expect("wait result should be observable"); + }); + + predicate_rx + .recv_timeout(Duration::from_secs(1)) + .expect("waiter should pause after its external predicate returns true"); + let wake_mailbox = mailbox.clone(); + let (wake_done_tx, wake_done_rx) = mpsc::channel(); + let waker = std::thread::spawn(move || { + wake_mailbox.wake(); + wake_done_tx.send(()).expect("wake should finish"); + }); + assert_eq!( + wake_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + resume_tx + .send(()) + .expect("condition wait setup should resume"); + wake_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("wake should advance the generation after acquiring the mailbox lock"); + assert!( + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("generation change should release the waiter immediately") + ); + waker.join().expect("waker should join"); + worker.join().expect("waiter should join"); + } +} diff --git a/crates/hypercolor-macos-capture/src/native.rs b/crates/hypercolor-macos-capture/src/native.rs new file mode 100644 index 000000000..999e15af5 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/native.rs @@ -0,0 +1,9533 @@ +use std::ffi::{CStr, c_char, c_void}; +use std::fmt; +use std::ptr::{self, NonNull}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::{Duration, Instant}; + +use block2::RcBlock; +use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained, MainThreadBound}; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject, ProtocolObject}; +use objc2::{ + AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel, +}; +use objc2_core_foundation::{ + CFArray, CFDictionary, CFGetTypeID, CFNumber, CFRetained, CFString, CFType, CFUUID, CGPoint, + CGRect, CGSize, +}; +use objc2_core_graphics::{ + CGDirectDisplayID, CGImage, CGMainDisplayID, CGPreflightScreenCaptureAccess, + CGRectMakeWithDictionaryRepresentation, CGRequestScreenCaptureAccess, +}; +use objc2_core_media::{CMSampleBuffer, CMTime}; +use objc2_core_video::{ + CVBuffer, CVPixelBuffer, CVPixelBufferGetBytesPerRow, CVPixelBufferGetBytesPerRowOfPlane, + CVPixelBufferGetDataSize, CVPixelBufferGetHeight, CVPixelBufferGetHeightOfPlane, + 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, 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, kIOSurfaceContentHeadroom}; +use objc2_screen_capture_kit::{ + SCCaptureDynamicRange, SCCaptureResolutionType, SCContentFilter, SCContentSharingPicker, + SCContentSharingPickerConfiguration, SCContentSharingPickerMode, + SCContentSharingPickerObserver, SCShareableContent, SCStream, SCStreamConfiguration, + SCStreamConfigurationPreset, SCStreamDelegate, SCStreamErrorCode, SCStreamErrorDomain, + SCStreamFrameInfoBoundingRect, SCStreamFrameInfoContentRect, SCStreamFrameInfoContentScale, + SCStreamFrameInfoDirtyRects, SCStreamFrameInfoDisplayTime, SCStreamFrameInfoScaleFactor, + SCStreamFrameInfoScreenRect, SCStreamFrameInfoStatus, SCStreamOutput, SCStreamOutputType, + SCWindow, +}; + +use crate::diagnostics::CallbackCounters; +use crate::stream_contract::MacosTahoeSelectionCapabilityState; +use crate::worker::{ + LatestSampleInput, LatestSampleWorker, SamplePublication, SamplePublishOutcome, +}; +use crate::{ + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCallbackDiagnostics, + 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, MacosScreenshotReferenceCapability, MacosScreenshotReferenceCapture, + MacosScreenshotReferenceImage, MacosScreenshotReferenceSet, MacosStreamDeliveryRejection, + MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, + MacosTahoeCapabilities, MacosTahoeRuntimeProbes, MacosTahoeSelectionCapabilities, + MacosTransferFunction, MacosValidatedStreamDelivery, MacosYuvMatrix, +}; + +mod lifecycle; +mod transactions; + +use lifecycle::{CompletionFence, CompletionWitness, NativeLifecycle}; +pub use transactions::{ + MacosNativeTransactionError, MacosNativeTransactionPhase, MacosStreamDiagnosticTransaction, + MacosStreamRequestTransaction, +}; +use transactions::{ + TransactionCompleter, TransactionIdentity, TransactionSettlement, + stream_diagnostic_transaction, stream_request_transaction, +}; + +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; +const HYPERCOLOR_UI_BUNDLE_IDENTIFIER: &str = "tech.hyperbliss.hypercolor"; +const MACOS_NATIVE_SOURCE_TIMEOUT: Duration = Duration::from_secs(5); +const MACOS_NATIVE_START_TIMEOUT: Duration = Duration::from_secs(5); +const MACOS_NATIVE_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(5); +const MACOS_NATIVE_STOP_TIMEOUT: Duration = Duration::from_secs(2); + +fn is_hypercolor_ui_bundle_identifier(bundle_identifier: &str) -> bool { + bundle_identifier == HYPERCOLOR_UI_BUNDLE_IDENTIFIER +} + +#[derive(Debug)] +struct SessionShared { + mailbox: MacosFrameMailbox, + status: Mutex, + selection: Mutex, + selector: Mutex, + tahoe: MacosTahoeCapabilities, + counters: CallbackCounters, + capture_active: AtomicBool, + picker_resolution: Mutex>, + current_epoch: AtomicU64, + resolution_epoch: AtomicU64, + restart_diagnostic: Mutex, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PostAuthorizationStreamDiagnosticAttempt { + attempt_id: u64, + selection_revision: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PostAuthorizationStreamDiagnosticResolution { + attempt: PostAuthorizationStreamDiagnosticAttempt, + resolution_epoch: u64, + selector: MacosCaptureSelector, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct GeneralSourceResolution { + resolution_epoch: u64, + selector: MacosCaptureSelector, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SourceResolution { + General(GeneralSourceResolution), + Diagnostic(PostAuthorizationStreamDiagnosticResolution), +} + +struct SourceTransaction { + resolution: SourceResolution, + completion: TransactionCompleter<()>, +} + +impl SourceResolution { + fn selector(&self) -> &MacosCaptureSelector { + match self { + Self::General(resolution) => &resolution.selector, + Self::Diagnostic(resolution) => &resolution.selector, + } + } +} + +#[derive(Debug)] +struct PostAuthorizationStreamDiagnostic { + attempt: PostAuthorizationStreamDiagnosticAttempt, + authorization_granted: bool, + resolution_epoch: Option, + stream_epoch: Option, + completion: TransactionCompleter, +} + +#[derive(Debug, Default)] +struct PostAuthorizationStreamDiagnosticState { + next_attempt_id: u64, + active: Option, +} + +#[derive(Debug, Default)] +struct SessionSelectionState { + selection: MacosCaptureSelection, + tahoe: MacosTahoeSelectionCapabilityState, +} + +impl SessionShared { + fn new( + status: MacosProtectedSourceState, + selector: MacosCaptureSelector, + tahoe: MacosTahoeCapabilities, + ) -> Self { + Self { + mailbox: MacosFrameMailbox::new(), + status: Mutex::new(status), + selection: Mutex::new(SessionSelectionState::default()), + selector: Mutex::new(selector), + tahoe, + counters: CallbackCounters::default(), + capture_active: AtomicBool::new(false), + picker_resolution: Mutex::new(None), + current_epoch: AtomicU64::new(0), + resolution_epoch: AtomicU64::new(0), + restart_diagnostic: Mutex::new(PostAuthorizationStreamDiagnosticState::default()), + } + } + + fn status(&self) -> MacosProtectedSourceState { + *lock(&self.status) + } + + fn set_status(&self, status: MacosProtectedSourceState) { + *lock(&self.status) = status; + } + + fn begin_restart_diagnostic( + &self, + authorization_granted: bool, + selection_revision: u64, + ) -> Result< + ( + PostAuthorizationStreamDiagnosticResolution, + MacosStreamDiagnosticTransaction, + ), + MacosCaptureError, + > { + let (attempt, superseded, transaction) = { + let mut state = lock(&self.restart_diagnostic); + let attempt_id = state + .next_attempt_id + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.next_attempt_id = attempt_id; + let attempt = PostAuthorizationStreamDiagnosticAttempt { + attempt_id, + selection_revision, + }; + let resolution_epoch = self.allocate_resolution_epoch()?; + let (transaction, completion) = stream_diagnostic_transaction(attempt_id); + let superseded = state.active.as_ref().and_then(|active| { + active + .completion + .claim(Ok(MacosProtectedSourceState::Failed)) + }); + state.active = Some(PostAuthorizationStreamDiagnostic { + attempt, + authorization_granted, + resolution_epoch: Some(resolution_epoch), + stream_epoch: None, + completion, + }); + ( + PostAuthorizationStreamDiagnosticResolution { + attempt, + resolution_epoch, + selector: MacosCaptureSelector::PrimaryDisplay, + }, + superseded, + transaction, + ) + }; + if let Some(superseded) = superseded { + superseded.publish(); + } + if !authorization_granted { + self.complete_restart_diagnostic_attempt( + attempt.attempt, + MacosProtectedSourceState::PermissionDenied, + ); + } + Ok((attempt, transaction)) + } + + fn diagnostic_resolution_is_current( + &self, + resolution: &PostAuthorizationStreamDiagnosticResolution, + ) -> bool { + self.resolution_is_current(resolution.resolution_epoch) + && lock(&self.restart_diagnostic) + .active + .as_ref() + .is_some_and(|active| { + active.attempt == resolution.attempt + && active.resolution_epoch == Some(resolution.resolution_epoch) + }) + } + + fn record_filter_enumerated( + &self, + resolution: &PostAuthorizationStreamDiagnosticResolution, + stream_epoch: u64, + ) { + let mut state = lock(&self.restart_diagnostic); + if let Some(active) = state.active.as_mut() + && active.attempt == resolution.attempt + && active.authorization_granted + && active.resolution_epoch == Some(resolution.resolution_epoch) + { + active.stream_epoch = Some(stream_epoch); + } + } + + fn record_non_stream_diagnostic_failure( + &self, + resolution: &PostAuthorizationStreamDiagnosticResolution, + state: MacosProtectedSourceState, + ) { + if self.diagnostic_resolution_is_current(resolution) { + self.complete_restart_diagnostic_attempt( + resolution.attempt, + if state == MacosProtectedSourceState::PermissionDenied { + state + } else { + MacosProtectedSourceState::Failed + }, + ); + } + } + + fn fail_restart_diagnostic_attempt(&self, attempt: PostAuthorizationStreamDiagnosticAttempt) { + self.complete_restart_diagnostic_attempt(attempt, MacosProtectedSourceState::Failed); + } + + fn claim_restart_diagnostic_completion( + &self, + outcome: MacosProtectedSourceState, + ) -> Option> { + let mut state = lock(&self.restart_diagnostic); + let settlement = state.active.as_ref()?.completion.claim(Ok(outcome))?; + state.active = None; + Some(settlement) + } + + fn restart_diagnostic_completion( + &self, + attempt: PostAuthorizationStreamDiagnosticAttempt, + ) -> Option> { + lock(&self.restart_diagnostic) + .active + .as_ref() + .filter(|active| active.attempt == attempt) + .map(|active| active.completion.clone()) + } + + fn take_restart_diagnostic_attempt( + &self, + attempt: PostAuthorizationStreamDiagnosticAttempt, + ) -> Option> { + let mut state = lock(&self.restart_diagnostic); + if state + .active + .as_ref() + .is_some_and(|active| active.attempt == attempt) + { + state.active.take().map(|active| active.completion) + } else { + None + } + } + + fn record_stream_diagnostic_result( + &self, + stream_epoch: u64, + state: MacosProtectedSourceState, + ) -> MacosProtectedSourceState { + let settlement = { + let mut diagnostic = lock(&self.restart_diagnostic); + let Some(active) = diagnostic + .active + .as_ref() + .filter(|active| active.stream_epoch == Some(stream_epoch)) + else { + return state; + }; + let state = if active.authorization_granted + && state == MacosProtectedSourceState::PermissionDenied + { + MacosProtectedSourceState::NeedsProcessRestart + } else { + state + }; + let settlement = active.completion.claim(Ok(state)); + if settlement.is_some() { + diagnostic.active = None; + } + settlement.map(|settlement| (settlement, state)) + }; + if let Some((settlement, state)) = settlement { + settlement.publish(); + state + } else { + state + } + } + + fn complete_restart_diagnostic_attempt( + &self, + attempt: PostAuthorizationStreamDiagnosticAttempt, + outcome: MacosProtectedSourceState, + ) { + let settlement = { + let mut diagnostic = lock(&self.restart_diagnostic); + if diagnostic + .active + .as_ref() + .is_some_and(|active| active.attempt == attempt) + { + let settlement = diagnostic + .active + .as_ref() + .and_then(|active| active.completion.claim(Ok(outcome))); + if settlement.is_some() { + diagnostic.active = None; + } + settlement + } else { + None + } + }; + if let Some(settlement) = settlement { + settlement.publish(); + } + } + + fn selection(&self) -> MacosCaptureSelection { + 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 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 { + 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 enable_picker_callbacks(&self, resolution: SourceResolution) { + *lock(&self.picker_resolution) = Some(resolution); + } + + fn disable_picker_callbacks(&self) { + lock(&self.picker_resolution).take(); + } + + fn picker_resolution(&self) -> Option { + lock(&self.picker_resolution).clone() + } + + fn consume_picker_resolution(&self, resolution: &SourceResolution) -> bool { + let mut picker = lock(&self.picker_resolution); + if picker.as_ref() == Some(resolution) { + picker.take(); + true + } else { + false + } + } + + fn set_capture_active(&self, active: bool) -> bool { + self.capture_active.swap(active, Ordering::AcqRel) + } + + fn begin_resolution(&self) -> Result { + let superseded = { + let mut state = lock(&self.restart_diagnostic); + let settlement = state.active.as_ref().and_then(|active| { + active + .completion + .claim(Ok(MacosProtectedSourceState::Failed)) + }); + state.active = None; + settlement + }; + if let Some(superseded) = superseded { + superseded.publish(); + } + self.allocate_resolution_epoch() + } + + fn allocate_resolution_epoch(&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 source_resolution_is_current(&self, resolution: &SourceResolution) -> bool { + match resolution { + SourceResolution::General(resolution) => { + self.resolution_is_current(resolution.resolution_epoch) + } + SourceResolution::Diagnostic(resolution) => { + self.diagnostic_resolution_is_current(resolution) + } + } + } + + 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 + } + MacosFrameEvent::RecoverableError(_) => self.status(), + }; + 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)); + } + + fn publish_recoverable_error(&self, error: MacosCaptureError) { + self.mailbox + .publish(Ok(MacosFrameEvent::RecoverableError(Box::new(error)))); + } + + fn record_retirement_error(&self, error: &MacosCaptureError) { + self.counters.record_drop(error); + } +} + +struct RetainedNativeSample { + attachments: MacosRawFrameAttachments, + pixel_buffer: CFRetained, + admission_lifetime: PoolBackingLifetime, + cursor_composed: bool, +} + +enum RetainedNativeDelivery { + Complete(T), + Lifecycle(MacosFrameStatus), +} + +fn route_retained_delivery( + delivery: RetainedNativeDelivery, + complete: impl FnOnce(T), + lifecycle: impl FnOnce(MacosFrameStatus), +) { + match delivery { + RetainedNativeDelivery::Complete(sample) => complete(sample), + RetainedNativeDelivery::Lifecycle(status) => lifecycle(status), + } +} + +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 {} + +fn retain_sample( + sample: &CMSampleBuffer, + cursor_composed: bool, + pool: &PoolObservation, +) -> 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(); + 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")); + } + }; + if status != MacosFrameStatus::Complete { + return Ok(RetainedNativeDelivery::Lifecycle(status)); + } + 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)) }; + RetainedNativeDelivery::Complete(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, + publication: SamplePublication, + epoch: u64, + streams: &Weak, + shared: &Arc, +) { + let _timing = shared.counters.observe_publication(); + match result { + Ok(sample) => { + if let Some(streams) = streams.upgrade() { + streams.publish_decoded_sample(epoch, sample, &publication); + } + } + Err(error @ MacosCaptureError::StreamDeliveryRejected(_)) => { + handle_fatal_stream_error(streams, epoch, Arc::clone(shared), error); + } + Err(error) => shared.counters.record_drop(&error), + } +} + +struct CaptureOutputIvars { + samples: LatestSampleInput, + pool: PoolObservation, + 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, + ) { + let _callback_timing = self.ivars().shared.counters.observe_callback(); + self.ivars().shared.counters.record_received(); + if self + .ivars() + .streams + .upgrade() + .is_none_or(|streams| !streams.accepts_epoch(self.ivars().epoch)) + { + return; + } + let delivery = if output_type == SCStreamOutputType::Screen { + let _retain_timing = self.ivars().shared.counters.observe_retain(); + retain_sample( + sample_buffer, + self.ivars().cursor_composed, + &self.ivars().pool, + ) + } else { + Err(MacosCaptureError::UnexpectedStreamOutputType(output_type.0)) + }; + let delivery = match delivery { + Err(error @ MacosCaptureError::ScreenResourceExhausted { .. }) => { + handle_fatal_stream_error( + &self.ivars().streams, + self.ivars().epoch, + Arc::clone(&self.ivars().shared), + error, + ); + return; + } + Err(error) => { + self.ivars().shared.counters.record_drop(&error); + return; + } + Ok(delivery) => delivery, + }; + route_retained_delivery( + delivery, + |sample| { + let _enqueue_timing = self.ivars().shared.counters.observe_enqueue(); + if self.ivars().samples.publish(sample) == SamplePublishOutcome::Superseded { + self.ivars() + .shared + .counters + .record_native_sample_superseded(); + } + }, + |status| { + if let Some(streams) = self.ivars().streams.upgrade() { + route_stream_lifecycle( + &self.ivars().samples, + &streams, + self.ivars().epoch, + status, + ); + } + }, + ); + } + } + + 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 let Some(streams) = self.ivars().streams.upgrade() { + route_stream_activity( + &self.ivars().samples, + &streams, + self.ivars().epoch, + true, + self.ivars().display_filter, + ); + } + } + + #[allow(non_snake_case)] + #[unsafe(method(streamDidBecomeInactive:))] + fn streamDidBecomeInactive(&self, _stream: &SCStream) { + if let Some(streams) = self.ivars().streams.upgrade() { + route_stream_activity( + &self.ivars().samples, + &streams, + self.ivars().epoch, + false, + self.ivars().display_filter, + ); + } + } + } +); + +fn route_stream_lifecycle( + samples: &LatestSampleInput, + streams: &StreamSlot, + epoch: u64, + status: MacosFrameStatus, +) { + if matches!( + status, + MacosFrameStatus::Suspended | MacosFrameStatus::Stopped + ) { + samples.invalidate_if(|| streams.publish_stream_lifecycle(epoch, status)); + } else { + samples.synchronize_if(|| streams.publish_stream_lifecycle(epoch, status)); + } +} + +fn route_stream_activity( + samples: &LatestSampleInput, + streams: &StreamSlot, + epoch: u64, + active: bool, + display_filter: bool, +) { + if active { + samples.synchronize_if(|| streams.record_stream_activity(epoch, true, display_filter)); + } else { + samples.invalidate_if(|| streams.record_stream_activity(epoch, false, display_filter)); + } +} + +impl CaptureOutput { + fn new( + epoch: u64, + samples: LatestSampleInput, + pool: PoolObservation, + shared: Arc, + streams: Weak, + cursor_composed: bool, + display_filter: bool, + ) -> Retained { + let this = Self::alloc().set_ivars(CaptureOutputIvars { + samples, + pool, + shared, + streams, + epoch, + cursor_composed, + display_filter, + }); + // SAFETY: NSObject has no additional initialization requirements for + // this callback subclass. + unsafe { msg_send![super(this), init] } + } +} + +#[derive(Clone)] +enum NativeFilter { + System(Retained), + #[cfg(test)] + Fixture(u64), +} + +// 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 {} + +impl NativeFilter { + fn system(&self) -> &SCContentFilter { + match self { + Self::System(filter) => filter, + #[cfg(test)] + Self::Fixture(_) => panic!("fixture selection has no native filter"), + } + } +} + +#[derive(Clone)] +struct NativeSelectionFilter { + filter: NativeFilter, + selection: MacosCaptureSelection, + source_id: Arc, +} + +impl NativeSelectionFilter { + fn retain(filter: &SCContentFilter) -> Result { + let selection = selection_from_filter(filter)?; + let source_id = selection_source_id(filter, &selection); + // SAFETY: The picker or configured-source callback supplies a live + // immutable filter, and each owner stays process-local. + let filter = unsafe { + Retained::retain(ptr::from_ref(filter).cast_mut()) + .ok_or(MacosCaptureError::RetainNativeFilterFailed)? + }; + Ok(Self { + filter: NativeFilter::System(filter), + selection, + source_id, + }) + } + + #[cfg(test)] + fn fixture(id: u64) -> Self { + let source_id: Arc = Arc::from(format!("fixture:{id}")); + Self { + filter: NativeFilter::Fixture(id), + selection: MacosCaptureSelection::Display { + source_id: Arc::clone(&source_id), + }, + source_id, + } + } + + #[cfg(test)] + fn fixture_id(&self) -> u64 { + match &self.filter { + NativeFilter::Fixture(id) => *id, + NativeFilter::System(_) => panic!("native filter has no fixture identity"), + } + } +} + +#[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.system(), + 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, + selection: MacosCaptureSelection, + source_id: Arc, + request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, + worker: LatestSampleWorker, + start_completion: CompletionFence, + _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( + selection_filter: NativeSelectionFilter, + request: MacosStreamRequest, + epoch: u64, + shared: Arc, + streams: Weak, + reserve_pool: &PoolReservationFactory, + native_lifecycle: &NativeLifecycle, + ) -> Result { + let filter = selection_filter.filter.system(); + let (configuration, display_filter, extent, configured_stream) = + stream_configuration(filter, request)?; + let quote = conservative_pool_quote(extent, configured_stream.configured_pixel_format)?; + let pool = reserve_pool(quote.per_surface_bytes, quote.stream_metadata_bytes)?; + 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: RetainedNativeSample| { + let _timing = decode_shared.counters.observe_conversion(); + decode_sample(&mut decoder, &mut delivery_validator, sample) + }, + move |result, publication| { + publish_decoded_result(result, publication, epoch, &worker_streams, &worker_shared); + }, + ) + .map_err(|error| MacosCaptureError::CaptureWorkerStartFailed(error.to_string()))?; + let samples = worker.input(); + let output = CaptureOutput::new( + epoch, + samples, + pool, + shared, + streams, + request.cursor_composed, + display_filter, + ); + let setup_shared = Arc::clone(&output.ivars().shared); + 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. + let output_result = unsafe { + stream.addStreamOutput_type_sampleHandlerQueue_error( + protocol, + SCStreamOutputType::Screen, + Some(&queue), + ) + }; + if let Err(error) = output_result { + setup_shared.record_stream_diagnostic_result(epoch, classify_stream_error(&error)); + let error = native_error("add ScreenCaptureKit output", &error); + let completion = CompletionFence::new(); + drop(completion.witness()); + let retirement_shared = Arc::clone(&setup_shared); + native_lifecycle.retire_without_native_stop(worker, completion, move |worker| { + worker.close(); + if worker.join().is_err() { + retirement_shared + .counters + .record_drop(&MacosCaptureError::CaptureWorkerPanicked); + } + }); + return Err(error); + } + Ok(Self { + stream, + filter: selection_filter.filter, + selection: selection_filter.selection, + source_id: selection_filter.source_id, + request, + reserve_pool: Arc::clone(reserve_pool), + worker, + start_completion: CompletionFence::new(), + _output: output, + _queue: queue, + }) + } + + fn epoch(&self) -> u64 { + self._output.ivars().epoch + } + + fn finish_worker_retirement(&mut self) -> Result<(), MacosCaptureError> { + self.worker.close(); + self.worker + .join() + .map_err(|_| MacosCaptureError::CaptureWorkerPanicked) + } + + fn interruption_restage(&self, selection_revision: u64) -> InterruptedRestagePlan { + InterruptedRestagePlan { + recovery: InterruptedRestage::interrupted(self.epoch(), selection_revision), + selection_filter: NativeSelectionFilter { + filter: self.filter.clone(), + selection: self.selection.clone(), + source_id: Arc::clone(&self.source_id), + }, + request: self.request, + reserve_pool: Arc::clone(&self.reserve_pool), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StreamRole { + Current, + Candidate, + 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_epoch.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, + selection_filter: NativeSelectionFilter, + request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, +} + +#[derive(Clone)] +struct PendingSelectionFilter { + epoch: u64, + selection_revision: u64, + selection_filter: NativeSelectionFilter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CandidateStage { + epoch: u64, + selection_revision: u64, + lifecycle_revision: u64, + predecessor_epoch: Option, + recovery_current_epoch: Option, + recovery: Option, + request: Option, +} + +impl CandidateStage { + const fn identity(self) -> CandidateStageIdentity { + CandidateStageIdentity { + epoch: self.epoch, + selection_revision: self.selection_revision, + lifecycle_revision: self.lifecycle_revision, + predecessor_epoch: self.predecessor_epoch, + } + } + + 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.lifecycle_revision == self.lifecycle_revision + && state.candidate_epoch.is_none() + && state.pending_selection.as_ref().is_some_and(|pending| { + pending.epoch == self.epoch && pending.selection_revision == self.selection_revision + }) + && self.request.is_none_or(|request| { + state + .pending_request + .as_ref() + .is_some_and(|pending| pending.identity() == request) + }) + && 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) + }) + } + + fn begin(self, state: &mut StreamState, shared: &SessionShared) -> bool { + if !self.is_current(state, shared) { + return false; + } + state.candidate_epoch = Some(self.epoch); + state.staging_epoch = None; + true + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CandidateStageIdentity { + epoch: u64, + selection_revision: u64, + lifecycle_revision: u64, + predecessor_epoch: Option, +} + +struct CandidatePreparationFailure { + stage: CandidateStageIdentity, + error: MacosCaptureError, + settlement: Option>>, +} + +impl CandidatePreparationFailure { + fn new( + stage: CandidateStageIdentity, + error: MacosCaptureError, + settlement: Option>, + ) -> Self { + Self { + stage, + error, + settlement: settlement.map(Box::new), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PendingStreamRequestIdentity { + epoch: u64, + request: MacosStreamRequest, +} + +#[derive(Debug)] +struct PendingStreamRequest { + epoch: u64, + request: MacosStreamRequest, + completion: TransactionCompleter<()>, +} + +impl PendingStreamRequest { + const fn identity(&self) -> PendingStreamRequestIdentity { + PendingStreamRequestIdentity { + epoch: self.epoch, + request: self.request, + } + } +} + +struct StreamRemoval { + role: StreamRole, + stream: Option, + selection_revision: u64, + request_settlement: Option>, +} + +struct CandidatePublication { + previous: Option, + previous_epoch: Option, + previous_status: MacosProtectedSourceState, + previous_selection: MacosCaptureSelection, + previous_request: MacosStreamRequest, + previous_selected_filter: Option, + previous_inactive_epochs: Vec, + previous_terminal_epochs: Vec, + request_settlement: TransactionSettlement<()>, +} + +#[derive(Default)] +struct PublicationSideEffects { + candidate: Option, +} + +struct CandidateActivationAbort { + payload: Box, + stream: Option, + request_settlement: TransactionSettlement<()>, +} + +struct RestartDiagnosticReset { + current: Option, + candidate: Option, + candidate_settlement: Option>, +} + +struct ClaimedSourceResolution { + resolution: SourceResolution, + settlement: Option>, +} + +struct CandidateReservation { + stage: CandidateStage, + selection_filter: NativeSelectionFilter, + replaced: Option, + replaced_settlement: Option>, +} + +enum FilterAcceptance { + Stale, + Stored(Option), + Candidate { + reservation: Box, + request: MacosStreamRequest, + }, +} + +enum CaptureActivation { + Unchanged, + NeedsSelection, + Candidate { + reservation: Box, + request: MacosStreamRequest, + }, +} + +#[derive(Default)] +struct StreamState { + current: Option, + candidate: Option, + candidate_epoch: Option, + selected_filter: Option, + pending_selection: Option, + selection_revision: u64, + lifecycle_revision: u64, + pending_interruption: Option, + staging_epoch: Option, + request: MacosStreamRequest, + pending_request: Option, + candidate_completion: Option>, + inactive_epochs: Vec, + terminal_epochs: Vec, + #[cfg(test)] + fixture_current_epoch: Option, + #[cfg(test)] + fixture_candidate_epoch: Option, +} + +struct StreamSlot { + // When more than one is required, lock lifecycle_start, rejected_epochs, + // then state. Native start runs with only the lifecycle gate retained. + lifecycle_start: Mutex<()>, + rejected_epochs: Mutex>, + state: Mutex, + source_transaction: Mutex>, + lifecycle_callbacks: DispatchRetained, + native_lifecycle: NativeLifecycle, + shared: Arc, + next_epoch: AtomicU64, +} + +impl StreamSlot { + fn new( + shared: Arc, + request: MacosStreamRequest, + ) -> Result, MacosCaptureError> { + let native_lifecycle = NativeLifecycle::start().map_err(|error| { + MacosCaptureError::CaptureWorkerStartFailed(format!( + "start macOS native transaction scheduler: {error}" + )) + })?; + Ok(Arc::new(Self { + lifecycle_start: Mutex::new(()), + rejected_epochs: Mutex::new(Vec::new()), + state: Mutex::new(StreamState { + request, + ..StreamState::default() + }), + source_transaction: Mutex::new(None), + lifecycle_callbacks: DispatchQueue::new( + "tech.hyperbliss.hypercolor.screen-capture-lifecycle", + DispatchQueueAttr::SERIAL, + ), + native_lifecycle, + 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 install_candidate_completion( + state: &mut StreamState, + epoch: u64, + request: Option<&PendingStreamRequest>, + ) -> Option> { + let completion = request.map_or_else( + || { + TransactionCompleter::new(TransactionIdentity { + generation: epoch, + phase: MacosNativeTransactionPhase::StreamStart, + }) + }, + |request| { + // An adopted in-flight request must follow the stage it now + // belongs to: every deadline arm, cancel, and claim filters + // on the live cell generation, and a cell left keyed to the + // superseded epoch would miss all of them, insta-cancelling + // the fresh candidate and stranding the core waiter. + let completion = request.completion.clone(); + // A refused rekey means the cell was already claimed (a + // timeout won the race); installing it anyway is safe + // because the stage's own arm declines claimed cells and + // aborts the stage. + let _ = completion.rekey_generation(epoch); + completion + }, + ); + let replaced = state.candidate_completion.as_ref().and_then(|replaced| { + (!replaced.shares_cell(&completion)).then(|| { + let identity = replaced.identity(); + replaced.claim(Err(MacosNativeTransactionError::Cancelled { + phase: identity.phase, + generation: identity.generation, + })) + }) + }); + state.candidate_completion = Some(completion); + replaced.flatten() + } + + fn cancel_candidate_completion(state: &mut StreamState) -> Option> { + let settlement = state.candidate_completion.as_ref().and_then(|completion| { + let identity = completion.identity(); + completion.claim(Err(MacosNativeTransactionError::Cancelled { + phase: identity.phase, + generation: identity.generation, + })) + }); + state.candidate_completion = None; + settlement + } + + fn finish_replaced_candidate(settlement: Option>) { + if let Some(settlement) = settlement { + settlement.publish(); + } + } + + fn arm_candidate_deadline( + self: &Arc, + epoch: u64, + phase: MacosNativeTransactionPhase, + timeout: Duration, + ) -> Result { + let completion = { + let state = lock(&self.state); + state + .candidate_completion + .as_ref() + .filter(|completion| completion.identity().generation == epoch) + .cloned() + }; + let Some(completion) = completion else { + return Ok(false); + }; + let streams = Arc::downgrade(self); + completion + .arm_for_generation( + self.native_lifecycle.deadlines(), + Instant::now() + timeout, + epoch, + phase, + move || { + if let Some(streams) = streams.upgrade() { + streams.timeout_candidate(epoch, phase); + } + }, + ) + .map_err(|error| { + MacosCaptureError::CaptureWorkerStartFailed(format!( + "schedule macOS {phase} deadline: {error}" + )) + }) + } + + fn timeout_candidate(&self, epoch: u64, phase: MacosNativeTransactionPhase) { + let stream = { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + let completion = state + .candidate_completion + .as_ref() + .filter(|completion| { + let identity = completion.identity(); + identity.generation == epoch && identity.phase == phase + }) + .cloned(); + if state.candidate_epoch != Some(epoch) || completion.is_none() { + return; + } + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1); + state.candidate_epoch = None; + Self::forget_epoch_activity(&mut state, epoch); + #[cfg(test)] + { + state + .fixture_candidate_epoch + .take_if(|candidate| *candidate == epoch); + } + state + .pending_selection + .take_if(|pending| pending.epoch == epoch); + state + .pending_request + .take_if(|request| request.epoch == epoch); + state.candidate_completion = None; + if state + .pending_interruption + .is_some_and(|recovery| recovery.matches(epoch)) + { + state.pending_interruption = None; + } + state.candidate.take() + }; + if let Some(stream) = stream { + self.stop_stream(stream); + } + let error = MacosCaptureError::CaptureWorkerStartFailed(format!( + "macOS {phase} transaction {epoch} timed out" + )); + if self.shared.current_epoch() == 0 { + self.shared.set_status(MacosProtectedSourceState::Failed); + self.shared.publish_error(error); + } else { + self.shared.set_status(MacosProtectedSourceState::Live); + self.shared.publish_recoverable_error(error); + } + } + + fn begin_resolution(self: &Arc) -> Result { + let _lifecycle = lock(&self.lifecycle_start); + let resolution_epoch = self.shared.begin_resolution()?; + let resolution = SourceResolution::General(GeneralSourceResolution { + resolution_epoch, + selector: self.shared.selector(), + }); + self.install_source_transaction(resolution.clone(), Some(MACOS_NATIVE_SOURCE_TIMEOUT))?; + Ok(resolution) + } + + fn begin_picker_resolution(self: &Arc) -> Result { + let _lifecycle = lock(&self.lifecycle_start); + let resolution_epoch = self.shared.begin_resolution()?; + let resolution = SourceResolution::General(GeneralSourceResolution { + resolution_epoch, + selector: self.shared.selector(), + }); + self.install_source_transaction(resolution.clone(), None)?; + self.shared.enable_picker_callbacks(resolution.clone()); + Ok(resolution) + } + + fn set_selector(&self, selector: MacosCaptureSelector) { + let _lifecycle = lock(&self.lifecycle_start); + let source_settlement = self.cancel_source_transaction_locked(); + self.shared.disable_picker_callbacks(); + self.shared.set_selector(selector); + if let Some(settlement) = source_settlement { + settlement.publish(); + } + } + + fn set_selector_and_begin_resolution( + self: &Arc, + selector: MacosCaptureSelector, + ) -> Result { + let _lifecycle = lock(&self.lifecycle_start); + self.shared.set_selector(selector.clone()); + let resolution_epoch = self.shared.begin_resolution()?; + let resolution = SourceResolution::General(GeneralSourceResolution { + resolution_epoch, + selector, + }); + self.install_source_transaction(resolution.clone(), Some(MACOS_NATIVE_SOURCE_TIMEOUT))?; + Ok(resolution) + } + + #[cfg(test)] + fn begin_restart_diagnostic( + self: &Arc, + authorization_granted: bool, + selection_revision: u64, + ) -> Result< + ( + PostAuthorizationStreamDiagnosticResolution, + MacosStreamDiagnosticTransaction, + ), + MacosCaptureError, + > { + let _lifecycle = lock(&self.lifecycle_start); + self.shared + .set_selector(MacosCaptureSelector::PrimaryDisplay); + let (resolution, transaction) = self + .shared + .begin_restart_diagnostic(authorization_granted, selection_revision)?; + self.arm_restart_diagnostic(&resolution)?; + Ok((resolution, transaction)) + } + + fn reset_for_restart_diagnostic_locked( + &self, + state: &mut StreamState, + ) -> Result { + let selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + let lifecycle_revision = state + .lifecycle_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + self.shared.set_capture_active(false); + let current = state.current.take(); + let candidate = state.candidate.take(); + #[cfg(test)] + { + state.fixture_current_epoch = None; + state.fixture_candidate_epoch = None; + } + state.selection_revision = selection_revision; + state.lifecycle_revision = lifecycle_revision; + state.selected_filter = None; + state.pending_selection = None; + state.pending_interruption = None; + let candidate_settlement = Self::cancel_candidate_completion(state); + state.pending_request = None; + state.staging_epoch = None; + state.candidate_epoch = None; + state.inactive_epochs.clear(); + state.terminal_epochs.clear(); + self.shared.activate_epoch(0); + self.shared.clear_tahoe_selection(); + self.shared + .set_unconfirmed_selection(MacosCaptureSelection::None); + self.shared.set_capture_active(true); + Ok(RestartDiagnosticReset { + current, + candidate, + candidate_settlement, + }) + } + + fn setup_restart_diagnostic( + self: &Arc, + authorization_granted: bool, + ) -> Result< + ( + PostAuthorizationStreamDiagnosticResolution, + MacosStreamDiagnosticTransaction, + ), + MacosCaptureError, + > { + self.setup_restart_diagnostic_with(authorization_granted, || {}) + } + + fn setup_restart_diagnostic_with( + self: &Arc, + authorization_granted: bool, + setup_installed: impl FnOnce(), + ) -> Result< + ( + PostAuthorizationStreamDiagnosticResolution, + MacosStreamDiagnosticTransaction, + ), + MacosCaptureError, + > { + let (diagnostic, current, candidate, candidate_settlement, source_settlement) = { + let _lifecycle = lock(&self.lifecycle_start); + self.shared.disable_picker_callbacks(); + let source_settlement = self.cancel_source_transaction_locked(); + let mut state = lock(&self.state); + let RestartDiagnosticReset { + current, + candidate, + candidate_settlement, + } = self.reset_for_restart_diagnostic_locked(&mut state)?; + self.shared + .set_selector(MacosCaptureSelector::PrimaryDisplay); + let selection_revision = state.selection_revision; + let diagnostic = self + .shared + .begin_restart_diagnostic(authorization_granted, selection_revision); + if diagnostic.is_ok() { + self.shared.set_status(MacosProtectedSourceState::Starting); + } + drop(state); + setup_installed(); + ( + diagnostic, + current, + candidate, + candidate_settlement, + source_settlement, + ) + }; + if let Some(candidate) = candidate { + self.stop_stream(candidate); + } + if let Some(current) = current { + self.stop_stream(current); + } + if let Some(settlement) = source_settlement { + settlement.publish(); + } + Self::finish_replaced_candidate(candidate_settlement); + let (resolution, transaction) = diagnostic?; + self.arm_restart_diagnostic(&resolution)?; + Ok((resolution, transaction)) + } + + fn arm_restart_diagnostic( + self: &Arc, + resolution: &PostAuthorizationStreamDiagnosticResolution, + ) -> Result<(), MacosCaptureError> { + let Some(completion) = self + .shared + .restart_diagnostic_completion(resolution.attempt) + else { + return Ok(()); + }; + let cancel_streams = Arc::downgrade(self); + let attempt = resolution.attempt; + completion.set_cancel(move |_| { + if let Some(streams) = cancel_streams.upgrade() { + streams.finish_restart_diagnostic(attempt); + } + }); + let timeout_streams = Arc::downgrade(self); + let result = completion.arm( + self.native_lifecycle.deadlines(), + Instant::now() + MACOS_NATIVE_SOURCE_TIMEOUT, + move || { + if let Some(streams) = timeout_streams.upgrade() { + streams.finish_restart_diagnostic(attempt); + } + }, + ); + if let Err(source) = result { + let error = MacosCaptureError::CaptureWorkerStartFailed(format!( + "schedule macOS source resolution deadline: {source}" + )); + let settlement = { + let mut state = lock(&self.shared.restart_diagnostic); + let settlement = state + .active + .as_ref() + .filter(|active| active.attempt == attempt) + .and_then(|active| { + active + .completion + .claim(Err(MacosNativeTransactionError::Capture(error.clone()))) + }); + if settlement.is_some() { + state.active = None; + } + settlement + }; + self.shared.set_status(MacosProtectedSourceState::Failed); + if let Some(settlement) = settlement { + settlement.publish(); + } + return Err(error); + } + Ok(()) + } + + fn finish_restart_diagnostic(&self, attempt: PostAuthorizationStreamDiagnosticAttempt) { + let _lifecycle = lock(&self.lifecycle_start); + if self + .shared + .take_restart_diagnostic_attempt(attempt) + .is_none() + { + return; + } + let _ = self.shared.begin_resolution(); + self.shared.set_status(MacosProtectedSourceState::Failed); + } + + fn install_source_transaction( + self: &Arc, + resolution: SourceResolution, + timeout: Option, + ) -> Result<(), MacosCaptureError> { + let generation = match &resolution { + SourceResolution::General(resolution) => resolution.resolution_epoch, + SourceResolution::Diagnostic(resolution) => resolution.resolution_epoch, + }; + let completion = TransactionCompleter::new(TransactionIdentity { + generation, + phase: MacosNativeTransactionPhase::SourceResolution, + }); + let replaced = { + let mut state = lock(&self.source_transaction); + let settlement = state.as_ref().and_then(|replaced| { + let identity = replaced.completion.identity(); + replaced + .completion + .claim(Err(MacosNativeTransactionError::Cancelled { + phase: identity.phase, + generation: identity.generation, + })) + }); + *state = Some(SourceTransaction { + resolution: resolution.clone(), + completion: completion.clone(), + }); + settlement + }; + let Some(timeout) = timeout else { + if let Some(settlement) = replaced { + settlement.publish(); + } + return Ok(()); + }; + let streams = Arc::downgrade(self); + let result = completion.arm( + self.native_lifecycle.deadlines(), + Instant::now() + timeout, + move || { + if let Some(streams) = streams.upgrade() { + streams.timeout_source_resolution(resolution.clone()); + } + }, + ); + if let Err(source) = result { + let error = MacosCaptureError::CaptureWorkerStartFailed(format!( + "schedule macOS source resolution deadline: {source}" + )); + let settlement = { + let mut state = lock(&self.source_transaction); + let settlement = state + .as_ref() + .filter(|transaction| transaction.completion.shares_cell(&completion)) + .and_then(|transaction| { + transaction + .completion + .claim(Err(MacosNativeTransactionError::Capture(error.clone()))) + }); + if settlement.is_some() { + state.take(); + } + settlement + }; + if let Some(settlement) = settlement { + settlement.publish(); + } + if let Some(settlement) = replaced { + settlement.publish(); + } + return Err(error); + } + if let Some(settlement) = replaced { + settlement.publish(); + } + Ok(()) + } + + fn claim_source_transaction( + &self, + resolution: &SourceResolution, + ) -> Option> { + let _lifecycle = lock(&self.lifecycle_start); + { + let mut state = lock(&self.source_transaction); + let settlement = state + .as_ref() + .filter(|transaction| transaction.resolution == *resolution) + .and_then(|transaction| transaction.completion.claim(Ok(()))); + if settlement.is_some() { + state.take(); + } + settlement + } + } + + fn cancel_source_transaction( + &self, + resolution: &SourceResolution, + ) -> Option> { + let _lifecycle = lock(&self.lifecycle_start); + { + let mut state = lock(&self.source_transaction); + let settlement = state + .as_ref() + .filter(|transaction| transaction.resolution == *resolution) + .and_then(|transaction| { + let identity = transaction.completion.identity(); + transaction + .completion + .claim(Err(MacosNativeTransactionError::Cancelled { + phase: identity.phase, + generation: identity.generation, + })) + }); + if settlement.is_some() { + state.take(); + } + settlement + } + } + + fn cancel_source_transaction_locked(&self) -> Option> { + { + let mut state = lock(&self.source_transaction); + let settlement = state.as_ref().and_then(|transaction| { + let identity = transaction.completion.identity(); + transaction + .completion + .claim(Err(MacosNativeTransactionError::Cancelled { + phase: identity.phase, + generation: identity.generation, + })) + }); + state.take(); + settlement + } + } + + fn timeout_source_resolution(&self, resolution: SourceResolution) { + let _lifecycle = lock(&self.lifecycle_start); + let transaction = lock(&self.source_transaction) + .take_if(|transaction| transaction.resolution == resolution); + let Some(transaction) = transaction else { + return; + }; + let generation = transaction.completion.identity().generation; + let _ = self.shared.allocate_resolution_epoch(); + self.shared.consume_picker_resolution(&resolution); + let state = lock(&self.state); + let preserve_current = Self::current_epoch(&state).is_some(); + let preserve_selection = + state.pending_selection.is_some() || state.selected_filter.is_some(); + drop(state); + let error = MacosCaptureError::CaptureWorkerStartFailed(format!( + "macOS source resolution transaction {} timed out", + generation + )); + if preserve_current || preserve_selection { + self.shared.publish_recoverable_error(error); + } else { + self.shared.set_status(MacosProtectedSourceState::Failed); + self.shared.publish_error(error); + } + } + + fn reserve_selection_candidate_locked( + &self, + state: &mut StreamState, + epoch: u64, + candidate_request: MacosStreamRequest, + selection_filter: NativeSelectionFilter, + ) -> Result { + let authoritative_request = state + .pending_request + .as_ref() + .map_or(state.request, |pending| pending.request); + if candidate_request != authoritative_request { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "candidate request snapshot does not match the authoritative stream request" + .to_owned(), + )); + } + let selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + let lifecycle_revision = state + .lifecycle_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.selection_revision = selection_revision; + state.lifecycle_revision = lifecycle_revision; + state.pending_interruption = None; + let request = state.pending_request.take().map(|mut pending| { + pending.epoch = epoch; + pending + }); + let request_identity = request.as_ref().map(PendingStreamRequest::identity); + let replaced_settlement = + Self::install_candidate_completion(state, epoch, request.as_ref()); + state.pending_request = request; + state.pending_selection = Some(PendingSelectionFilter { + epoch, + selection_revision: state.selection_revision, + selection_filter: selection_filter.clone(), + }); + let stage = CandidateStage { + epoch, + selection_revision: state.selection_revision, + lifecycle_revision, + predecessor_epoch: Self::current_epoch(state), + recovery_current_epoch: None, + recovery: None, + request: request_identity, + }; + state.staging_epoch = Some(epoch); + if let Some(replaced_epoch) = state.candidate_epoch { + Self::forget_epoch_activity(state, replaced_epoch); + } + state.candidate_epoch = None; + Ok(CandidateReservation { + stage, + selection_filter, + replaced: state.candidate.take(), + replaced_settlement, + }) + } + + fn accept_selection_filter_with( + &self, + selection_filter: NativeSelectionFilter, + candidate_request: MacosStreamRequest, + epoch: u64, + resolution: SourceResolution, + picker: bool, + accepted: impl FnOnce(), + ) -> Result { + self.accept_selection_filter_with_hooks( + selection_filter, + candidate_request, + epoch, + resolution, + picker, + (|| {}, accepted), + ) + } + + fn accept_selection_filter_with_hooks( + &self, + selection_filter: NativeSelectionFilter, + candidate_request: MacosStreamRequest, + epoch: u64, + resolution: SourceResolution, + picker: bool, + hooks: (impl FnOnce(), impl FnOnce()), + ) -> Result { + let (before_transition, accepted) = hooks; + before_transition(); + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if !self.resolution_is_current(&state, &resolution) { + return Ok(FilterAcceptance::Stale); + } + if picker && !self.shared.consume_picker_resolution(&resolution) { + return Ok(FilterAcceptance::Stale); + } + let (acceptance, stored_selection) = if self.shared.capture_active() { + let request = state + .pending_request + .as_ref() + .map_or(state.request, |pending| pending.request); + let reservation = self.reserve_selection_candidate_locked( + &mut state, + epoch, + candidate_request, + selection_filter, + )?; + ( + FilterAcceptance::Candidate { + reservation: Box::new(reservation), + request, + }, + None, + ) + } else { + let selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + let lifecycle_revision = state + .lifecycle_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.selection_revision = selection_revision; + state.lifecycle_revision = lifecycle_revision; + state.pending_interruption = None; + state.staging_epoch = None; + state.pending_selection = None; + state.inactive_epochs.clear(); + state.terminal_epochs.clear(); + state.candidate_epoch = None; + let selection = selection_filter.selection.clone(); + state.selected_filter = Some(selection_filter); + let replaced = state.candidate.take(); + (FilterAcceptance::Stored(replaced), Some(selection)) + }; + if let SourceResolution::Diagnostic(diagnostic) = &resolution { + self.shared.record_filter_enumerated(diagnostic, epoch); + } + drop(state); + if let Some(selection) = stored_selection { + self.shared.set_unconfirmed_selection(selection); + self.shared.set_status(MacosProtectedSourceState::ReadyIdle); + } + accepted(); + Ok(acceptance) + } + + fn accept_selection_filter( + &self, + selection_filter: NativeSelectionFilter, + candidate_request: MacosStreamRequest, + epoch: u64, + resolution: SourceResolution, + picker: bool, + ) -> Result { + self.accept_selection_filter_with( + selection_filter, + candidate_request, + epoch, + resolution, + picker, + || {}, + ) + } + + fn resolution_is_current(&self, state: &StreamState, resolution: &SourceResolution) -> bool { + self.shared.source_resolution_is_current(resolution) + && match resolution { + SourceResolution::General(_) => true, + SourceResolution::Diagnostic(diagnostic) => { + state.selection_revision == diagnostic.attempt.selection_revision + } + } + } + + fn finalize_picker_cancel(&self, resolution: &SourceResolution) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let state = lock(&self.state); + if !self.resolution_is_current(&state, resolution) + || !self.shared.consume_picker_resolution(resolution) + { + return false; + } + let needs_selection = Self::current_epoch(&state).is_none() + && state.pending_selection.is_none() + && state.selected_filter.is_none(); + drop(state); + if needs_selection { + self.shared + .set_status(MacosProtectedSourceState::NeedsSelection); + } + true + } + + fn finalize_session_scoped_resolution(&self, resolution: &SourceResolution) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let state = lock(&self.state); + if !self.resolution_is_current(&state, resolution) { + return false; + } + drop(state); + self.shared + .set_status(MacosProtectedSourceState::NeedsSelection); + true + } + + fn finalize_resolution_error( + &self, + resolution: &SourceResolution, + consume_picker: bool, + error: MacosCaptureError, + ) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let state = lock(&self.state); + if !self.resolution_is_current(&state, resolution) + || (consume_picker && !self.shared.consume_picker_resolution(resolution)) + { + return false; + } + if let SourceResolution::Diagnostic(diagnostic) = resolution { + self.shared.record_non_stream_diagnostic_failure( + diagnostic, + MacosProtectedSourceState::Failed, + ); + } + let preserve_current = Self::current_epoch(&state).is_some(); + let preserve_selection = + state.pending_selection.is_some() || state.selected_filter.is_some(); + let preserve_status = + preserve_current || state.candidate_epoch.is_some() || state.staging_epoch.is_some(); + let status = (!preserve_status).then_some({ + if preserve_selection { + MacosProtectedSourceState::ReadyIdle + } else if matches!(error, MacosCaptureError::DisplaySourceUnavailable(_)) { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::Failed + } + }); + drop(state); + if let Some(status) = status { + self.shared.set_status(status); + } + if preserve_current || preserve_selection { + self.shared.publish_recoverable_error(error); + } else { + self.shared.publish_error(error); + } + true + } + + fn finalize_picker_failure( + &self, + resolution: &SourceResolution, + error: MacosCaptureError, + ) -> bool { + self.finalize_resolution_error(resolution, true, error) + } + + fn finalize_candidate_preparation_failure( + &self, + failure: CandidatePreparationFailure, + resolution: Option<&SourceResolution>, + ) -> bool { + self.finalize_candidate_preparation_failure_with(failure, resolution, || {}) + } + + fn finalize_candidate_preparation_failure_with( + &self, + mut failure: CandidatePreparationFailure, + resolution: Option<&SourceResolution>, + before_finalization: impl FnOnce(), + ) -> bool { + before_finalization(); + let finalized = (|| { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if resolution.is_some_and(|resolution| !self.resolution_is_current(&state, resolution)) + { + return false; + } + let failed_stage_cleared = state.staging_epoch != Some(failure.stage.epoch) + && state.candidate_epoch != Some(failure.stage.epoch) + && state + .pending_selection + .as_ref() + .is_none_or(|pending| pending.epoch != failure.stage.epoch); + let lifecycle_matches = failed_stage_cleared + && state.selection_revision == failure.stage.selection_revision + && state.lifecycle_revision == failure.stage.lifecycle_revision + && Self::current_epoch(&state) == failure.stage.predecessor_epoch + && state.staging_epoch.is_none() + && state.candidate_epoch.is_none(); + if !lifecycle_matches { + return false; + } + let Some(lifecycle_revision) = state.lifecycle_revision.checked_add(1) else { + return false; + }; + if let Some(SourceResolution::Diagnostic(diagnostic)) = resolution { + self.shared.record_non_stream_diagnostic_failure( + diagnostic, + MacosProtectedSourceState::Failed, + ); + } + let current_epoch = Self::current_epoch(&state); + let current_inactive = + current_epoch.is_some_and(|epoch| state.inactive_epochs.contains(&epoch)); + let preserve_current = current_epoch.is_some(); + let preserve_selection = state.selected_filter.is_some(); + let status = if preserve_current { + if current_inactive { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::Live + } + } else if preserve_selection { + MacosProtectedSourceState::ReadyIdle + } else if matches!( + &failure.error, + MacosCaptureError::DisplaySourceUnavailable(_) + ) { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::Failed + }; + state.lifecycle_revision = lifecycle_revision; + drop(state); + self.shared.set_status(status); + if preserve_current || preserve_selection { + self.shared.publish_recoverable_error(failure.error.clone()); + } else { + self.shared.publish_error(failure.error.clone()); + } + true + })(); + if let Some(settlement) = failure.settlement.take() { + (*settlement).publish(); + } + finalized + } + + fn stage_candidate_with_selection( + self: &Arc, + selection_filter: Option, + request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, + epoch: u64, + recovery: Option, + request_transaction: Option, + ) -> Result { + let failure_stage = { + let state = lock(&self.state); + CandidateStageIdentity { + epoch, + selection_revision: recovery.map_or(state.selection_revision, |recovery| { + recovery.selection_revision + }), + lifecycle_revision: state.lifecycle_revision, + predecessor_epoch: recovery + .is_none() + .then(|| Self::current_epoch(&state)) + .flatten(), + } + }; + let Some(reservation) = self + .reserve_candidate_stage( + epoch, + request, + selection_filter, + recovery, + request_transaction, + ) + .map_err(|error| CandidatePreparationFailure { + stage: failure_stage, + error, + settlement: None, + })? + else { + return Ok(false); + }; + self.prepare_and_start_candidate(reservation, request, reserve_pool) + } + + fn prepare_and_start_candidate( + self: &Arc, + reservation: CandidateReservation, + request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, + ) -> Result { + let CandidateReservation { + stage, + selection_filter, + replaced, + replaced_settlement, + } = reservation; + if let Some(replaced) = replaced { + self.stop_stream(replaced); + } + Self::finish_replaced_candidate(replaced_settlement); + let candidate = match NativeStream::prepare( + selection_filter, + request, + stage.epoch, + Arc::clone(&self.shared), + Arc::downgrade(self), + reserve_pool, + &self.native_lifecycle, + ) { + Ok(candidate) => candidate, + Err(error) => { + let (identity, settlement) = + self.cancel_candidate_stage(stage, Some(error.clone())); + return Err(CandidatePreparationFailure::new( + identity, error, settlement, + )); + } + }; + self.start_candidate_stage(candidate, stage) + } + + fn reserve_candidate_stage( + &self, + epoch: u64, + candidate_request: MacosStreamRequest, + candidate_selection: Option, + recovery: Option, + request_transaction: Option, + ) -> Result, MacosCaptureError> { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if !self.shared.capture_active() { + if let Some(request) = request_transaction { + let Some(settlement) = request.completion.claim(Ok(())) else { + return Ok(None); + }; + state.request = request.request; + state.pending_request = None; + drop(state); + settlement.publish(); + } + return Ok(None); + } + let selection_replacement = request_transaction.is_none() && recovery.is_none(); + match (request_transaction.as_ref(), state.pending_request.as_ref()) { + (Some(request), Some(_)) => { + return Err(MacosCaptureError::CaptureWorkerStartFailed(format!( + "stream request transaction {} cannot replace another pending request", + request.epoch + ))); + } + (None, pending) => { + let authoritative_request = + pending.map_or(state.request, |pending| pending.request); + if candidate_request != authoritative_request { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "candidate request snapshot does not match the authoritative stream request" + .to_owned(), + )); + } + } + _ => {} + } + let selection_filter = candidate_selection + .or_else(|| { + state + .pending_selection + .as_ref() + .map(|pending| pending.selection_filter.clone()) + }) + .or_else(|| state.selected_filter.clone()); + let Some(selection_filter) = selection_filter else { + let Some(request) = request_transaction else { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "candidate has no authoritative selection filter".to_owned(), + )); + }; + let Some(settlement) = request.completion.claim(Ok(())) else { + return Ok(None); + }; + state.request = request.request; + state.pending_request = None; + drop(state); + settlement.publish(); + return Ok(None); + }; + let lifecycle_revision = state + .lifecycle_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + 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 => { + if selection_replacement { + state.selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + } + state.pending_interruption = None; + None + } + }; + state.lifecycle_revision = lifecycle_revision; + let request = request_transaction.or_else(|| { + state.pending_request.take().map(|mut pending| { + pending.epoch = epoch; + pending + }) + }); + let request_identity = request.as_ref().map(PendingStreamRequest::identity); + let replaced_settlement = + Self::install_candidate_completion(&mut state, epoch, request.as_ref()); + state.pending_request = request; + state.pending_selection = Some(PendingSelectionFilter { + epoch, + selection_revision: state.selection_revision, + selection_filter: selection_filter.clone(), + }); + let stage = CandidateStage { + epoch, + selection_revision: state.selection_revision, + lifecycle_revision, + predecessor_epoch: Self::current_epoch(&state), + recovery_current_epoch: recovery.map(|_| current_epoch), + recovery, + request: request_identity, + }; + state.staging_epoch = Some(epoch); + if let Some(replaced_epoch) = state.candidate_epoch { + Self::forget_epoch_activity(&mut state, replaced_epoch); + } + state.candidate_epoch = None; + Ok(Some(CandidateReservation { + stage, + selection_filter, + replaced: state.candidate.take(), + replaced_settlement, + })) + } + + fn cancel_candidate_stage( + &self, + stage: CandidateStage, + error: Option, + ) -> (CandidateStageIdentity, Option>) { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + let mut identity = stage.identity(); + let current = state.lifecycle_revision == stage.lifecycle_revision + && state.staging_epoch == Some(stage.epoch); + let settlement = current.then(|| { + error.as_ref().and_then(|error| { + state + .candidate_completion + .as_ref() + .filter(|completion| completion.identity().generation == stage.epoch) + .and_then(|completion| { + completion.claim(Err(MacosNativeTransactionError::Capture(error.clone()))) + }) + }) + }); + if current { + state.staging_epoch = None; + state + .pending_selection + .take_if(|pending| pending.epoch == stage.epoch); + if stage + .recovery + .is_some_and(|recovery| state.pending_interruption == Some(recovery)) + { + state.pending_interruption = None; + } + if let Some(lifecycle_revision) = state.lifecycle_revision.checked_add(1) { + state.lifecycle_revision = lifecycle_revision; + identity.lifecycle_revision = lifecycle_revision; + } + } + if current { + state.candidate_completion = None; + } + if current + && stage.request.is_some_and(|request| { + state + .pending_request + .as_ref() + .is_some_and(|pending| pending.identity() == request) + }) + { + state.pending_request = None; + } + drop(state); + (identity, settlement.flatten()) + } + + #[cfg(test)] + fn fail_candidate_preparation_fixture( + &self, + stage: CandidateStage, + error: MacosCaptureError, + ) -> CandidatePreparationFailure { + let (identity, settlement) = self.cancel_candidate_stage(stage, Some(error.clone())); + CandidatePreparationFailure::new(identity, error, settlement) + } + + fn start_candidate_stage( + self: &Arc, + candidate: NativeStream, + stage: CandidateStage, + ) -> Result { + match self.arm_candidate_deadline( + stage.epoch, + MacosNativeTransactionPhase::StreamStart, + MACOS_NATIVE_START_TIMEOUT, + ) { + Ok(true) => {} + Ok(false) => { + let error = MacosCaptureError::CaptureWorkerStartFailed( + "stream request candidate was superseded before start".to_owned(), + ); + let (_, settlement) = self.cancel_candidate_stage(stage, Some(error)); + self.retire_unstarted_stream(candidate); + if let Some(settlement) = settlement { + settlement.publish(); + } + return Ok(false); + } + Err(error) => { + let (identity, settlement) = + self.cancel_candidate_stage(stage, Some(error.clone())); + self.retire_unstarted_stream(candidate); + return Err(CandidatePreparationFailure::new( + identity, error, settlement, + )); + } + } + let stream = candidate.stream.clone(); + let start_completion = candidate.start_completion.witness(); + let mut candidate = Some(candidate); + let started = self.invoke_candidate_start( + stage, + |state| state.candidate = candidate.take(), + || { + start_stream( + &stream, + stage.epoch, + Arc::downgrade(self), + Arc::clone(&self.shared), + start_completion, + ); + }, + ); + if !started { + let error = MacosCaptureError::CaptureWorkerStartFailed( + "stream request candidate was superseded before start".to_owned(), + ); + let (_, settlement) = self.cancel_candidate_stage(stage, Some(error)); + self.retire_unstarted_stream(candidate.expect("uninstalled candidate remains owned")); + if let Some(settlement) = settlement { + settlement.publish(); + } + return Ok(false); + } + Ok(true) + } + + fn invoke_candidate_start( + &self, + stage: CandidateStage, + install: impl FnOnce(&mut StreamState), + invoke_start: impl FnOnce(), + ) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + { + let mut state = lock(&self.state); + if !stage.begin(&mut state, &self.shared) { + return false; + } + install(&mut state); + self.shared.set_status(MacosProtectedSourceState::Starting); + } + invoke_start(); + true + } + + #[cfg(test)] + fn start_candidate_fixture(&self, stage: CandidateStage) -> bool { + self.start_candidate_fixture_with(stage, || {}) + } + + #[cfg(test)] + fn start_candidate_fixture_with( + &self, + stage: CandidateStage, + invoke_start: impl FnOnce(), + ) -> bool { + self.invoke_candidate_start( + stage, + |state| state.fixture_candidate_epoch = Some(stage.epoch), + invoke_start, + ) + } + + #[cfg(test)] + fn activate_candidate_fixture(&self, epoch: u64) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let rejected = lock(&self.rejected_epochs); + let mut state = lock(&self.state); + if !Self::candidate_is_activatable(&state, &rejected, epoch) { + return false; + } + let Some(lifecycle_revision) = state.lifecycle_revision.checked_add(1) else { + return false; + }; + let Some(completion) = state.candidate_completion.as_ref().cloned() else { + return false; + }; + let Some(settlement) = completion.claim(Ok(())) else { + return false; + }; + state.lifecycle_revision = lifecycle_revision; + state.candidate_epoch = None; + state.fixture_candidate_epoch = None; + state.fixture_current_epoch = Some(epoch); + Self::commit_pending_selection(&mut state, epoch); + state.candidate_completion = None; + Self::commit_pending_request(&mut state, epoch); + self.shared.activate_epoch(epoch); + drop(state); + settlement.publish(); + true + } + + #[cfg(test)] + fn fail_candidate_fixture(&self, epoch: u64, error: MacosCaptureError) -> bool { + let removal = self.remove(epoch, Some(MacosNativeTransactionError::Capture(error))); + if removal.role != StreamRole::Candidate { + return false; + } + if let Some(settlement) = removal.request_settlement { + settlement.publish(); + } + true + } + + #[cfg(test)] + fn drain_lifecycle_callbacks(&self) { + self.lifecycle_callbacks.exec_sync(|| {}); + } + + fn current_is_epoch(state: &StreamState, epoch: u64) -> bool { + let current = state.current.as_ref().map(NativeStream::epoch); + #[cfg(test)] + { + current.or(state.fixture_current_epoch) == Some(epoch) + } + #[cfg(not(test))] + { + current == Some(epoch) + } + } + + fn current_epoch(state: &StreamState) -> Option { + let current = state.current.as_ref().map(NativeStream::epoch); + #[cfg(test)] + { + current.or(state.fixture_current_epoch) + } + #[cfg(not(test))] + { + current + } + } + + fn tracks_epoch(state: &StreamState, epoch: u64) -> bool { + Self::current_is_epoch(state, epoch) + || state.candidate_epoch == Some(epoch) + || state + .candidate + .as_ref() + .is_some_and(|candidate| candidate.epoch() == epoch) + } + + fn forget_epoch_activity(state: &mut StreamState, epoch: u64) { + state.inactive_epochs.retain(|inactive| *inactive != epoch); + state.terminal_epochs.retain(|terminal| *terminal != epoch); + } + + fn record_stream_activity(&self, epoch: u64, active: bool, display_filter: bool) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if !Self::tracks_epoch(&state, epoch) { + return false; + } + if display_filter { + return false; + } + let changed = if active { + let changed = + state.inactive_epochs.contains(&epoch) || state.terminal_epochs.contains(&epoch); + Self::forget_epoch_activity(&mut state, epoch); + changed + } else if !state.inactive_epochs.contains(&epoch) { + state.inactive_epochs.push(epoch); + true + } else { + false + }; + let current = Self::current_is_epoch(&state, epoch); + drop(state); + if current { + self.shared.set_status(if active { + MacosProtectedSourceState::Live + } else { + MacosProtectedSourceState::NeedsSelection + }); + } + changed + } + + fn activate_candidate_for_publication( + &self, + state: &mut StreamState, + rejected: &[u64], + epoch: u64, + confirmed_delivery: Option, + after_claim: impl FnOnce(), + ) -> Result, Box> { + if !Self::candidate_is_activatable(state, rejected, epoch) { + return Ok(None); + } + let Some(lifecycle_revision) = state.lifecycle_revision.checked_add(1) else { + return Ok(None); + }; + let Some(confirmed_delivery) = confirmed_delivery else { + return Ok(None); + }; + #[cfg(not(test))] + if state.candidate.is_none() { + return Ok(None); + } + #[cfg(test)] + let fixture_candidate = state.fixture_candidate_epoch == Some(epoch); + #[cfg(test)] + if state.candidate.is_none() && !fixture_candidate { + return Ok(None); + } + let Some(request_completion) = state.candidate_completion.as_ref().cloned() else { + return Ok(None); + }; + let previous_epoch = Self::current_epoch(state); + let previous_status = self.shared.status(); + let previous_selection = self.shared.selection(); + let previous_request = state.request; + let previous_selected_filter = state.selected_filter.clone(); + let previous_inactive_epochs = state.inactive_epochs.clone(); + let previous_terminal_epochs = state.terminal_epochs.clone(); + let confirmed_selection = state.candidate.as_ref().map(|candidate| { + ( + candidate.selection.clone(), + Arc::clone(&candidate.source_id), + ) + }); + let Some(request_settlement) = request_completion.claim(Ok(())) else { + return Ok(None); + }; + if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(after_claim)) { + let removal = Self::remove_candidate_locked(state, epoch, None) + .expect("claimed candidate remains tracked until activation commits"); + return Err(Box::new(CandidateActivationAbort { + payload, + stream: removal.stream, + request_settlement, + })); + } + let candidate = state.candidate.take(); + #[cfg(test)] + state + .fixture_candidate_epoch + .take_if(|candidate| *candidate == epoch); + state.lifecycle_revision = lifecycle_revision; + state.candidate_epoch = None; + let previous = candidate.and_then(|candidate| state.current.replace(candidate)); + #[cfg(test)] + if fixture_candidate { + state.fixture_current_epoch = Some(epoch); + } + if let Some(previous_epoch) = previous_epoch { + Self::forget_epoch_activity(state, previous_epoch); + } + Self::commit_pending_selection(state, epoch); + state.candidate_completion = None; + Self::commit_pending_request(state, epoch); + let recovered = state + .pending_interruption + .take_if(|recovery| recovery.matches(epoch)) + .is_some(); + if let Some((selection, source_id)) = confirmed_selection { + self.shared + .confirm_selection(selection, source_id, epoch, confirmed_delivery); + } + self.shared.activate_epoch(epoch); + if recovered { + self.shared.set_status(MacosProtectedSourceState::Live); + } + Ok(Some(PublicationSideEffects { + candidate: Some(CandidatePublication { + previous, + previous_epoch, + previous_status, + previous_selection, + previous_request, + previous_selected_filter, + previous_inactive_epochs, + previous_terminal_epochs, + request_settlement, + }), + })) + } + + fn rollback_candidate_publication( + &self, + epoch: u64, + candidate: &mut CandidatePublication, + ) -> Option { + let mut state = lock(&self.state); + let failed = Self::current_is_epoch(&state, epoch) + .then(|| state.current.take()) + .flatten(); + state.current = candidate.previous.take(); + state.request = candidate.previous_request; + state.selected_filter = candidate.previous_selected_filter.take(); + state.pending_selection = None; + state.pending_request = None; + state.pending_interruption = None; + state.candidate_completion = None; + state.inactive_epochs = std::mem::take(&mut candidate.previous_inactive_epochs); + state.terminal_epochs = std::mem::take(&mut candidate.previous_terminal_epochs); + #[cfg(test)] + { + state.fixture_current_epoch = candidate.previous_epoch; + state.fixture_candidate_epoch = None; + } + self.shared + .activate_epoch(candidate.previous_epoch.unwrap_or_default()); + self.shared + .set_unconfirmed_selection(candidate.previous_selection.clone()); + self.shared.set_status(candidate.previous_status); + failed + } + + fn publish_decoded_sample( + &self, + epoch: u64, + sample: DecodedSample, + publication: &SamplePublication, + ) -> bool { + let is_frame = matches!(&sample.event, MacosFrameEvent::Frame(_)); + self.publish_decoded_event_if( + epoch, + is_frame, + sample.confirmed_delivery, + || publication.is_current(), + || self.shared.publish(sample.event), + ) + } + + fn publish_stream_lifecycle(&self, epoch: u64, status: MacosFrameStatus) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + let rejected = lock(&self.rejected_epochs); + let mut state = lock(&self.state); + if !self.shared.capture_active() + || rejected.contains(&epoch) + || !Self::tracks_epoch(&state, epoch) + { + return false; + } + let current = Self::current_is_epoch(&state, epoch); + if matches!( + status, + MacosFrameStatus::Suspended | MacosFrameStatus::Stopped + ) { + if state.terminal_epochs.contains(&epoch) { + return false; + } + let Some(lifecycle_revision) = state.lifecycle_revision.checked_add(1) else { + return false; + }; + state.lifecycle_revision = lifecycle_revision; + if !state.inactive_epochs.contains(&epoch) { + state.inactive_epochs.push(epoch); + } + state.terminal_epochs.push(epoch); + drop(state); + if current { + self.shared.publish(MacosFrameEvent::Lifecycle(status)); + } + return true; + } + if !current || state.inactive_epochs.contains(&epoch) { + return false; + } + drop(state); + self.shared.publish(MacosFrameEvent::Lifecycle(status)); + true + } + + #[cfg(test)] + fn publish_decoded_event_with( + &self, + epoch: u64, + is_frame: bool, + confirmed_delivery: Option, + publish: impl FnOnce(), + ) -> bool { + self.publish_decoded_event_if(epoch, is_frame, confirmed_delivery, || true, publish) + } + + fn publish_decoded_event_if( + &self, + epoch: u64, + is_frame: bool, + confirmed_delivery: Option, + publication_is_current: impl FnOnce() -> bool, + publish: impl FnOnce(), + ) -> bool { + self.publish_decoded_event_if_with_claim_hook( + epoch, + is_frame, + confirmed_delivery, + publication_is_current, + || {}, + publish, + ) + } + + #[cfg(test)] + fn publish_decoded_event_with_claim_hook( + &self, + epoch: u64, + confirmed_delivery: MacosValidatedStreamDelivery, + after_claim: impl FnOnce(), + publish: impl FnOnce(), + ) -> bool { + self.publish_decoded_event_if_with_claim_hook( + epoch, + true, + Some(confirmed_delivery), + || true, + after_claim, + publish, + ) + } + + fn publish_decoded_event_if_with_claim_hook( + &self, + epoch: u64, + is_frame: bool, + confirmed_delivery: Option, + publication_is_current: impl FnOnce() -> bool, + after_candidate_claim: impl FnOnce(), + publish: impl FnOnce(), + ) -> bool { + let lifecycle = lock(&self.lifecycle_start); + if !publication_is_current() { + return false; + } + let rejected = lock(&self.rejected_epochs); + let mut state = lock(&self.state); + if !self.shared.capture_active() + || rejected.contains(&epoch) + || state.inactive_epochs.contains(&epoch) + { + return false; + } + let side_effects = if Self::current_is_epoch(&state, epoch) { + PublicationSideEffects::default() + } else if is_frame { + match self.activate_candidate_for_publication( + &mut state, + &rejected, + epoch, + confirmed_delivery, + after_candidate_claim, + ) { + Ok(Some(side_effects)) => side_effects, + Ok(None) => return false, + Err(abort) => { + let CandidateActivationAbort { + payload, + stream, + request_settlement, + } = *abort; + if let Some(stream) = stream { + self.stop_stream(stream); + } + drop(request_settlement); + drop(state); + drop(rejected); + drop(lifecycle); + std::panic::resume_unwind(payload); + } + } + } else { + return false; + }; + drop(state); + if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(publish)) { + let mut side_effects = side_effects; + if let Some(mut candidate) = side_effects.candidate.take() { + if let Some(stream) = self.rollback_candidate_publication(epoch, &mut candidate) { + self.stop_stream(stream); + } + drop(candidate.request_settlement); + } + drop(rejected); + drop(lifecycle); + std::panic::resume_unwind(payload); + } + let previous = if let Some(candidate) = side_effects.candidate { + candidate.request_settlement.publish(); + candidate.previous + } else { + None + }; + drop(rejected); + drop(lifecycle); + if let Some(previous) = previous { + self.stop_stream(previous); + } + true + } + + fn commit_pending_request(state: &mut StreamState, epoch: u64) { + if let Some(request) = state + .pending_request + .take_if(|request| request.epoch == epoch) + { + state.request = request.request; + } + } + + fn commit_pending_selection(state: &mut StreamState, epoch: u64) { + if let Some(pending) = state + .pending_selection + .take_if(|pending| pending.epoch == epoch) + { + state.selected_filter = Some(pending.selection_filter); + } + } + + fn candidate_is_activatable(state: &StreamState, rejected: &[u64], epoch: u64) -> bool { + !rejected.contains(&epoch) + && !state.inactive_epochs.contains(&epoch) + && state.candidate_epoch == Some(epoch) + && state.pending_selection.as_ref().is_some_and(|pending| { + pending.epoch == epoch && pending.selection_revision == state.selection_revision + }) + } + + fn remove( + &self, + epoch: u64, + request_error: Option, + ) -> StreamRemoval { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if let Some(removal) = + Self::remove_candidate_locked(&mut state, epoch, request_error.as_ref()) + { + return removal; + } + if Self::current_is_epoch(&state, epoch) { + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1); + let current = state.current.take(); + Self::forget_epoch_activity(&mut state, epoch); + #[cfg(test)] + { + state + .fixture_current_epoch + .take_if(|current| *current == epoch); + } + self.shared.activate_epoch(0); + self.shared.clear_tahoe_selection(); + return StreamRemoval { + role: StreamRole::Current, + stream: current, + selection_revision: state.selection_revision, + request_settlement: None, + }; + } + StreamRemoval { + role: StreamRole::Stale, + stream: None, + selection_revision: state.selection_revision, + request_settlement: None, + } + } + + fn remove_candidate_locked( + state: &mut StreamState, + epoch: u64, + request_error: Option<&MacosNativeTransactionError>, + ) -> Option { + if state.candidate_epoch != Some(epoch) { + return None; + } + let request_settlement = request_error.and_then(|error| { + state + .candidate_completion + .as_ref() + .filter(|completion| completion.identity().generation == epoch) + .and_then(|completion| completion.claim(Err(error.clone()))) + }); + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1); + state.candidate_epoch = None; + Self::forget_epoch_activity(state, epoch); + #[cfg(test)] + { + state + .fixture_candidate_epoch + .take_if(|candidate| *candidate == epoch); + } + state + .pending_selection + .take_if(|pending| pending.epoch == epoch); + state.candidate_completion = None; + state + .pending_request + .take_if(|request| request.epoch == epoch); + if state + .pending_interruption + .is_some_and(|recovery| recovery.matches(epoch)) + { + state.pending_interruption = None; + } + Some(StreamRemoval { + role: StreamRole::Candidate, + stream: state.candidate.take(), + selection_revision: state.selection_revision, + request_settlement, + }) + } + + fn cancel_candidate_transaction(&self, epoch: u64) { + let removal = { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + let Some(removal) = Self::remove_candidate_locked(&mut state, epoch, None) else { + return; + }; + removal + }; + if let Some(stream) = removal.stream { + self.stop_stream(stream); + } + self.shared.set_status(if self.shared.current_epoch() == 0 { + MacosProtectedSourceState::ReadyIdle + } else { + MacosProtectedSourceState::Live + }); + } + + fn accepts_epoch(&self, epoch: u64) -> bool { + let rejected = lock(&self.rejected_epochs); + if rejected.contains(&epoch) { + return false; + } + let state = lock(&self.state); + !state.inactive_epochs.contains(&epoch) + && (state + .current + .as_ref() + .is_some_and(|stream| stream.epoch() == epoch) + || state + .candidate + .as_ref() + .is_some_and(|stream| stream.epoch() == epoch)) + } + + fn record_stream_start_success(&self, epoch: u64) { + let _lifecycle = lock(&self.lifecycle_start); + let rejected = lock(&self.rejected_epochs); + if rejected.contains(&epoch) { + return; + } + let tracked = { + let state = lock(&self.state); + state.candidate_epoch == Some(epoch) + || state + .current + .as_ref() + .is_some_and(|stream| stream.epoch() == epoch) + }; + if tracked { + self.shared + .record_stream_diagnostic_result(epoch, MacosProtectedSourceState::ReadyIdle); + } + } + + fn reject_epoch(&self, epoch: u64) { + let mut rejected = lock(&self.rejected_epochs); + if !rejected.contains(&epoch) { + rejected.push(epoch); + } + } + + fn clear_rejected_epoch(&self, epoch: u64) { + lock(&self.rejected_epochs).retain(|rejected| *rejected != epoch); + } + + fn selection_revision(&self) -> u64 { + lock(&self.state).selection_revision + } + + fn has_newer_lifecycle(&self, selection_revision: u64) -> bool { + let state = lock(&self.state); + state.selection_revision != selection_revision + || Self::current_epoch(&state).is_some() + || state.candidate_epoch.is_some() + || state.staging_epoch.is_some() + } + + fn finalize_stream_error( + &self, + role: StreamRole, + selection_revision: u64, + terminal_state: MacosProtectedSourceState, + error: MacosCaptureError, + ) { + let _lifecycle = lock(&self.lifecycle_start); + let state = lock(&self.state); + let current_epoch = Self::current_epoch(&state); + let preserve_current = role == StreamRole::Candidate && current_epoch.is_some(); + let superseded_candidate = role == StreamRole::Candidate + && (state.selection_revision != selection_revision + || state.candidate_epoch.is_some() + || state.staging_epoch.is_some()); + let superseded_current = role == StreamRole::Current + && (!self.shared.capture_active() + || state.selection_revision != selection_revision + || current_epoch.is_some() + || state.candidate_epoch.is_some() + || state.staging_epoch.is_some()); + let current_inactive = + current_epoch.is_some_and(|epoch| state.inactive_epochs.contains(&epoch)); + drop(state); + if superseded_candidate || superseded_current { + self.shared.publish_recoverable_error(error); + } else if preserve_current { + self.shared.set_status(if current_inactive { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::Live + }); + self.shared.publish_recoverable_error(error); + } else if role != StreamRole::Stale { + self.shared.set_status(terminal_state); + self.shared.publish_error(error); + } + } + + 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 { + let state = lock(&self.state); + state.pending_selection.is_some() || state.selected_filter.is_some() + } + + #[cfg(test)] + fn clear_selection(&self) -> Result<(), MacosCaptureError> { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + state.selection_revision = state + .selection_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.lifecycle_revision = state + .lifecycle_revision + .checked_add(1) + .ok_or(MacosCaptureError::SequenceExhausted)?; + state.selected_filter = None; + state.pending_selection = None; + state.pending_interruption = None; + let candidate_settlement = Self::cancel_candidate_completion(&mut state); + state.pending_request = None; + state.staging_epoch = None; + state.candidate_epoch = None; + state.inactive_epochs.clear(); + state.terminal_epochs.clear(); + drop(state); + Self::finish_replaced_candidate(candidate_settlement); + self.shared + .set_unconfirmed_selection(MacosCaptureSelection::None); + 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 request(&self) -> MacosStreamRequest { + let state = lock(&self.state); + state + .pending_request + .as_ref() + .map_or(state.request, |pending| pending.request) + } + + fn committed_request(&self) -> MacosStreamRequest { + lock(&self.state).request + } + + fn set_request( + self: &Arc, + request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, + ) -> Result { + let (transaction, reservation) = self.begin_request_candidate(request)?; + if let Some(reservation) = reservation + && let Err(failure) = + self.prepare_and_start_candidate(reservation, request, reserve_pool) + { + let error = failure.error.clone(); + self.finalize_candidate_preparation_failure(failure, None); + return Err(error); + } + Ok(transaction) + } + + fn begin_request_candidate( + self: &Arc, + request: MacosStreamRequest, + ) -> Result<(MacosStreamRequestTransaction, Option), MacosCaptureError> + { + { + let _lifecycle = lock(&self.lifecycle_start); + let state = lock(&self.state); + if state.pending_request.is_some() { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "another stream request transaction is still pending".to_owned(), + )); + } + if state.request == request { + let generation = self.shared.current_epoch(); + let (transaction, completion) = stream_request_transaction(generation); + if let Some(settlement) = completion.claim(Ok(())) { + settlement.publish(); + } + return Ok((transaction, None)); + } + } + let epoch = self.allocate_epoch()?; + let (transaction, completion) = stream_request_transaction(epoch); + let reservation = self.reserve_candidate_stage( + epoch, + request, + None, + None, + Some(PendingStreamRequest { + epoch, + request, + completion: completion.clone(), + }), + )?; + let cancel_streams = Arc::downgrade(self); + completion.set_cancel(move |generation| { + if let Some(streams) = cancel_streams.upgrade() { + streams.cancel_candidate_transaction(generation); + } + }); + Ok((transaction, reservation)) + } + + #[cfg(test)] + fn begin_request_candidate_fixture( + self: &Arc, + request: MacosStreamRequest, + ) -> Result<(MacosStreamRequestTransaction, Option), MacosCaptureError> { + let (transaction, reservation) = self.begin_request_candidate(request)?; + let Some(reservation) = reservation else { + return Ok((transaction, None)); + }; + let CandidateReservation { + stage, + replaced, + replaced_settlement, + .. + } = reservation; + Self::finish_replaced_candidate(replaced_settlement); + if !self.arm_candidate_deadline( + stage.epoch, + MacosNativeTransactionPhase::StreamStart, + MACOS_NATIVE_START_TIMEOUT, + )? { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "fixture request deadline was superseded before start".to_owned(), + )); + } + if !self.start_candidate_fixture(stage) { + return Err(MacosCaptureError::CaptureWorkerStartFailed( + "fixture request candidate was superseded before start".to_owned(), + )); + } + Ok((transaction, replaced)) + } + + fn current_stream(&self) -> Option> { + lock(&self.state) + .current + .as_ref() + .map(|current| current.stream.clone()) + } + + fn stage_interrupted_recovery( + self: &Arc, + plan: InterruptedRestagePlan, + ) -> Result { + let lifecycle_revision = lock(&self.state).lifecycle_revision; + let epoch = self + .allocate_epoch() + .map_err(|error| CandidatePreparationFailure { + stage: CandidateStageIdentity { + epoch: plan.recovery.interrupted_epoch, + selection_revision: plan.recovery.selection_revision, + lifecycle_revision, + predecessor_epoch: None, + }, + error, + settlement: None, + })?; + self.stage_candidate_with_selection( + Some(plan.selection_filter), + plan.request, + &plan.reserve_pool, + epoch, + Some(plan.recovery), + None, + ) + } + + fn begin_capture_activation(&self) -> Result { + let _lifecycle = lock(&self.lifecycle_start); + let mut state = lock(&self.state); + if self.shared.capture_active() { + return Ok(CaptureActivation::Unchanged); + } + let Some(selection_filter) = state.selected_filter.clone() else { + self.shared.set_capture_active(true); + return Ok(CaptureActivation::NeedsSelection); + }; + let epoch = self.allocate_epoch()?; + let request = state + .pending_request + .as_ref() + .map_or(state.request, |pending| pending.request); + let reservation = + self.reserve_selection_candidate_locked(&mut state, epoch, request, selection_filter)?; + self.shared.set_capture_active(true); + Ok(CaptureActivation::Candidate { + reservation: Box::new(reservation), + request, + }) + } + + fn set_capture_active(&self, active: bool) -> bool { + let _lifecycle = lock(&self.lifecycle_start); + if self.shared.set_capture_active(active) == active { + return false; + } + if active { + return true; + } + self.shared.disable_picker_callbacks(); + let source_settlement = self.cancel_source_transaction_locked(); + let (current, candidate, selection, candidate_settlement, diagnostic_settlement) = { + let mut state = lock(&self.state); + let diagnostic_settlement = self + .shared + .claim_restart_diagnostic_completion(MacosProtectedSourceState::Failed); + state.selection_revision = state.selection_revision.saturating_add(1); + state.lifecycle_revision = state.lifecycle_revision.saturating_add(1); + state.pending_interruption = None; + let candidate_settlement = Self::cancel_candidate_completion(&mut state); + state.staging_epoch = None; + state.pending_request = None; + state.candidate_epoch = None; + state.inactive_epochs.clear(); + state.terminal_epochs.clear(); + #[cfg(test)] + { + state.fixture_candidate_epoch = None; + state.fixture_current_epoch = None; + } + let mut selection = state.pending_selection.take().map(|pending| { + let selection = pending.selection_filter.selection.clone(); + state.selected_filter = Some(pending.selection_filter); + selection + }); + if state.current.is_none() + && state.selected_filter.is_none() + && let Some(candidate) = state.candidate.as_ref() + { + let selection_filter = NativeSelectionFilter { + filter: candidate.filter.clone(), + selection: candidate.selection.clone(), + source_id: Arc::clone(&candidate.source_id), + }; + selection = Some(selection_filter.selection.clone()); + state.selected_filter = Some(selection_filter); + } + ( + state.current.take(), + state.candidate.take(), + selection, + candidate_settlement, + diagnostic_settlement, + ) + }; + self.shared.activate_epoch(0); + self.shared.clear_tahoe_selection(); + if let Some(selection) = selection { + self.shared.set_unconfirmed_selection(selection); + } + drop(_lifecycle); + if let Some(candidate) = candidate { + self.stop_stream(candidate); + } + if let Some(current) = current { + self.stop_stream(current); + } + Self::finish_replaced_candidate(candidate_settlement); + if let Some(settlement) = source_settlement { + settlement.publish(); + } + if let Some(settlement) = diagnostic_settlement { + settlement.publish(); + } + true + } + + fn stop_stream(&self, stream: NativeStream) { + let start_completion = stream.start_completion.clone(); + let shared = Arc::clone(&self.shared); + let stop_shared = Arc::clone(&shared); + let timeout_shared = Arc::clone(&shared); + self.native_lifecycle.retire( + stream, + start_completion, + Instant::now() + MACOS_NATIVE_STOP_TIMEOUT, + move |stream, stop_completion| { + stream.worker.close(); + let completion = RcBlock::new(move |error: *mut NSError| { + // SAFETY: ScreenCaptureKit supplies either null or a live + // NSError for the duration of this callback. + if let Some(error) = unsafe { error.as_ref() } { + stop_shared.record_retirement_error(&native_error( + "stop ScreenCaptureKit stream", + error, + )); + } + let _ = stop_completion.complete(); + }); + // SAFETY: ScreenCaptureKit copies the completion block. The + // retirement registry retains the stream until it invokes or + // destroys that block and the decode worker has retired. + unsafe { + stream + .stream + .stopCaptureWithCompletionHandler(Some(&completion)); + } + if let Err(error) = stream.finish_worker_retirement() { + shared.record_retirement_error(&error); + } + }, + move || { + timeout_shared + .record_retirement_error(&MacosCaptureError::StreamStopCompletionLost); + }, + ); + } + + fn retire_stream_after_native_error(&self, stream: NativeStream) { + let start_completion = stream.start_completion.clone(); + let shared = Arc::clone(&self.shared); + self.native_lifecycle + .retire_without_native_stop(stream, start_completion, move |stream| { + if let Err(error) = stream.finish_worker_retirement() { + shared.counters.record_drop(&error); + } + }); + } + + fn retire_unstarted_stream(&self, stream: NativeStream) { + let start_completion = stream.start_completion.clone(); + drop(start_completion.witness()); + let shared = Arc::clone(&self.shared); + self.native_lifecycle + .retire_without_native_stop(stream, start_completion, move |stream| { + if let Err(error) = stream.finish_worker_retirement() { + shared.counters.record_drop(&error); + } + }); + } +} + +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, + streams: Weak, + shared: Arc, + start_completion: CompletionWitness, +) { + let completion = RcBlock::new(move |error: *mut NSError| { + let _ = start_completion.complete(); + // 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); + } else { + dispatch_stream_start_success(&streams, epoch); + } + }); + // 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 dispatch_stream_start_success(streams: &Weak, epoch: u64) { + let Some(streams) = streams.upgrade() else { + return; + }; + match streams.arm_candidate_deadline( + epoch, + MacosNativeTransactionPhase::FirstCompleteFrame, + MACOS_NATIVE_FIRST_FRAME_TIMEOUT, + ) { + Ok(true) => {} + Ok(false) => return, + Err(error) => { + let shared = Arc::clone(&streams.shared); + dispatch_owned_stream_error( + streams, + epoch, + shared, + MacosProtectedSourceState::Failed, + error, + ); + return; + } + } + let callbacks = streams.lifecycle_callbacks.clone(); + callbacks.exec_async(move || streams.record_stream_start_success(epoch)); +} + +fn handle_stream_error( + streams: &Weak, + epoch: u64, + shared: &Arc, + error: &NSError, +) { + let Some(streams) = streams.upgrade() else { + return; + }; + let state = classify_stream_error(error); + let error = native_error("ScreenCaptureKit stream", error); + let shared = Arc::clone(shared); + dispatch_owned_stream_error(streams, epoch, shared, state, error); +} + +fn dispatch_owned_stream_error( + streams: Arc, + epoch: u64, + shared: Arc, + state: MacosProtectedSourceState, + error: MacosCaptureError, +) { + streams.reject_epoch(epoch); + let callbacks = streams.lifecycle_callbacks.clone(); + callbacks.exec_async(move || { + handle_owned_stream_error(&streams, epoch, &shared, state, error); + }); +} + +fn handle_owned_stream_error( + streams: &Arc, + epoch: u64, + shared: &SessionShared, + state: MacosProtectedSourceState, + error: MacosCaptureError, +) { + handle_owned_stream_error_with(streams, epoch, shared, state, error, || {}); +} + +fn handle_owned_stream_error_with( + streams: &Arc, + epoch: u64, + shared: &SessionShared, + state: MacosProtectedSourceState, + error: MacosCaptureError, + after_retirement: impl FnOnce(), +) { + let mut removal = streams.remove( + epoch, + Some(MacosNativeTransactionError::Capture(error.clone())), + ); + streams.clear_rejected_epoch(epoch); + let state = if removal.role == StreamRole::Stale { + state + } else { + shared.record_stream_diagnostic_result(epoch, state) + }; + 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 { + streams.retire_stream_after_native_error(retired); + } + after_retirement(); + if let Some(recovery) = recovery { + let stream_error = 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.counters.record_drop(&stream_error); + streams.finalize_candidate_preparation_failure(stage_error, None); + } + } + if let Some(settlement) = removal.request_settlement.take() { + settlement.publish(); + } + return; + } + streams.finalize_stream_error(role, selection_revision, state, error); + if let Some(settlement) = removal.request_settlement.take() { + settlement.publish(); + } +} + +fn handle_fatal_stream_error( + streams: &Weak, + epoch: u64, + shared: Arc, + error: MacosCaptureError, +) { + shared.counters.record_drop(&error); + let Some(streams) = streams.upgrade() else { + return; + }; + streams.reject_epoch(epoch); + let callbacks = streams.lifecycle_callbacks.clone(); + callbacks.exec_async(move || { + handle_owned_fatal_stream_error(&streams, epoch, shared, error); + }); +} + +fn handle_owned_fatal_stream_error( + streams: &Arc, + epoch: u64, + shared: Arc, + error: MacosCaptureError, +) { + handle_owned_fatal_stream_error_with(streams, epoch, shared, error, || {}); +} + +fn handle_owned_fatal_stream_error_with( + streams: &Arc, + epoch: u64, + shared: Arc, + error: MacosCaptureError, + after_retirement: impl FnOnce(), +) { + let mut removal = streams.remove( + epoch, + Some(MacosNativeTransactionError::Capture(error.clone())), + ); + streams.clear_rejected_epoch(epoch); + if removal.role != StreamRole::Stale { + shared.record_stream_diagnostic_result(epoch, MacosProtectedSourceState::Failed); + } + let role = removal.role; + let selection_revision = removal.selection_revision; + if let Some(retired) = removal.stream { + streams.stop_stream(retired); + } + after_retirement(); + streams.finalize_stream_error( + role, + selection_revision, + MacosProtectedSourceState::Failed, + error, + ); + if let Some(settlement) = removal.request_settlement.take() { + settlement.publish(); + } +} + +struct PickerObserverIvars { + shared: Arc, + streams: Arc, + reserve_pool: PoolReservationFactory, +} + +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>, + ) { + let Some(resolution) = self.ivars().shared.picker_resolution() else { + return; + }; + let settlement = self.ivars().streams.cancel_source_transaction(&resolution); + self.ivars().streams.finalize_picker_cancel(&resolution); + if let Some(settlement) = settlement { + settlement.publish(); + } + } + + #[allow(non_snake_case)] + #[unsafe(method(contentSharingPicker:didUpdateWithFilter:forStream:))] + fn contentSharingPicker_didUpdateWithFilter_forStream( + &self, + _picker: &SCContentSharingPicker, + filter: &SCContentFilter, + _stream: Option<&SCStream>, + ) { + let Some(resolution) = self.ivars().shared.picker_resolution() else { + return; + }; + let settlement = self.ivars().streams.claim_source_transaction(&resolution); + accept_filter( + &self.ivars().streams, + &self.ivars().shared, + self.ivars().streams.request(), + &self.ivars().reserve_pool, + filter, + true, + ClaimedSourceResolution { + resolution, + settlement, + }, + ); + } + + #[allow(non_snake_case)] + #[unsafe(method(contentSharingPickerStartDidFailWithError:))] + fn contentSharingPickerStartDidFailWithError(&self, error: &NSError) { + let Some(resolution) = self.ivars().shared.picker_resolution() else { + return; + }; + let settlement = self.ivars().streams.claim_source_transaction(&resolution); + let error = native_error("ScreenCaptureKit picker", error); + self.ivars() + .streams + .finalize_picker_failure(&resolution, error); + if let Some(settlement) = settlement { + settlement.publish(); + } + } + } +); + +impl PickerObserver { + fn new( + mtm: MainThreadMarker, + request: MacosStreamRequest, + shared: Arc, + reserve_pool: PoolReservationFactory, + ) -> Result, MacosCaptureError> { + let streams = StreamSlot::new(Arc::clone(&shared), request)?; + let this = mtm.alloc::().set_ivars(PickerObserverIvars { + shared, + streams, + reserve_pool, + }); + // SAFETY: NSObject has no additional initialization requirements for + // this main-thread observer subclass. + Ok(unsafe { msg_send![super(this), init] }) + } + + fn request(&self) -> MacosStreamRequest { + self.ivars().streams.request() + } + + fn set_request( + &self, + request: MacosStreamRequest, + ) -> Result { + self.ivars() + .streams + .set_request(request, &self.ivars().reserve_pool) + } + + 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 set_active(&self, active: bool) { + if !active { + if !self.ivars().streams.set_capture_active(false) { + return; + } + let status = if self.ivars().streams.has_selection() { + MacosProtectedSourceState::ReadyIdle + } else { + MacosProtectedSourceState::NeedsSelection + }; + self.ivars().shared.set_status(status); + return; + } + match self.ivars().streams.begin_capture_activation() { + Ok(CaptureActivation::Unchanged) => {} + Ok(CaptureActivation::NeedsSelection) => self + .ivars() + .shared + .set_status(MacosProtectedSourceState::NeedsSelection), + Ok(CaptureActivation::Candidate { + reservation, + request, + }) => { + if let Err(failure) = self.ivars().streams.prepare_and_start_candidate( + *reservation, + request, + &self.ivars().reserve_pool, + ) { + self.ivars() + .streams + .finalize_candidate_preparation_failure(failure, None); + } + } + Err(error) => self.ivars().shared.counters.record_drop(&error), + } + } + + fn stop(&self) { + self.ivars().streams.set_capture_active(false); + } +} + +fn accept_filter( + streams: &Arc, + shared: &Arc, + request: MacosStreamRequest, + reserve_pool: &PoolReservationFactory, + filter: &SCContentFilter, + picker: bool, + claimed: ClaimedSourceResolution, +) { + let ClaimedSourceResolution { + resolution, + settlement, + } = claimed; + let commit = || { + let diagnostic = matches!(resolution, SourceResolution::Diagnostic(_)); + let selection_filter = match NativeSelectionFilter::retain(filter) { + Ok(selection_filter) => selection_filter, + Err(error) => { + streams.finalize_resolution_error(&resolution, picker, error); + return; + } + }; + let epoch = match streams.allocate_epoch() { + Ok(epoch) => epoch, + Err(error) => { + streams.finalize_resolution_error(&resolution, picker, error); + return; + } + }; + match streams.accept_selection_filter( + selection_filter, + request, + epoch, + resolution.clone(), + picker, + ) { + Ok(FilterAcceptance::Stale) => {} + Ok(FilterAcceptance::Stored(replaced)) => { + if let Some(replaced) = replaced { + streams.stop_stream(replaced); + } + if diagnostic { + shared + .record_stream_diagnostic_result(epoch, MacosProtectedSourceState::Failed); + } + } + Ok(FilterAcceptance::Candidate { + reservation, + request, + }) => match streams.prepare_and_start_candidate(*reservation, request, reserve_pool) { + Ok(true) => {} + Ok(false) => { + shared + .record_stream_diagnostic_result(epoch, MacosProtectedSourceState::Failed); + } + Err(failure) => { + streams.finalize_candidate_preparation_failure(failure, Some(&resolution)); + } + }, + Err(error) => { + streams.finalize_resolution_error(&resolution, false, error); + } + } + }; + commit(); + if let Some(settlement) = settlement { + settlement.publish(); + } +} + +struct MainThreadSession { + picker: Retained, + observer: Retained, +} + +pub struct MacosScreenCaptureSession { + main: MainThreadBound, + shared: Arc, + streams: Arc, + capabilities: MacosCaptureCapabilities, +} + +impl MacosScreenCaptureSession { + pub fn capabilities() -> Result { + native_capture_capabilities() + } + + pub fn new( + 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()?; + 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, capabilities, reserve_pool, mtm) + }) + } + + fn new_on_main( + request: MacosStreamRequest, + selector: MacosCaptureSelector, + capabilities: MacosCaptureCapabilities, + reserve_pool: PoolReservationFactory, + mtm: MainThreadMarker, + ) -> Result { + let authorized = CGPreflightScreenCaptureAccess(); + let status = if authorized { + MacosProtectedSourceState::NeedsSelection + } else { + MacosProtectedSourceState::NeedsUserAction + }; + let shared = Arc::new(SessionShared::new(status, selector, capabilities.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 + // 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); + let excluded_bundle_ids = NSArray::from_retained_slice(&[NSString::from_str( + HYPERCOLOR_UI_BUNDLE_IDENTIFIER, + )]); + configuration.setExcludedBundleIDs(&excluded_bundle_ids); + picker.setDefaultConfiguration(&configuration); + picker.setMaximumStreamCount(Some(&NSNumber::new_i32(2))); + let protocol: &ProtocolObject = + ProtocolObject::from_ref(&*observer); + picker.addObserver(protocol); + picker.setActive(true); + picker + }; + let session = Self { + main: MainThreadBound::new(MainThreadSession { picker, observer }, mtm), + shared, + streams, + capabilities, + }; + if authorized { + session.resolve_configured_source()?; + } + Ok(session) + } + + pub fn screen_authorized() -> bool { + CGPreflightScreenCaptureAccess() + } + + pub fn request_authorization(&self) -> MacosProtectedSourceState { + if CGRequestScreenCaptureAccess() { + self.shared + .set_status(MacosProtectedSourceState::NeedsSelection); + if let Err(error) = self.resolve_configured_source() { + self.shared.counters.record_drop(&error); + } + } else { + self.shared + .set_status(MacosProtectedSourceState::PermissionDenied); + } + self.shared.status() + } + + pub fn present_picker(&self) -> Result<(), MacosCaptureError> { + if !CGPreflightScreenCaptureAccess() { + self.shared + .set_status(MacosProtectedSourceState::NeedsUserAction); + return Err(MacosCaptureError::ScreenCapturePermissionRequired); + } + self.streams.begin_picker_resolution()?; + self.main + .get_on_main(|main| main.observer.present(&main.picker)); + Ok(()) + } + + pub fn status(&self) -> MacosProtectedSourceState { + self.shared.status() + } + + pub fn begin_post_authorization_stream_diagnostic( + &self, + ) -> Result { + if !CGPreflightScreenCaptureAccess() { + return Err(MacosCaptureError::ScreenCapturePermissionRequired); + } + let (resolution, completion_rx) = self.streams.setup_restart_diagnostic(true)?; + if let Err(error) = self.resolve_configured_source_with_resolution( + SourceResolution::Diagnostic(resolution.clone()), + ) { + self.shared + .fail_restart_diagnostic_attempt(resolution.attempt); + self.streams.finalize_resolution_error( + &SourceResolution::Diagnostic(resolution), + false, + error, + ); + } + Ok(completion_rx) + } + + pub fn selection(&self) -> MacosCaptureSelection { + self.shared.selection() + } + + pub fn selection_revision(&self) -> u64 { + self.streams.selection_revision() + } + + 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 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, + { + 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.main + .get_on_main(|main| main.observer.request().cursor_composed), + Box::new(move |result| { + completion(result.map(|references| { + MacosScreenshotReferenceCapture::new(source_id, generation, references) + })); + }), + ) + } + + pub fn mailbox(&self) -> MacosFrameMailbox { + self.shared.mailbox.clone() + } + + pub fn diagnostics(&self) -> MacosCaptureCallbackDiagnostics { + self.shared.diagnostics() + } + + pub fn stop(&self) { + self.set_capture_active(false); + } + + pub fn set_capture_active(&self, active: bool) { + self.main + .get_on_main(|main| main.observer.set_active(active)); + } + + pub fn set_selector(&self, selector: MacosCaptureSelector) -> Result<(), MacosCaptureError> { + if CGPreflightScreenCaptureAccess() { + let resolution = self.streams.set_selector_and_begin_resolution(selector)?; + self.resolve_configured_source_with_resolution(resolution) + } else { + self.streams.set_selector(selector); + self.shared + .set_status(MacosProtectedSourceState::NeedsUserAction); + Ok(()) + } + } + + pub fn set_stream_request( + &self, + request: MacosStreamRequest, + ) -> Result<(), MacosNativeTransactionError> { + self.begin_stream_request(request)?.wait() + } + + pub fn begin_stream_request( + &self, + request: MacosStreamRequest, + ) -> Result { + request.cadence.timescale()?; + self.capabilities + .validate_dynamic_range(request.dynamic_range)?; + self.main + .get_on_main(|main| main.observer.set_request(request)) + } + + pub fn stream_request(&self) -> MacosStreamRequest { + self.streams.committed_request() + } + + fn resolve_configured_source(&self) -> Result<(), MacosCaptureError> { + let resolution = self.streams.begin_resolution()?; + self.resolve_configured_source_with_resolution(resolution) + } + + fn resolve_configured_source_with_resolution( + &self, + resolution: SourceResolution, + ) -> Result<(), MacosCaptureError> { + let selector = resolution.selector().clone(); + if selector == MacosCaptureSelector::SessionScoped { + let settlement = self.streams.claim_source_transaction(&resolution); + self.streams.finalize_session_scoped_resolution(&resolution); + if let Some(settlement) = settlement { + settlement.publish(); + } + return Ok(()); + } + resolve_display_selector( + Arc::clone(&self.streams), + Arc::clone(&self.shared), + self.main.get_on_main(|main| main.observer.request()), + self.main + .get_on_main(|main| Arc::clone(&main.observer.ivars().reserve_pool)), + selector, + resolution, + ) + } +} + +fn resolve_display_selector( + streams: Arc, + shared: Arc, + request: MacosStreamRequest, + reserve_pool: PoolReservationFactory, + selector: MacosCaptureSelector, + resolution: SourceResolution, +) -> Result<(), MacosCaptureError> { + let completion = RcBlock::new( + move |content: *mut SCShareableContent, error: *mut NSError| { + if !source_resolution_is_current(&streams, &shared, &resolution) { + return; + } + let settlement = streams.claim_source_transaction(&resolution); + // 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 !source_resolution_is_current(&streams, &shared, &resolution) { + if let Some(settlement) = settlement { + settlement.publish(); + } + return; + } + match result { + Ok(filter) => { + accept_filter( + &streams, + &shared, + request, + &reserve_pool, + &filter, + false, + ClaimedSourceResolution { + resolution: resolution.clone(), + settlement, + }, + ); + } + Err(error) => { + streams.finalize_resolution_error(&resolution, false, error); + if let Some(settlement) = settlement { + settlement.publish(); + } + } + } + }, + ); + // 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 source_resolution_is_current( + streams: &StreamSlot, + shared: &SessionShared, + resolution: &SourceResolution, +) -> bool { + shared.source_resolution_is_current(resolution) + && match resolution { + SourceResolution::General(_) => true, + SourceResolution::Diagnostic(diagnostic) => { + streams.selection_revision() == diagnostic.attempt.selection_revision + } + } +} + +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() }; + // SAFETY: The same immutable shareable-content snapshot retains its + // returned window array and each member. + let excluded_windows = unsafe { content.windows() } + .to_vec() + .into_iter() + .filter(|window| { + // SAFETY: Every retained SCWindow and owning application belongs + // to this immutable shareable-content snapshot. + unsafe { + window.owningApplication().is_some_and(|application| { + is_hypercolor_ui_bundle_identifier(&application.bundleIdentifier().to_string()) + }) + } + }) + .collect::>(); + let excluded = NSArray::::from_retained_slice(&excluded_windows); + 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) { + // SAFETY: The filter retains the selected display and the stable + // Hypercolor window identities 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( + 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 source_id = display_source_id(display_id)?; + return Ok(MacosCaptureSelection::Display { + source_id: Arc::from(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 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))?; + 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> { + #[link(name = "ColorSync", kind = "framework")] + 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 + .debug_struct("MacosScreenCaptureSession") + .field("status", &self.status()) + .finish_non_exhaustive() + } +} + +impl Drop for MainThreadSession { + fn drop(&mut self) { + self.observer.stop(); + // 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); + self.picker.removeObserver(protocol); + self.picker.setActive(false); + } + } +} + +fn native_capture_capabilities() -> Result { + let screenshot_configuration = AnyClass::get(c"SCScreenshotConfiguration"); + let screenshot_manager = AnyClass::get(c"SCScreenshotManager"); + let probes = MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: capability( + crate::screenshot::tahoe_reference_output_symbols_present(), + ), + 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: + )) + })), + }; + capture_capabilities_from_probes( + sysctl_i32(c"hw.optional.arm64", "hw.optional.arm64"), + sysctl_i32(c"sysctl.proc_translated", "sysctl.proc_translated"), + probes, + ) +} + +const fn capability(present: bool) -> MacosRuntimeCapability { + if present { + MacosRuntimeCapability::Present + } else { + MacosRuntimeCapability::Absent + } +} + +#[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( + 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(SysctlI32Value::Present(value)) + } else if status != 0 { + 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")) + } +} + +fn stream_configuration( + filter: &SCContentFilter, + request: MacosStreamRequest, +) -> Result< + ( + Retained, + bool, + MacosPixelExtent, + MacosConfiguredStream, + ), + 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: 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 = match request.preset() { + MacosStreamPreset::SdrDefault => SCStreamConfiguration::new(), + MacosStreamPreset::CaptureHdrStreamCanonicalDisplay => { + SCStreamConfiguration::streamConfigurationWithPreset( + SCStreamConfigurationPreset::CaptureHDRStreamCanonicalDisplay, + ) + } + }; + 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); + if request.dynamic_range == MacosCaptureDynamicRange::Sdr { + configuration.setCaptureDynamicRange(SCCaptureDynamicRange::SDR); + configuration.setPixelFormat(0x4247_5241); + } + configuration + }; + // 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)] +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 { + // 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) +} + +fn decode_sample( + decoder: &mut MacosFrameDecoder, + delivery_validator: &mut MacosStreamDeliveryValidator, + sample: RetainedNativeSample, +) -> Result { + let awaiting_first_delivery = matches!( + delivery_validator.state(), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) + ); + let frame = decode_complete_frame( + sample.pixel_buffer, + Some(sample.admission_lifetime), + sample.cursor_composed, + ) + .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!( + validator.state(), + MacosStreamDeliveryState::AwaitingFirstCompleteFrame(_) + ) { + return reject_first_delivery(validator, error); + } + match error { + MacosCaptureError::StreamDeliveryRejected(rejection) => { + MacosCaptureError::FrameDeliveryDropped(rejection) + } + error => error, + } +} + +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")) + } + MacosCaptureError::StreamDeliveryRejected(rejection) => Some(*rejection), + _ => None, + }; + rejection.map_or(error, |rejection| { + validator.reject_delivery(rejection); + MacosCaptureError::StreamDeliveryRejected(rejection) + }) +} + +fn decode_complete_frame( + pixel_buffer: CFRetained, + admission_lifetime: Option, + 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 (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, + 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 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. + 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, + ) + }; + // SAFETY: A null mode pointer explicitly requests no attachment-mode + // output, and the retained result survives the pixel-buffer query. + let Some(value) = (unsafe { pixel_buffer.attachment(location_key, ptr::null_mut()) }) else { + // ScreenCaptureKit display streams can deliver 4:2:0 buffers that + // carry the YCbCr matrix but no chroma-location attachment + // (observed on macOS 26). ITU-T H.273 defines left siting as the + // default for unsignalled 4:2:0 video and AVFoundation samples + // under the same assumption, so absence is a defaulting case, not + // a delivery-contract violation. A present-but-unrecognized value + // still fails below. + return Ok(MacosChromaLocation::Left); + }; + let value = value + .downcast::() + .map_err(|_| MacosCaptureError::UnsupportedColorAttachment("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 { + // ScreenCaptureKit has shipped dirty rects both as NSValue-wrapped + // CGRects and as CGRect dictionary representations; accept either. + let rect = ns_value_rect(value).or_else(|| dictionary_rect(value))?; + pixel_rect_from_cg(rect) +} + +fn ns_value_rect(value: &CFType) -> Option { + let object = >::as_ref(value); + object.downcast_ref::()?.get_rect() +} + +fn dictionary_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. + unsafe { CGRectMakeWithDictionaryRepresentation(Some(dictionary), &mut rect) }.then_some(rect) +} + +fn pixel_rect_from_cg(rect: CGRect) -> Option { + // Dirty rects are a damage hint. Scaled displays deliver fractional + // coordinates, so round outward to the containing integer rect: the + // damaged area must always be covered, never trimmed. + let left = rect.origin.x.floor(); + let top = rect.origin.y.floor(); + let right = (rect.origin.x + rect.size.width).ceil(); + let bottom = (rect.origin.y + rect.size.height).ceil(); + let x = exact_i64(left)?; + let y = exact_i64(top)?; + let width = exact_u32(right - left)?; + let height = exact_u32(bottom - top)?; + 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) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex, mpsc}; + use std::thread; + use std::time::Duration; + + use super::{ + CandidateReservation, CandidateStage, InterruptedRestage, InterruptionRecoveryPhase, + MacosCaptureColorimetry, MacosCaptureDynamicRange, MacosCaptureError, + MacosCapturePixelFormat, MacosColorPrimaries, MacosColorRange, MacosConfiguredStream, + MacosDeliveredFrameMetadata, MacosFrameEvent, MacosFrameStatus, MacosHostArchitecture, + MacosNativeTransactionError, MacosNativeTransactionPhase, MacosPixelExtent, + MacosProtectedSourceState, MacosRuntimeCapability, MacosStreamDeliveryRejection, + MacosStreamDeliveryState, MacosStreamDeliveryValidator, MacosStreamPreset, + MacosStreamRequestTransaction, MacosTahoeCapabilities, MacosTahoeRuntimeProbes, + MacosTransferFunction, MacosValidatedStreamDelivery, NativeSelectionFilter, NativeStream, + PendingStreamRequest, PoolBackingLifetime, PoolObservation, SCCaptureDynamicRange, + SCStreamConfiguration, SCStreamConfigurationPreset, ScreenshotCaptureBackend, + ScreenshotFilterHandle, ScreenshotIdentityFence, ScreenshotImageCompletion, + ScreenshotTransactionSnapshot, SessionShared, SourceResolution, StreamSlot, SysctlI32Value, + capture_capabilities_from_probes, capture_dynamic_range, classify_delivery_error, + color_range_from_fourcc, conservative_pool_quote, execute_screenshot_transaction, + is_hypercolor_ui_bundle_identifier, route_retained_delivery, route_stream_activity, + route_stream_lifecycle, session_selection_source_id, stream_request_transaction, + with_admitted_surface, + }; + use crate::worker::{LatestSampleWorker, SamplePublishOutcome}; + use crate::{ + MacosCaptureCadence, MacosScreenshotReferenceCapability, MacosScreenshotReferenceImage, + MacosScreenshotReferenceSet, MacosStreamRequest, + }; + + struct FixtureScreenshotCall { + filter_id: u64, + dynamic_range: MacosCaptureDynamicRange, + completion: ScreenshotImageCompletion, + } + + #[test] + fn fractional_dirty_rects_round_outward_to_cover_the_damage() { + let rect = objc2_core_foundation::CGRect { + origin: objc2_core_foundation::CGPoint { x: 10.25, y: 7.5 }, + size: objc2_core_foundation::CGSize { + width: 99.5, + height: 41.25, + }, + }; + let pixel = super::pixel_rect_from_cg(rect).expect("fractional rect must decode"); + assert_eq!( + (pixel.x, pixel.y, pixel.width, pixel.height), + (10, 7, 100, 42), + ); + } + + #[test] + fn chroma_location_defaults_to_left_when_unsignalled() { + let mut raw: *mut objc2_core_video::CVPixelBuffer = std::ptr::null_mut(); + // SAFETY: The out-pointer is a valid stack slot and no attribute + // dictionary is supplied, matching the documented contract. + let status = unsafe { + objc2_core_video::CVPixelBufferCreate( + None, + 4, + 4, + 0x3432_3076, + None, + std::ptr::NonNull::from(&mut raw), + ) + }; + assert_eq!(status, 0, "CVPixelBufferCreate failed: {status}"); + // SAFETY: A zero status guarantees a retained, non-null buffer that + // this test now owns. + let buffer = unsafe { + objc2_core_foundation::CFRetained::from_raw( + std::ptr::NonNull::new(raw).expect("created pixel buffer"), + ) + }; + + let location = super::chroma_location(&buffer); + assert!( + matches!(location, Ok(crate::MacosChromaLocation::Left)), + "unsignalled chroma location must default to left siting, got {location:?}", + ); + } + + #[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, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Absent, + screenshot_capture_selector: MacosRuntimeCapability::Absent, + }; + + #[test] + fn hypercolor_ui_exclusion_matches_only_the_stable_app_bundle() { + assert!(is_hypercolor_ui_bundle_identifier( + "tech.hyperbliss.hypercolor" + )); + assert!(!is_hypercolor_ui_bundle_identifier( + "tech.hyperbliss.hypercolor.daemon" + )); + assert!(!is_hypercolor_ui_bundle_identifier( + "com.example.hypercolor" + )); + } + + #[test] + fn stream_selection_revision_advances_monotonically_across_lifecycles() { + let shared = Arc::new(SessionShared::new( + MacosProtectedSourceState::ReadyIdle, + super::MacosCaptureSelector::Auto, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + )); + let streams = StreamSlot::new(shared, MacosStreamRequest::default()) + .expect("fixture native lifecycle starts"); + assert_eq!(streams.selection_revision(), 0); + + assert!(streams.set_capture_active(true)); + assert!(streams.set_capture_active(false)); + assert_eq!(streams.selection_revision(), 1); + + assert!(streams.set_capture_active(true)); + assert!(streams.set_capture_active(false)); + assert_eq!(streams.selection_revision(), 2); + } + + #[test] + fn incomplete_native_delivery_never_enters_the_latest_frame_slot() { + let latest_frame_slot_called = AtomicBool::new(false); + let lifecycle_called = AtomicBool::new(false); + + route_retained_delivery( + super::RetainedNativeDelivery::<()>::Lifecycle(MacosFrameStatus::Idle), + |_| latest_frame_slot_called.store(true, Ordering::Release), + |status| { + assert_eq!(status, MacosFrameStatus::Idle); + lifecycle_called.store(true, Ordering::Release); + }, + ); + + assert!(!latest_frame_slot_called.load(Ordering::Acquire)); + assert!(lifecycle_called.load(Ordering::Acquire)); + } + + fn stream_slot_fixture(current_epoch: u64, selection_revision: u64) -> Arc { + let shared = Arc::new(SessionShared::new( + MacosProtectedSourceState::Live, + super::MacosCaptureSelector::Auto, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + )); + shared.set_capture_active(true); + shared.activate_epoch(current_epoch); + let streams = StreamSlot::new(shared, MacosStreamRequest::default()) + .expect("fixture native lifecycle starts"); + { + let mut state = super::lock(&streams.state); + state.selection_revision = selection_revision; + state.selected_filter = Some(NativeSelectionFilter::fixture(1)); + state.fixture_current_epoch = (current_epoch != 0).then_some(current_epoch); + } + streams + } + + fn reserve_selection_candidate_fixture( + streams: &StreamSlot, + epoch: u64, + request: MacosStreamRequest, + selection_id: u64, + ) -> Result)>, MacosCaptureError> { + streams + .reserve_candidate_stage( + epoch, + request, + Some(NativeSelectionFilter::fixture(selection_id)), + None, + None, + ) + .map(|reservation| { + reservation.map(|reservation| { + StreamSlot::finish_replaced_candidate(reservation.replaced_settlement); + (reservation.stage, reservation.replaced) + }) + }) + } + + fn reserve_request_candidate_fixture( + streams: &StreamSlot, + epoch: u64, + request: MacosStreamRequest, + pending: PendingStreamRequest, + ) -> Result)>, MacosCaptureError> { + streams + .reserve_candidate_stage(epoch, request, None, None, Some(pending)) + .map(|reservation| { + reservation.map(|reservation| { + StreamSlot::finish_replaced_candidate(reservation.replaced_settlement); + (reservation.stage, reservation.replaced) + }) + }) + } + + fn pending_request( + epoch: u64, + request: MacosStreamRequest, + ) -> (PendingStreamRequest, MacosStreamRequestTransaction) { + let (transaction, completion) = stream_request_transaction(epoch); + ( + PendingStreamRequest { + epoch, + request, + completion, + }, + transaction, + ) + } + + fn selection_filter_ids(streams: &StreamSlot) -> (Option, Option<(u64, u64)>) { + let state = super::lock(&streams.state); + ( + state + .selected_filter + .as_ref() + .map(NativeSelectionFilter::fixture_id), + state + .pending_selection + .as_ref() + .map(|pending| (pending.epoch, pending.selection_filter.fixture_id())), + ) + } + + fn sdr_delivery_fixture() -> MacosValidatedStreamDelivery { + 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("fixture delivery metadata should be valid"); + MacosValidatedStreamDelivery { + configured, + delivered, + } + } + + #[test] + fn current_publication_holds_lifecycle_until_publish_precedes_deactivation() { + let streams = stream_slot_fixture(41, 9); + let (publishing_tx, publishing_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let publishing_streams = Arc::clone(&streams); + let publisher = thread::spawn(move || { + publishing_streams.publish_decoded_event_with(41, false, None, || { + publishing_tx + .send(()) + .expect("publication should be observable"); + release_rx.recv().expect("publication should resume"); + publishing_streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + }) + }); + publishing_rx + .recv_timeout(Duration::from_secs(1)) + .expect("current publication should hold the lifecycle gate"); + assert!(streams.state.try_lock().is_ok()); + + let (deactivation_started_tx, deactivation_started_rx) = mpsc::channel(); + let (deactivated_tx, deactivated_rx) = mpsc::channel(); + let deactivating_streams = Arc::clone(&streams); + let deactivator = thread::spawn(move || { + deactivation_started_tx + .send(()) + .expect("deactivation attempt should be observable"); + let changed = deactivating_streams.set_capture_active(false); + deactivating_streams + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + deactivated_tx + .send(changed) + .expect("deactivation should be observable"); + }); + deactivation_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should reach the lifecycle gate"); + assert_eq!( + deactivated_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + assert_eq!(streams.shared.current_epoch(), 41); + + release_tx.send(()).expect("publication should be released"); + assert!(publisher.join().expect("publisher thread should join")); + assert!( + deactivated_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should follow publication") + ); + deactivator.join().expect("deactivation thread should join"); + assert_eq!(streams.shared.current_epoch(), 0); + assert_eq!( + streams.shared.status(), + MacosProtectedSourceState::ReadyIdle + ); + } + + #[test] + fn candidate_first_frame_publish_holds_lifecycle_until_deactivation() { + let streams = stream_slot_fixture(41, 9); + let (stage, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 2) + .expect("candidate reservation should succeed") + .expect("active capture should admit the candidate"); + assert!(streams.start_candidate_fixture(stage)); + let (publishing_tx, publishing_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let publishing_streams = Arc::clone(&streams); + let publisher = thread::spawn(move || { + publishing_streams.publish_decoded_event_with( + 42, + true, + Some(sdr_delivery_fixture()), + || { + publishing_tx + .send(()) + .expect("first-frame publication should be observable"); + release_rx.recv().expect("publication should resume"); + publishing_streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + }, + ) + }); + publishing_rx + .recv_timeout(Duration::from_secs(1)) + .expect("candidate activation should reach publication under the lifecycle gate"); + assert!(streams.state.try_lock().is_ok()); + assert_eq!(streams.shared.current_epoch(), 42); + assert_eq!(selection_filter_ids(&streams), (Some(2), None)); + + let (deactivation_started_tx, deactivation_started_rx) = mpsc::channel(); + let (deactivated_tx, deactivated_rx) = mpsc::channel(); + let deactivating_streams = Arc::clone(&streams); + let deactivator = thread::spawn(move || { + deactivation_started_tx + .send(()) + .expect("deactivation attempt should be observable"); + let changed = deactivating_streams.set_capture_active(false); + deactivating_streams + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + deactivated_tx + .send(changed) + .expect("deactivation should be observable"); + }); + deactivation_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should reach the lifecycle gate"); + assert_eq!( + deactivated_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + + release_tx.send(()).expect("publication should be released"); + assert!(publisher.join().expect("publisher thread should join")); + assert!( + deactivated_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should follow first-frame publication") + ); + deactivator.join().expect("deactivation thread should join"); + assert_eq!(streams.shared.current_epoch(), 0); + assert_eq!(selection_filter_ids(&streams), (Some(2), None)); + assert_eq!( + streams.shared.status(), + MacosProtectedSourceState::ReadyIdle + ); + } + + #[test] + fn stale_picker_resolution_cannot_mutate_filter_acceptance() { + let streams = stream_slot_fixture(41, 9); + let stale = streams + .begin_resolution() + .expect("picker resolution should begin"); + streams.shared.enable_picker_callbacks(stale); + let picker_resolution = streams + .shared + .picker_resolution() + .expect("picker update should retain its exact resolution"); + let initial_revision = streams.selection_revision(); + let initial_selection = selection_filter_ids(&streams); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let accepting_streams = Arc::clone(&streams); + let accepting = thread::spawn(move || { + accepting_streams.accept_selection_filter_with_hooks( + NativeSelectionFilter::fixture(2), + MacosStreamRequest::default(), + 42, + picker_resolution, + true, + ( + || { + ready_tx + .send(()) + .expect("retained picker filter should be observable"); + release_rx + .recv() + .expect("picker filter acceptance should resume"); + }, + || panic!("stale picker filter must not be accepted"), + ), + ) + }); + ready_rx + .recv_timeout(Duration::from_secs(1)) + .expect("picker filter should pause before the lifecycle transition"); + + let fresh = streams + .begin_picker_resolution() + .expect("newer picker resolution should begin"); + release_tx + .send(()) + .expect("stale picker acceptance should resume"); + assert!(matches!( + accepting.join().expect("acceptance thread should join"), + Ok(super::FilterAcceptance::Stale) + )); + assert_eq!(streams.selection_revision(), initial_revision); + assert_eq!(selection_filter_ids(&streams), initial_selection); + + let retry = streams + .accept_selection_filter( + NativeSelectionFilter::fixture(3), + MacosStreamRequest::default(), + 43, + fresh, + true, + ) + .expect("fresh resolution should be accepted"); + assert!(matches!(retry, super::FilterAcceptance::Candidate { .. })); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((43, 3)))); + } + + fn install_live_successor(streams: &StreamSlot, epoch: u64) { + let (stage, _) = reserve_selection_candidate_fixture( + streams, + epoch, + MacosStreamRequest::default(), + epoch, + ) + .expect("successor reservation should succeed") + .expect("active capture should admit the successor"); + assert!(streams.start_candidate_fixture(stage)); + assert!(streams.activate_candidate_fixture(epoch)); + assert!(streams.publish_decoded_event_with(epoch, false, None, || { + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + })); + } + + fn assert_retired_error_cannot_overwrite_successor(fatal: bool) { + let streams = stream_slot_fixture(41, 9); + let error = MacosCaptureError::CaptureWorkerStartFailed( + "retired injected stream failure".to_owned(), + ); + let (retired_tx, retired_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let failing_streams = Arc::clone(&streams); + let failing_shared = Arc::clone(&streams.shared); + let finalizer = thread::spawn(move || { + let after_retirement = || { + retired_tx + .send(()) + .expect("retired stream should be observable"); + release_rx.recv().expect("error finalization should resume"); + }; + if fatal { + super::handle_owned_fatal_stream_error_with( + &failing_streams, + 41, + failing_shared, + error, + after_retirement, + ); + } else { + super::handle_owned_stream_error_with( + &failing_streams, + 41, + &failing_shared, + MacosProtectedSourceState::PermissionDenied, + error, + after_retirement, + ); + } + }); + retired_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old error should pause after retirement"); + assert_eq!(streams.shared.current_epoch(), 0); + + install_live_successor(&streams, 42); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + release_tx + .send(()) + .expect("old error finalization should resume"); + finalizer.join().expect("error finalizer should join"); + + assert_eq!(streams.shared.current_epoch(), 42); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle))) + )); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::RecoverableError(_))) + )); + } + + #[test] + fn ordinary_error_finalization_cannot_overwrite_live_successor() { + assert_retired_error_cannot_overwrite_successor(false); + } + + #[test] + fn fatal_error_finalization_cannot_overwrite_live_successor() { + assert_retired_error_cannot_overwrite_successor(true); + } + + #[test] + fn duplicate_fatal_callbacks_invalidate_the_owned_epoch_once() { + let streams = stream_slot_fixture(41, 9); + let error = MacosCaptureError::CaptureWorkerStartFailed( + "duplicate fatal callback fixture".to_owned(), + ); + + super::handle_owned_fatal_stream_error( + &streams, + 41, + Arc::clone(&streams.shared), + error.clone(), + ); + super::handle_owned_fatal_stream_error(&streams, 41, Arc::clone(&streams.shared), error); + + assert!(matches!( + streams.shared.mailbox.take_latest_with_generation(), + Some((_, 1, Err(MacosCaptureError::CaptureWorkerStartFailed(_)))) + )); + assert!(!streams.shared.mailbox.has_pending()); + } + + #[test] + fn retired_preparation_failure_cannot_overwrite_live_successor() { + let streams = stream_slot_fixture(41, 9); + let removal = streams.remove(41, None); + assert_eq!(removal.role, super::StreamRole::Current); + assert_eq!(streams.shared.current_epoch(), 0); + let recovery = InterruptedRestage::interrupted(41, 9); + let reservation = streams + .reserve_candidate_stage( + 42, + MacosStreamRequest::default(), + Some(NativeSelectionFilter::fixture(1)), + Some(recovery), + None, + ) + .expect("interrupted restage should reserve") + .expect("active capture should admit interrupted restage"); + let CandidateReservation { + stage, + replaced, + replaced_settlement, + .. + } = reservation; + StreamSlot::finish_replaced_candidate(replaced_settlement); + assert!(replaced.is_none()); + let failure = streams.fail_candidate_preparation_fixture( + stage, + MacosCaptureError::CaptureWorkerStartFailed( + "retired interrupted restage failed to prepare".to_owned(), + ), + ); + let (paused_tx, paused_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (finalized_tx, finalized_rx) = mpsc::channel(); + let failing_streams = Arc::clone(&streams); + let finalizer = thread::spawn(move || { + let finalized = + failing_streams.finalize_candidate_preparation_failure_with(failure, None, || { + paused_tx + .send(()) + .expect("post-retirement finalization pause should be observable"); + release_rx + .recv() + .expect("preparation finalization should resume"); + }); + finalized_tx + .send(finalized) + .expect("preparation finalization result should be observable"); + }); + paused_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old preparation failure should pause after retirement"); + + let (successor, _) = + reserve_selection_candidate_fixture(&streams, 43, MacosStreamRequest::default(), 43) + .expect("successor reservation should succeed") + .expect("active capture should admit the successor"); + assert!(streams.start_candidate_fixture(successor)); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Starting); + release_tx + .send(()) + .expect("old preparation finalization should resume"); + assert!( + !finalized_rx + .recv_timeout(Duration::from_secs(1)) + .expect("stale preparation finalization should finish") + ); + finalizer.join().expect("preparation finalizer should join"); + + assert_eq!(streams.shared.current_epoch(), 0); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Starting); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))) + )); + assert!(streams.activate_candidate_fixture(43)); + assert!(streams.publish_decoded_event_with(43, false, None, || { + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + })); + assert_eq!(streams.shared.current_epoch(), 43); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle))) + )); + } + + #[test] + fn preparation_failure_revision_rejects_request_only_aba() { + let original = MacosStreamRequest::default(); + let request_a = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("first request-only candidate should be valid"); + let request_b = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(45), true) + .expect("second request-only candidate should be valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = original; + + let (pending_a, completion_a) = pending_request(42, request_a); + let (stage_a, _) = reserve_request_candidate_fixture(&streams, 42, request_a, pending_a) + .expect("first request candidate should reserve") + .expect("active capture should stage the first request candidate"); + assert_eq!(streams.selection_revision(), 9); + let revision_a = stage_a.lifecycle_revision; + let failure_a = streams.fail_candidate_preparation_fixture( + stage_a, + MacosCaptureError::CaptureWorkerStartFailed( + "first request candidate failed to prepare".to_owned(), + ), + ); + assert!(failure_a.stage.lifecycle_revision > revision_a); + let failure_a_revision = failure_a.stage.lifecycle_revision; + assert_eq!(completion_a.try_recv(), Err(mpsc::TryRecvError::Empty)); + + let (paused_tx, paused_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (finalized_tx, finalized_rx) = mpsc::channel(); + let failing_streams = Arc::clone(&streams); + let finalizer_a = thread::spawn(move || { + let finalized = failing_streams.finalize_candidate_preparation_failure_with( + failure_a, + None, + || { + paused_tx + .send(()) + .expect("first finalizer pause should be observable"); + release_rx.recv().expect("first finalizer should resume"); + }, + ); + finalized_tx + .send(finalized) + .expect("first finalizer result should be observable"); + }); + paused_rx + .recv_timeout(Duration::from_secs(1)) + .expect("first finalizer should pause before lifecycle validation"); + assert_eq!(completion_a.try_recv(), Err(mpsc::TryRecvError::Empty)); + + let (pending_b, completion_b) = pending_request(43, request_b); + let (stage_b, _) = reserve_request_candidate_fixture(&streams, 43, request_b, pending_b) + .expect("second request candidate should reserve") + .expect("active capture should stage the second request candidate"); + assert_eq!(streams.selection_revision(), 9); + assert!(stage_b.lifecycle_revision > failure_a_revision); + let error_b = MacosCaptureError::CaptureWorkerStartFailed( + "second request candidate failed to prepare".to_owned(), + ); + let failure_b = streams.fail_candidate_preparation_fixture(stage_b, error_b.clone()); + assert!(failure_b.stage.lifecycle_revision > stage_b.lifecycle_revision); + let failure_b_revision = failure_b.stage.lifecycle_revision; + assert_eq!(completion_b.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(streams.finalize_candidate_preparation_failure(failure_b, None)); + assert!( + completion_b + .recv() + .expect("second request should complete after finalization") + .is_err() + ); + assert!(super::lock(&streams.state).lifecycle_revision > failure_b_revision); + assert_eq!(streams.selection_revision(), 9); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + + release_tx + .send(()) + .expect("first finalizer should resume after the ABA lifecycle"); + assert!( + !finalized_rx + .recv_timeout(Duration::from_secs(1)) + .expect("stale first finalizer should finish") + ); + finalizer_a.join().expect("first finalizer should join"); + assert!( + completion_a + .recv() + .expect("first request should complete after stale finalization") + .is_err() + ); + + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::RecoverableError(error))) if error.as_ref() == &error_b + )); + } + + #[test] + fn current_inactive_epoch_rejects_queued_frame_publication() { + let streams = stream_slot_fixture(41, 9); + streams.record_stream_activity(41, false, false); + let published = AtomicBool::new(false); + + assert!(!streams.publish_decoded_event_with( + 41, + true, + Some(sdr_delivery_fixture()), + || { + published.store(true, Ordering::Release); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + }, + )); + assert!(!published.load(Ordering::Acquire)); + assert_eq!(streams.shared.current_epoch(), 41); + assert_eq!( + streams.shared.status(), + MacosProtectedSourceState::NeedsSelection + ); + } + + #[test] + fn terminal_lifecycle_generation_rejects_frames_until_stream_reactivation() { + let streams = stream_slot_fixture(41, 9); + assert!(streams.publish_stream_lifecycle(41, MacosFrameStatus::Suspended)); + let stale_published = AtomicBool::new(false); + + assert!(!streams.publish_decoded_event_with(41, true, None, || { + stale_published.store(true, Ordering::Release); + })); + assert!(!stale_published.load(Ordering::Acquire)); + assert!(matches!( + streams.shared.mailbox.take_latest_with_generation(), + Some(( + _, + 1, + Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Suspended)) + )) + )); + + streams.record_stream_activity(41, true, false); + let resumed_published = AtomicBool::new(false); + assert!(streams.publish_decoded_event_with(41, true, None, || { + resumed_published.store(true, Ordering::Release); + })); + assert!(resumed_published.load(Ordering::Acquire)); + } + + #[test] + fn rejected_terminal_lifecycle_does_not_advance_decode_generation() { + let streams = stream_slot_fixture(41, 9); + let mut worker = LatestSampleWorker::spawn( + "macos-capture-terminal-generation-test", + |sample: ()| sample, + |(), _publication| {}, + ) + .expect("generation worker should start"); + let samples = worker.input(); + let initial_generation = samples.generation(); + + route_stream_lifecycle(&samples, &streams, 999, MacosFrameStatus::Suspended); + assert_eq!(samples.generation(), initial_generation); + + route_stream_lifecycle(&samples, &streams, 41, MacosFrameStatus::Suspended); + let terminal_generation = samples.generation(); + assert!(terminal_generation > initial_generation); + + route_stream_lifecycle(&samples, &streams, 41, MacosFrameStatus::Suspended); + assert_eq!(samples.generation(), terminal_generation); + + worker.close(); + worker.join().expect("generation worker should join"); + } + + #[test] + fn terminal_invalidation_crossing_cannot_publish_the_old_generation() { + let streams = stream_slot_fixture(41, 9); + let publish_streams = Arc::clone(&streams); + let old_published = Arc::new(AtomicBool::new(false)); + let new_published = Arc::new(AtomicBool::new(false)); + let worker_old_published = Arc::clone(&old_published); + let worker_new_published = Arc::clone(&new_published); + let (publication_entered_tx, publication_entered_rx) = mpsc::sync_channel(1); + let (release_publication_tx, release_publication_rx) = mpsc::sync_channel(1); + let (publication_result_tx, publication_result_rx) = mpsc::sync_channel(2); + let mut worker = LatestSampleWorker::spawn( + "macos-capture-terminal-crossing-test", + |sample| sample, + move |sample, publication| { + if sample == 1 { + publication_entered_tx + .send(()) + .expect("old publication should hold the generation lock"); + release_publication_rx + .recv() + .expect("old publication should resume"); + } + let published = publish_streams.publish_decoded_event_if( + 41, + true, + None, + || publication.is_current(), + || { + if sample == 1 { + worker_old_published.store(true, Ordering::Release); + } else { + worker_new_published.store(true, Ordering::Release); + } + }, + ); + publication_result_tx + .send((sample, published)) + .expect("publication outcome should be observable"); + }, + ) + .expect("crossing worker should start"); + let samples = worker.input(); + + assert_eq!(samples.publish(1), SamplePublishOutcome::Accepted); + publication_entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old decode should enter generation-locked publication"); + + let terminal_samples = samples.clone(); + let terminal_streams = Arc::clone(&streams); + let (invalidation_requested_tx, invalidation_requested_rx) = mpsc::sync_channel(1); + let (terminal_done_tx, terminal_done_rx) = mpsc::sync_channel(1); + let terminal = thread::spawn(move || { + let accepted = terminal_samples.invalidate_if_observed( + || { + invalidation_requested_tx + .send(()) + .expect("terminal invalidation request should be observable"); + }, + || terminal_streams.publish_stream_lifecycle(41, MacosFrameStatus::Suspended), + ); + terminal_done_tx + .send(accepted) + .expect("terminal outcome should be observable"); + }); + invalidation_requested_rx + .recv_timeout(Duration::from_secs(1)) + .expect("terminal callback should request invalidation"); + + let active_samples = samples.clone(); + let active_streams = Arc::clone(&streams); + let (active_started_tx, active_started_rx) = mpsc::sync_channel(1); + let (active_done_tx, active_done_rx) = mpsc::sync_channel(1); + let active = thread::spawn(move || { + active_started_tx + .send(()) + .expect("active callback start should be observable"); + route_stream_activity(&active_samples, &active_streams, 41, true, false); + active_done_tx + .send(()) + .expect("active callback completion should be observable"); + }); + active_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("exact active callback should start after terminal invalidation"); + assert_eq!( + active_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + release_publication_tx + .send(()) + .expect("old generation publication should resume"); + + assert_eq!( + publication_result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old publication should settle"), + (1, false) + ); + assert!(!old_published.load(Ordering::Acquire)); + assert!( + terminal_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("terminal transition should settle") + ); + terminal.join().expect("terminal callback should join"); + active_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("exact active callback should follow terminal invalidation"); + active.join().expect("active callback should join"); + + assert_eq!(samples.publish(2), SamplePublishOutcome::Accepted); + assert_eq!( + publication_result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("new generation should publish"), + (2, true) + ); + assert!(new_published.load(Ordering::Acquire)); + + worker.close(); + worker.join().expect("crossing worker should join"); + } + + #[test] + fn candidate_terminal_lifecycle_blocks_first_frame_until_exact_reactivation() { + for status in [MacosFrameStatus::Suspended, MacosFrameStatus::Stopped] { + let streams = stream_slot_fixture(41, 9); + let (stage, _) = reserve_selection_candidate_fixture( + &streams, + 42, + MacosStreamRequest::default(), + 42, + ) + .expect("candidate reservation should succeed") + .expect("active capture should admit the candidate"); + assert!(streams.start_candidate_fixture(stage)); + let revision = super::lock(&streams.state).lifecycle_revision; + + assert!(!streams.publish_stream_lifecycle(999, status)); + assert_eq!(super::lock(&streams.state).lifecycle_revision, revision); + assert!(streams.publish_stream_lifecycle(42, status)); + let terminal_revision = super::lock(&streams.state).lifecycle_revision; + assert!(terminal_revision > revision); + assert!(!streams.publish_stream_lifecycle(42, status)); + assert_eq!( + super::lock(&streams.state).lifecycle_revision, + terminal_revision + ); + assert!(!streams.shared.mailbox.has_pending()); + + let stale_published = AtomicBool::new(false); + assert!(!streams.publish_decoded_event_with( + 42, + true, + Some(sdr_delivery_fixture()), + || stale_published.store(true, Ordering::Release), + )); + assert!(!stale_published.load(Ordering::Acquire)); + assert_eq!(streams.shared.current_epoch(), 41); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(42)); + + streams.record_stream_activity(42, true, false); + let resumed_published = AtomicBool::new(false); + assert!(streams.publish_decoded_event_with( + 42, + true, + Some(sdr_delivery_fixture()), + || resumed_published.store(true, Ordering::Release), + )); + assert!(resumed_published.load(Ordering::Acquire)); + assert_eq!(streams.shared.current_epoch(), 42); + } + } + + #[test] + fn candidate_inactive_epoch_rejects_first_frame_activation() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("candidate request should be valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("candidate reservation should succeed") + .expect("active capture should admit the candidate"); + assert!(streams.start_candidate_fixture(stage)); + streams.record_stream_activity(42, false, false); + let published = AtomicBool::new(false); + + assert!(!streams.publish_decoded_event_with( + 42, + true, + Some(sdr_delivery_fixture()), + || published.store(true, Ordering::Release), + )); + assert!(!published.load(Ordering::Acquire)); + assert_eq!(streams.shared.current_epoch(), 41); + assert_eq!(streams.committed_request(), original); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + let state = super::lock(&streams.state); + assert_eq!(state.candidate_epoch, Some(42)); + assert_eq!( + state.pending_request.as_ref().map(|request| request.epoch), + Some(42) + ); + } + + #[test] + fn selection_stage_adopting_a_pending_request_keeps_deadline_authority() { + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("candidate request should be valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = MacosStreamRequest::default(); + let (pending, transaction) = pending_request(42, next); + reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request reservation should succeed") + .expect("active capture should admit the candidate"); + + reserve_selection_candidate_fixture(&streams, 43, next, 43) + .expect("selection reservation should succeed") + .expect("the selection stage should adopt the in-flight request"); + + let armed = streams + .arm_candidate_deadline( + 43, + MacosNativeTransactionPhase::StreamStart, + Duration::from_secs(5), + ) + .expect("deadline arming should not error"); + assert!( + armed, + "the adopted transaction must answer to the stage that owns it now" + ); + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + let state = super::lock(&streams.state); + assert_eq!( + state + .candidate_completion + .as_ref() + .map(|completion| completion.identity().generation), + Some(43) + ); + assert_eq!( + state.pending_request.as_ref().map(|request| request.epoch), + Some(43) + ); + } + + #[test] + fn cancelling_an_adopted_request_tears_down_the_adopting_stage() { + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("candidate request should be valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = MacosStreamRequest::default(); + let (pending, transaction) = pending_request(42, next); + reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request reservation should succeed") + .expect("active capture should admit the candidate"); + let (stage, _) = reserve_selection_candidate_fixture(&streams, 43, next, 43) + .expect("selection reservation should succeed") + .expect("the selection stage should adopt the in-flight request"); + assert!(streams.start_candidate_fixture(stage)); + let cancel_streams = Arc::clone(&streams); + { + let state = super::lock(&streams.state); + state + .candidate_completion + .as_ref() + .expect("candidate completion is installed") + .set_cancel(move |generation| { + cancel_streams.cancel_candidate_transaction(generation); + }); + } + + assert!(transaction.cancel()); + + let state = super::lock(&streams.state); + assert_eq!(state.candidate_epoch, None); + assert!(state.candidate_completion.is_none()); + assert!(state.pending_request.is_none()); + } + + #[test] + fn display_current_inactive_callback_does_not_block_publication() { + let streams = stream_slot_fixture(41, 9); + streams.record_stream_activity(41, false, true); + let published = AtomicBool::new(false); + + assert!(streams.publish_decoded_event_with(41, true, None, || { + published.store(true, Ordering::Release); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + })); + assert!(published.load(Ordering::Acquire)); + assert_eq!(streams.shared.current_epoch(), 41); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + } + + #[test] + fn display_candidate_inactive_callback_does_not_strand_request() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("candidate request should be valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("candidate reservation should succeed") + .expect("active capture should admit the candidate"); + assert!(streams.start_candidate_fixture(stage)); + streams.record_stream_activity(42, false, true); + + assert!( + streams.publish_decoded_event_with(42, true, Some(sdr_delivery_fixture()), || { + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + },) + ); + assert_eq!(completion.recv(), Ok(Ok(()))); + assert_eq!(streams.shared.current_epoch(), 42); + assert_eq!(streams.committed_request(), next); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + } + + fn assert_latest_lifecycle(streams: &StreamSlot, expected: MacosFrameStatus) { + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(actual))) if actual == expected + )); + } + + #[test] + fn stale_picker_cancel_cannot_overwrite_successor_starting() { + let streams = stream_slot_fixture(0, 0); + super::lock(&streams.state).selected_filter = None; + let stale = streams + .begin_picker_resolution() + .expect("first picker resolution should begin"); + let fresh = streams + .begin_picker_resolution() + .expect("successor picker resolution should begin"); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started)); + + assert!(!streams.finalize_picker_cancel(&stale)); + assert_eq!(streams.shared.picker_resolution(), Some(fresh)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Starting); + assert_latest_lifecycle(&streams, MacosFrameStatus::Started); + } + + #[test] + fn stale_picker_failure_cannot_overwrite_successor_live() { + let streams = stream_slot_fixture(41, 9); + let stale = streams + .begin_picker_resolution() + .expect("first picker resolution should begin"); + let fresh = streams + .begin_picker_resolution() + .expect("successor picker resolution should begin"); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + + assert!(!streams.finalize_picker_failure( + &stale, + MacosCaptureError::CaptureWorkerStartFailed("stale picker failure".to_owned()), + )); + assert_eq!(streams.shared.picker_resolution(), Some(fresh)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + assert_latest_lifecycle(&streams, MacosFrameStatus::Idle); + } + + #[test] + fn stale_filter_error_cannot_overwrite_successor_starting() { + let streams = stream_slot_fixture(0, 0); + super::lock(&streams.state).selected_filter = None; + let stale = streams + .begin_picker_resolution() + .expect("picker filter resolution should begin"); + let fresh = streams + .begin_picker_resolution() + .expect("successor filter resolution should begin"); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started)); + + assert!(!streams.finalize_resolution_error( + &stale, + true, + MacosCaptureError::RetainNativeFilterFailed, + )); + assert_eq!(streams.shared.picker_resolution(), Some(fresh)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Starting); + assert_latest_lifecycle(&streams, MacosFrameStatus::Started); + } + + #[test] + fn stale_enumeration_error_cannot_overwrite_successor_live() { + let streams = stream_slot_fixture(41, 9); + let stale = streams + .begin_resolution() + .expect("first enumeration should begin"); + let fresh = streams + .begin_resolution() + .expect("successor enumeration should begin"); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Idle)); + + assert!(!streams.finalize_resolution_error( + &stale, + false, + MacosCaptureError::MissingShareableContent, + )); + assert!(streams.shared.source_resolution_is_current(&fresh)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + assert_latest_lifecycle(&streams, MacosFrameStatus::Idle); + } + + #[test] + fn diagnostic_selector_remains_primary_across_concurrent_set_selector() { + let streams = stream_slot_fixture(0, 7); + let (diagnostic, completion) = streams + .begin_restart_diagnostic(true, 7) + .expect("diagnostic resolution should begin"); + let resolution = SourceResolution::Diagnostic(diagnostic.clone()); + assert_eq!( + resolution.selector(), + &super::MacosCaptureSelector::PrimaryDisplay + ); + + let lifecycle = super::lock(&streams.lifecycle_start); + let (started_tx, started_rx) = mpsc::channel(); + let mutating_streams = Arc::clone(&streams); + let mutation = thread::spawn(move || { + started_tx + .send(()) + .expect("selector mutation should be observable"); + mutating_streams + .set_selector_and_begin_resolution(super::MacosCaptureSelector::Auto) + .expect("selector mutation should begin its own resolution") + }); + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("selector mutation should reach the lifecycle gate"); + assert_eq!( + streams.shared.selector(), + super::MacosCaptureSelector::PrimaryDisplay + ); + drop(lifecycle); + let successor = mutation.join().expect("selector mutation should join"); + + assert_eq!(streams.shared.selector(), super::MacosCaptureSelector::Auto); + assert_eq!(successor.selector(), &super::MacosCaptureSelector::Auto); + assert_eq!( + resolution.selector(), + &super::MacosCaptureSelector::PrimaryDisplay + ); + assert_eq!(completion.recv(), Ok(MacosProtectedSourceState::Failed)); + } + + #[test] + fn diagnostic_setup_fences_old_filter_acceptance_and_new_picker_resolution() { + let streams = stream_slot_fixture(41, 9); + let stale_resolution = streams + .begin_picker_resolution() + .expect("old picker resolution should begin"); + let (setup_tx, setup_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let setup_streams = Arc::clone(&streams); + let setup = thread::spawn(move || { + setup_streams.setup_restart_diagnostic_with(true, || { + setup_tx + .send(()) + .expect("installed diagnostic setup should be observable"); + release_rx.recv().expect("diagnostic setup should resume"); + }) + }); + setup_rx + .recv_timeout(Duration::from_secs(1)) + .expect("diagnostic should pause while holding the lifecycle gate"); + + assert_eq!(streams.shared.picker_resolution(), None); + assert_eq!( + streams.shared.selector(), + super::MacosCaptureSelector::PrimaryDisplay + ); + assert!(streams.shared.capture_active()); + assert_eq!( + streams.shared.selection(), + super::MacosCaptureSelection::None + ); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Starting); + + let (filter_started_tx, filter_started_rx) = mpsc::channel(); + let (filter_done_tx, filter_done_rx) = mpsc::channel(); + let filter_streams = Arc::clone(&streams); + let stale_filter_resolution = stale_resolution.clone(); + let stale_filter = thread::spawn(move || { + filter_started_tx + .send(()) + .expect("old filter acceptance should be observable"); + let result = filter_streams.accept_selection_filter( + NativeSelectionFilter::fixture(2), + MacosStreamRequest::default(), + 42, + stale_filter_resolution, + true, + ); + filter_done_tx + .send(result) + .expect("old filter result should be observable"); + }); + let (picker_started_tx, picker_started_rx) = mpsc::channel(); + let (picker_done_tx, picker_done_rx) = mpsc::channel(); + let picker_streams = Arc::clone(&streams); + let new_picker = thread::spawn(move || { + picker_started_tx + .send(()) + .expect("new picker resolution should be observable"); + let resolution = picker_streams.begin_picker_resolution(); + picker_done_tx + .send(resolution) + .expect("new picker result should be observable"); + }); + filter_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old filter should reach the lifecycle gate"); + picker_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("new picker should reach the lifecycle gate"); + assert!(matches!( + filter_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + assert_eq!( + picker_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + + release_tx + .send(()) + .expect("diagnostic setup should release the lifecycle gate"); + let (diagnostic, completion) = setup + .join() + .expect("diagnostic setup thread should join") + .expect("diagnostic setup should succeed"); + assert!(matches!( + filter_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("old filter should finish after diagnostic setup"), + Ok(super::FilterAcceptance::Stale) + )); + let picker_resolution = picker_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("new picker should finish after diagnostic setup") + .expect("new picker resolution should succeed"); + stale_filter.join().expect("old filter thread should join"); + new_picker.join().expect("new picker thread should join"); + + assert!(!streams.shared.diagnostic_resolution_is_current(&diagnostic)); + assert_eq!( + streams.shared.picker_resolution(), + Some(picker_resolution.clone()) + ); + assert!( + streams + .shared + .source_resolution_is_current(&picker_resolution) + ); + assert_eq!(completion.recv(), Ok(MacosProtectedSourceState::Failed)); + assert_eq!(selection_filter_ids(&streams), (None, None)); + } + + #[test] + fn inactive_filter_acceptance_precedes_crossing_activation_atomically() { + let streams = stream_slot_fixture(41, 9); + assert!(streams.set_capture_active(false)); + streams.next_epoch.store(43, Ordering::Release); + let resolution = streams + .begin_resolution() + .expect("filter resolution should begin"); + let (accepted_tx, accepted_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let accepting_streams = Arc::clone(&streams); + let accepting = thread::spawn(move || { + accepting_streams.accept_selection_filter_with( + NativeSelectionFilter::fixture(2), + MacosStreamRequest::default(), + 42, + resolution, + false, + || { + accepted_tx + .send(()) + .expect("filter acceptance should be observable"); + release_rx.recv().expect("filter acceptance should resume"); + }, + ) + }); + accepted_rx + .recv_timeout(Duration::from_secs(1)) + .expect("filter acceptance should hold the lifecycle gate"); + + let (activation_tx, activation_rx) = mpsc::channel(); + let activation_streams = Arc::clone(&streams); + let activation = thread::spawn(move || { + activation_tx + .send(activation_streams.begin_capture_activation()) + .expect("activation result should be observable"); + }); + assert!(matches!( + activation_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + + release_tx + .send(()) + .expect("filter acceptance should be released"); + assert!(matches!( + accepting.join().expect("acceptance thread should join"), + Ok(super::FilterAcceptance::Stored(None)) + )); + let activation_result = activation_rx + .recv_timeout(Duration::from_secs(1)) + .expect("activation should finish after filter acceptance") + .expect("activation should reserve the accepted filter"); + activation.join().expect("activation thread should join"); + let super::CaptureActivation::Candidate { reservation, .. } = activation_result else { + panic!("activation should stage the accepted filter"); + }; + let CandidateReservation { + stage, + selection_filter, + replaced_settlement, + .. + } = *reservation; + StreamSlot::finish_replaced_candidate(replaced_settlement); + assert_eq!(selection_filter.fixture_id(), 2); + assert!(streams.start_candidate_fixture(stage)); + assert!(streams.activate_candidate_fixture(stage.epoch)); + assert_eq!(selection_filter_ids(&streams), (Some(2), None)); + } + + #[test] + fn active_filter_acceptance_precedes_crossing_deactivation_atomically() { + let streams = stream_slot_fixture(41, 9); + let resolution = streams + .begin_resolution() + .expect("filter resolution should begin"); + let (accepted_tx, accepted_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let accepting_streams = Arc::clone(&streams); + let accepting = thread::spawn(move || { + accepting_streams.accept_selection_filter_with( + NativeSelectionFilter::fixture(2), + MacosStreamRequest::default(), + 42, + resolution, + false, + || { + accepted_tx + .send(()) + .expect("filter acceptance should be observable"); + release_rx.recv().expect("filter acceptance should resume"); + }, + ) + }); + accepted_rx + .recv_timeout(Duration::from_secs(1)) + .expect("filter acceptance should hold the lifecycle gate"); + + let (deactivation_tx, deactivation_rx) = mpsc::channel(); + let deactivation_streams = Arc::clone(&streams); + let deactivation = thread::spawn(move || { + deactivation_tx + .send(deactivation_streams.set_capture_active(false)) + .expect("deactivation result should be observable"); + }); + assert_eq!( + deactivation_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + + release_tx + .send(()) + .expect("filter acceptance should be released"); + let acceptance = accepting + .join() + .expect("acceptance thread should join") + .expect("active acceptance should reserve a candidate"); + assert!( + deactivation_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should finish after filter acceptance") + ); + deactivation + .join() + .expect("deactivation thread should join"); + let super::FilterAcceptance::Candidate { reservation, .. } = acceptance else { + panic!("active acceptance should stage the delivered filter"); + }; + let CandidateReservation { + stage, + replaced_settlement, + .. + } = *reservation; + StreamSlot::finish_replaced_candidate(replaced_settlement); + assert!(!streams.start_candidate_fixture(stage)); + assert_eq!(selection_filter_ids(&streams), (Some(2), None)); + assert!(!streams.shared.capture_active()); + } + + #[test] + fn candidate_activation_requires_its_pending_selection_revision() { + let streams = stream_slot_fixture(41, 9); + let (stage, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 2) + .expect("candidate reservation succeeds") + .expect("active capture admits a candidate"); + assert!(streams.start_candidate_fixture(stage)); + super::lock(&streams.state).selection_revision += 1; + + assert!(!streams.activate_candidate_fixture(42)); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((42, 2)))); + assert_eq!(streams.shared.current_epoch(), 41); + } + + #[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 streams = stream_slot_fixture(41, 9); + let (stage, replaced) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("production slot reserves a candidate") + .expect("active demand admits a candidate"); + assert!(replaced.is_none()); + + assert!(streams.set_capture_active(false)); + + assert!(!streams.start_candidate_fixture(stage)); + assert_eq!(super::lock(&streams.state).candidate_epoch, None); + } + + #[test] + fn stream_slot_start_fixture_rejects_a_repick_before_the_old_start_runs() { + let streams = stream_slot_fixture(41, 9); + let (stale, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("first candidate reserves") + .expect("first candidate stages"); + let (current, _) = + reserve_selection_candidate_fixture(&streams, 43, MacosStreamRequest::default(), 43) + .expect("replacement candidate reserves") + .expect("replacement candidate stages"); + + assert!(!streams.start_candidate_fixture(stale)); + assert!(streams.start_candidate_fixture(current)); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(43)); + } + + #[test] + fn candidate_start_gate_blocks_deactivation_until_native_start_is_invoked() { + let streams = stream_slot_fixture(41, 9); + let (stage, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("candidate reservation succeeds") + .expect("active capture admits a candidate"); + let invoked = Arc::new(AtomicBool::new(false)); + let (installed_tx, installed_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let starter_streams = Arc::clone(&streams); + let starter_invoked = Arc::clone(&invoked); + let starter = thread::spawn(move || { + starter_streams.start_candidate_fixture_with(stage, move || { + installed_tx + .send(()) + .expect("installed candidate should be observable"); + release_rx + .recv() + .expect("native start invocation should be released"); + starter_invoked.store(true, Ordering::Release); + }) + }); + installed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("candidate should install before the injected invocation pauses"); + + let (deactivate_started_tx, deactivate_started_rx) = mpsc::channel(); + let (deactivate_done_tx, deactivate_done_rx) = mpsc::channel(); + let deactivate_streams = Arc::clone(&streams); + let deactivate_invoked = Arc::clone(&invoked); + let deactivate = thread::spawn(move || { + deactivate_started_tx + .send(()) + .expect("deactivation attempt should be observable"); + let changed = deactivate_streams.set_capture_active(false); + deactivate_done_tx + .send((changed, deactivate_invoked.load(Ordering::Acquire))) + .expect("deactivation result should be observable"); + }); + deactivate_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should reach the lifecycle gate"); + assert_eq!( + deactivate_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(42)); + + release_tx + .send(()) + .expect("native start invocation should resume"); + assert!(starter.join().expect("starter thread should join")); + assert_eq!( + deactivate_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("deactivation should finish after invocation"), + (true, true) + ); + deactivate.join().expect("deactivation thread should join"); + assert_eq!(super::lock(&streams.state).candidate_epoch, None); + } + + #[test] + fn candidate_start_gate_blocks_repick_until_native_start_is_invoked() { + let streams = stream_slot_fixture(41, 9); + let (stage, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("candidate reservation succeeds") + .expect("active capture admits a candidate"); + let invoked = Arc::new(AtomicBool::new(false)); + let (installed_tx, installed_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let starter_streams = Arc::clone(&streams); + let starter_invoked = Arc::clone(&invoked); + let starter = thread::spawn(move || { + starter_streams.start_candidate_fixture_with(stage, move || { + installed_tx + .send(()) + .expect("installed candidate should be observable"); + release_rx + .recv() + .expect("native start invocation should be released"); + starter_invoked.store(true, Ordering::Release); + }) + }); + installed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("candidate should install before the injected invocation pauses"); + + let (repick_started_tx, repick_started_rx) = mpsc::channel(); + let (repick_done_tx, repick_done_rx) = mpsc::channel(); + let repick_streams = Arc::clone(&streams); + let repick_invoked = Arc::clone(&invoked); + let repick = thread::spawn(move || { + repick_started_tx + .send(()) + .expect("repick attempt should be observable"); + let (replacement, retired) = reserve_selection_candidate_fixture( + &repick_streams, + 43, + MacosStreamRequest::default(), + 43, + ) + .expect("repick reservation succeeds") + .expect("active capture admits the repick"); + assert!(retired.is_none()); + repick_done_tx + .send((replacement.epoch, repick_invoked.load(Ordering::Acquire))) + .expect("repick result should be observable"); + }); + repick_started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("repick should reach the lifecycle gate"); + assert_eq!( + repick_done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(42)); + + release_tx + .send(()) + .expect("native start invocation should resume"); + assert!(starter.join().expect("starter thread should join")); + assert_eq!( + repick_done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("repick should finish after invocation"), + (43, true) + ); + repick.join().expect("repick thread should join"); + assert_eq!(super::lock(&streams.state).staging_epoch, Some(43)); + } + + #[test] + fn stale_async_start_failure_cannot_retire_the_successor_candidate() { + let streams = stream_slot_fixture(41, 9); + let (diagnostic, diagnostic_completion) = streams + .shared + .begin_restart_diagnostic(true, 9) + .expect("diagnostic attempt begins"); + streams.shared.record_filter_enumerated(&diagnostic, 42); + let (callback_blocked_tx, callback_blocked_rx) = mpsc::channel(); + let (release_callback_tx, release_callback_rx) = mpsc::channel(); + streams.lifecycle_callbacks.exec_async(move || { + callback_blocked_tx + .send(()) + .expect("blocked lifecycle callback should be observable"); + release_callback_rx + .recv() + .expect("lifecycle callback should be released"); + }); + callback_blocked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("lifecycle callback queue should pause"); + let (stale, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("stale candidate reservation succeeds") + .expect("active capture admits the stale candidate"); + let failure_streams = Arc::clone(&streams); + let failure_shared = Arc::clone(&streams.shared); + assert!(streams.start_candidate_fixture_with(stale, move || { + super::dispatch_owned_stream_error( + failure_streams, + 42, + failure_shared, + MacosProtectedSourceState::PermissionDenied, + MacosCaptureError::CaptureWorkerStartFailed( + "stale injected start failure".to_owned(), + ), + ); + })); + assert!(streams.set_capture_active(false)); + assert!(streams.set_capture_active(true)); + + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("successor request is valid"); + let (pending, completion) = pending_request(43, next); + let (successor, _) = reserve_request_candidate_fixture(&streams, 43, next, pending) + .expect("successor reservation succeeds") + .expect("reactivated capture admits the successor"); + assert!(streams.start_candidate_fixture(successor)); + + release_callback_tx + .send(()) + .expect("stale start completion should resume"); + streams.drain_lifecycle_callbacks(); + super::dispatch_stream_start_success(&Arc::downgrade(&streams), 42); + streams.drain_lifecycle_callbacks(); + + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(43)); + assert_eq!(streams.request(), next); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert_eq!( + diagnostic_completion.try_recv(), + Ok(MacosProtectedSourceState::Failed) + ); + assert!(streams.activate_candidate_fixture(43)); + assert_eq!(completion.recv(), Ok(Ok(()))); + streams + .shared + .fail_restart_diagnostic_attempt(diagnostic.attempt); + assert_eq!( + diagnostic_completion.try_recv(), + Ok(MacosProtectedSourceState::Failed) + ); + } + + #[test] + fn deactivation_retires_diagnostic_before_queued_candidate_completion() { + let streams = stream_slot_fixture(41, 9); + let (diagnostic, diagnostic_completion) = streams + .shared + .begin_restart_diagnostic(true, 9) + .expect("diagnostic attempt should begin"); + streams.shared.record_filter_enumerated(&diagnostic, 42); + + let (blocked_tx, blocked_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + streams.lifecycle_callbacks.exec_async(move || { + blocked_tx + .send(()) + .expect("queued completion pause should be observable"); + release_rx.recv().expect("queued completion should resume"); + }); + blocked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("lifecycle queue should pause before candidate completion"); + + let (candidate, _) = + reserve_selection_candidate_fixture(&streams, 42, MacosStreamRequest::default(), 42) + .expect("diagnostic candidate should reserve") + .expect("active capture should admit the diagnostic candidate"); + let callback_streams = Arc::clone(&streams); + let callback_shared = Arc::clone(&streams.shared); + assert!(streams.start_candidate_fixture_with(candidate, move || { + super::dispatch_owned_stream_error( + callback_streams, + 42, + callback_shared, + MacosProtectedSourceState::PermissionDenied, + MacosCaptureError::CaptureWorkerStartFailed( + "queued diagnostic candidate completion".to_owned(), + ), + ); + })); + + assert!(streams.set_capture_active(false)); + assert_eq!( + diagnostic_completion + .recv() + .expect("deactivation should terminally complete the diagnostic"), + MacosProtectedSourceState::Failed + ); + streams + .shared + .publish(MacosFrameEvent::Lifecycle(MacosFrameStatus::Stopped)); + + release_tx + .send(()) + .expect("stale candidate completion should resume"); + streams.drain_lifecycle_callbacks(); + super::dispatch_stream_start_success(&Arc::downgrade(&streams), 42); + streams.drain_lifecycle_callbacks(); + + assert!(!streams.shared.capture_active()); + assert_eq!(streams.shared.current_epoch(), 0); + assert_eq!(super::lock(&streams.state).candidate_epoch, None); + assert!(matches!( + streams.shared.mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Stopped))) + )); + } + + fn assert_failure_before_activation_rejects_the_candidate( + dispatch_failure: impl FnOnce(&Arc, Arc, MacosCaptureError), + ) { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("candidate request is valid"); + let streams = stream_slot_fixture(41, 9); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("candidate reservation succeeds") + .expect("active capture admits the candidate"); + assert!(streams.start_candidate_fixture(stage)); + + let (callback_blocked_tx, callback_blocked_rx) = mpsc::channel(); + let (release_callback_tx, release_callback_rx) = mpsc::channel(); + streams.lifecycle_callbacks.exec_async(move || { + callback_blocked_tx + .send(()) + .expect("blocked lifecycle callback should be observable"); + release_callback_rx + .recv() + .expect("lifecycle callback should be released"); + }); + callback_blocked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("lifecycle callback queue should pause"); + + dispatch_failure( + &streams, + Arc::clone(&streams.shared), + MacosCaptureError::CaptureWorkerStartFailed( + "injected candidate failure before activation".to_owned(), + ), + ); + + assert!(!streams.accepts_epoch(42)); + assert!(!streams.activate_candidate_fixture(42)); + assert_eq!(streams.committed_request(), original); + assert_eq!(streams.shared.current_epoch(), 41); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(42)); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + + release_callback_tx + .send(()) + .expect("queued teardown should resume"); + streams.drain_lifecycle_callbacks(); + + assert_eq!(super::lock(&streams.state).candidate_epoch, None); + assert_eq!(streams.committed_request(), original); + assert_eq!(streams.shared.current_epoch(), 41); + assert!(matches!(completion.recv(), Ok(Err(_)))); + } + + #[test] + fn start_failure_before_activation_rejects_the_exact_candidate_synchronously() { + assert_failure_before_activation_rejects_the_candidate(|streams, shared, error| { + super::dispatch_owned_stream_error( + Arc::clone(streams), + 42, + shared, + MacosProtectedSourceState::PermissionDenied, + error, + ); + }); + } + + #[test] + fn fatal_failure_before_activation_rejects_the_exact_candidate_synchronously() { + assert_failure_before_activation_rejects_the_candidate(|streams, shared, error| { + super::handle_fatal_stream_error(&Arc::downgrade(streams), 42, shared, error); + }); + } + + #[test] + fn stream_slot_start_fixture_never_regresses_a_newer_live_session_to_interrupted() { + let streams = stream_slot_fixture(0, 9); + let recovery = InterruptedRestage::interrupted(41, 9); + + assert!(recovery.can_begin(&super::lock(&streams.state), &streams.shared)); + streams.shared.activate_epoch(43); + + assert!(!recovery.can_begin(&super::lock(&streams.state), &streams.shared)); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Live); + } + + #[test] + fn pending_selection_request_after_repick_avoids_the_current_filter() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("request is valid"); + let streams = stream_slot_fixture(41, 9); + let (repick, _) = reserve_selection_candidate_fixture(&streams, 42, original, 2) + .expect("repick reserves") + .expect("active capture stages the repick"); + assert!(streams.start_candidate_fixture(repick)); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((42, 2)))); + assert_eq!(streams.shared.current_epoch(), 41); + + streams.next_epoch.store(43, Ordering::Release); + let (transaction, replaced) = streams + .begin_request_candidate_fixture(next) + .expect("request restages the repick selection"); + assert!(replaced.is_none()); + assert_eq!(transaction.generation(), 43); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((43, 2)))); + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(!streams.activate_candidate_fixture(42)); + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert_eq!(streams.shared.current_epoch(), 41); + + assert!(streams.activate_candidate_fixture(43)); + assert_eq!(transaction.recv(), Ok(Ok(()))); + assert_eq!(selection_filter_ids(&streams), (Some(2), None)); + assert_eq!(streams.committed_request(), next); + assert_eq!(streams.shared.current_epoch(), 43); + } + + #[test] + fn pending_selection_request_after_first_candidate_keeps_the_only_filter() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("request is valid"); + let streams = stream_slot_fixture(0, 3); + super::lock(&streams.state).selected_filter = None; + let (first, _) = reserve_selection_candidate_fixture(&streams, 42, original, 7) + .expect("first selection reserves") + .expect("active capture stages the first selection"); + assert!(streams.start_candidate_fixture(first)); + assert_eq!(selection_filter_ids(&streams), (None, Some((42, 7)))); + + streams.next_epoch.store(43, Ordering::Release); + let (transaction, replaced) = streams + .begin_request_candidate_fixture(next) + .expect("request restages the only selection"); + assert!(replaced.is_none()); + assert_eq!(selection_filter_ids(&streams), (None, Some((43, 7)))); + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(!streams.activate_candidate_fixture(42)); + + assert!(streams.activate_candidate_fixture(43)); + assert_eq!(transaction.recv(), Ok(Ok(()))); + assert_eq!(selection_filter_ids(&streams), (Some(7), None)); + assert_eq!(streams.committed_request(), next); + assert_eq!(streams.shared.current_epoch(), 43); + } + + #[test] + fn pending_selection_request_fences_async_preinstall_ordering() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("request is valid"); + let streams = stream_slot_fixture(41, 9); + let (uninstalled, _) = reserve_selection_candidate_fixture(&streams, 42, original, 8) + .expect("async selection reserves") + .expect("active capture stages the async selection"); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((42, 8)))); + + streams.next_epoch.store(43, Ordering::Release); + let (transaction, replaced) = streams + .begin_request_candidate_fixture(next) + .expect("request supersedes the pre-install stage"); + assert!(replaced.is_none()); + assert_eq!(selection_filter_ids(&streams), (Some(1), Some((43, 8)))); + assert!(!streams.start_candidate_fixture(uninstalled)); + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + + assert!(streams.activate_candidate_fixture(43)); + assert_eq!(transaction.recv(), Ok(Ok(()))); + assert_eq!(selection_filter_ids(&streams), (Some(8), None)); + assert_eq!(streams.committed_request(), next); + } + + #[test] + fn stream_slot_request_restage_commits_only_at_candidate_activation() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(9, next); + + let (stage, replaced) = reserve_request_candidate_fixture(&streams, 9, next, pending) + .expect("request restage should reserve") + .expect("active request should stage a candidate"); + assert!(replaced.is_none()); + assert_eq!(streams.request(), next); + assert_eq!(super::lock(&streams.state).request, original); + assert_eq!( + completion.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ); + + assert!(streams.start_candidate_fixture(stage)); + assert_eq!( + completion.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ); + assert!(streams.activate_candidate_fixture(stage.epoch)); + assert_eq!(completion.recv(), Ok(Ok(()))); + + let state = super::lock(&streams.state); + assert_eq!(state.request, next); + assert!(state.pending_request.is_none()); + assert_eq!(state.candidate_epoch, None); + } + + #[test] + fn picker_replacement_retargets_the_pending_request_transaction() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("pending request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (request_stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request candidate reserves") + .expect("active capture stages the request candidate"); + assert!(streams.start_candidate_fixture(request_stage)); + + let (picker_stage, replaced) = reserve_selection_candidate_fixture(&streams, 43, next, 43) + .expect("picker replacement reserves with the authoritative request") + .expect("active capture stages the picker replacement"); + assert!(replaced.is_none()); + assert_eq!(picker_stage.request.map(|request| request.epoch), Some(43)); + assert_eq!(streams.request(), next); + assert_eq!(streams.committed_request(), original); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + + assert!(!streams.fail_candidate_fixture( + 42, + MacosCaptureError::CaptureWorkerStartFailed( + "stale replaced candidate failed".to_owned(), + ) + )); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(streams.start_candidate_fixture(picker_stage)); + assert!(streams.activate_candidate_fixture(43)); + assert_eq!(completion.recv(), Ok(Ok(()))); + assert_eq!(streams.committed_request(), next); + } + + #[test] + fn stale_resolution_snapshot_cannot_displace_the_pending_request_transaction() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("pending request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (request_stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request candidate reserves") + .expect("active capture stages the request candidate"); + assert!(streams.start_candidate_fixture(request_stage)); + let selection_revision = streams.selection_revision(); + + let error = match reserve_selection_candidate_fixture(&streams, 43, original, 43) { + Ok(_) => panic!("stale resolution snapshot must be rejected"), + Err(error) => error, + }; + assert!(error.to_string().contains("authoritative stream request")); + assert_eq!(streams.selection_revision(), selection_revision); + assert_eq!(super::lock(&streams.state).candidate_epoch, Some(42)); + assert_eq!(streams.request(), next); + assert_eq!(streams.committed_request(), original); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + + let (retry, _) = reserve_selection_candidate_fixture(&streams, 44, next, 44) + .expect("resolution retries with the authoritative pending request") + .expect("retry stages a replacement candidate"); + assert!(!streams.fail_candidate_fixture( + 42, + MacosCaptureError::CaptureWorkerStartFailed( + "stale request candidate failed".to_owned(), + ) + )); + assert_eq!(completion.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(streams.start_candidate_fixture(retry)); + assert!(streams.activate_candidate_fixture(44)); + assert_eq!(completion.recv(), Ok(Ok(()))); + assert_eq!(streams.committed_request(), next); + } + + #[test] + fn stale_resolution_snapshot_after_request_commit_cannot_replace_the_committed_request() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("pending request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (request_stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request candidate reserves") + .expect("active capture stages the request candidate"); + assert!(streams.start_candidate_fixture(request_stage)); + assert!(streams.activate_candidate_fixture(42)); + assert_eq!(completion.recv(), Ok(Ok(()))); + + let selection_revision = streams.selection_revision(); + let current_epoch = streams.shared.current_epoch(); + let error = match reserve_selection_candidate_fixture(&streams, 43, original, 43) { + Ok(_) => panic!("post-commit stale resolution snapshot must be rejected"), + Err(error) => error, + }; + assert!(error.to_string().contains("authoritative stream request")); + assert_eq!(streams.selection_revision(), selection_revision); + assert_eq!(streams.shared.current_epoch(), current_epoch); + { + let state = super::lock(&streams.state); + assert_eq!(state.request, next); + assert!(state.pending_request.is_none()); + assert_eq!(state.staging_epoch, None); + assert_eq!(state.candidate_epoch, None); + } + + let (retry, replaced) = reserve_selection_candidate_fixture(&streams, 44, next, 44) + .expect("resolution retries with the committed request") + .expect("retry stages a replacement candidate"); + assert!(replaced.is_none()); + assert!(streams.start_candidate_fixture(retry)); + assert!(streams.activate_candidate_fixture(44)); + assert_eq!(streams.committed_request(), next); + assert_eq!(streams.shared.current_epoch(), 44); + } + + #[test] + fn stale_resolution_snapshot_after_request_rollback_cannot_replace_the_committed_request() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("pending request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(42, next); + let (request_stage, _) = reserve_request_candidate_fixture(&streams, 42, next, pending) + .expect("request candidate reserves") + .expect("active capture stages the request candidate"); + assert!(streams.start_candidate_fixture(request_stage)); + let failure = + MacosCaptureError::CaptureWorkerStartFailed("fixture request failure".to_owned()); + assert!(streams.fail_candidate_fixture(42, failure.clone())); + assert_eq!(completion.recv(), Ok(Err(failure))); + + let selection_revision = streams.selection_revision(); + let current_epoch = streams.shared.current_epoch(); + let error = match reserve_selection_candidate_fixture(&streams, 43, next, 43) { + Ok(_) => panic!("post-rollback stale resolution snapshot must be rejected"), + Err(error) => error, + }; + assert!(error.to_string().contains("authoritative stream request")); + assert_eq!(streams.selection_revision(), selection_revision); + assert_eq!(streams.shared.current_epoch(), current_epoch); + { + let state = super::lock(&streams.state); + assert_eq!(state.request, original); + assert!(state.pending_request.is_none()); + assert_eq!(state.staging_epoch, None); + assert_eq!(state.candidate_epoch, None); + } + + let (retry, replaced) = reserve_selection_candidate_fixture(&streams, 44, original, 44) + .expect("resolution retries with the rolled-back committed request") + .expect("retry stages a replacement candidate"); + assert!(replaced.is_none()); + assert!(streams.start_candidate_fixture(retry)); + assert!(streams.activate_candidate_fixture(44)); + assert_eq!(streams.committed_request(), original); + assert_eq!(streams.shared.current_epoch(), 44); + } + + #[test] + fn stream_slot_request_restage_failure_rolls_back_pending_request() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new_hdr(MacosCaptureCadence::NativeRefresh, true) + .expect("fixture HDR request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(12, next); + + let (stage, replaced) = reserve_request_candidate_fixture(&streams, 12, next, pending) + .expect("request restage should reserve") + .expect("active request should stage a candidate"); + assert!(replaced.is_none()); + assert!(streams.start_candidate_fixture(stage)); + let error = MacosCaptureError::CaptureWorkerStartFailed("fixture async failure".to_owned()); + assert!(streams.fail_candidate_fixture(stage.epoch, error.clone())); + assert_eq!(completion.recv(), Ok(Err(error))); + + let state = super::lock(&streams.state); + assert_eq!(state.request, original); + assert!(state.pending_request.is_none()); + assert_eq!(state.staging_epoch, None); + assert_eq!(state.candidate_epoch, None); + } + + #[test] + fn missing_start_completion_times_out_without_retiring_the_current_stream() { + let original = MacosStreamRequest::default(); + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + streams.next_epoch.store(12, Ordering::Release); + let (transaction, _) = streams + .begin_request_candidate_fixture(next) + .expect("candidate transaction starts"); + let deadline = transaction + .current_deadline() + .expect("start transaction has a deadline"); + + streams + .native_lifecycle + .deadlines() + .expire_through(deadline); + + assert_eq!( + transaction.wait(), + Err(MacosNativeTransactionError::TimedOut { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 12, + }) + ); + let state = super::lock(&streams.state); + assert_eq!(StreamSlot::current_epoch(&state), Some(7)); + assert_eq!(state.candidate_epoch, None); + assert_eq!(state.request, original); + } + + #[test] + fn missing_source_callback_times_out_and_fences_the_exact_resolution() { + let streams = stream_slot_fixture(7, 3); + let resolution = streams + .begin_resolution() + .expect("general source resolution starts"); + let completion = super::lock(&streams.source_transaction) + .as_ref() + .expect("source transaction is installed") + .completion + .clone(); + let deadline = completion + .current_deadline() + .expect("general source resolution is bounded"); + + streams + .native_lifecycle + .deadlines() + .expire_through(deadline); + + assert!(!completion.is_open()); + assert!(!streams.shared.source_resolution_is_current(&resolution)); + assert!(super::lock(&streams.source_transaction).is_none()); + assert_eq!(streams.shared.current_epoch(), 7); + } + + #[test] + fn picker_selection_has_cancellation_without_a_wall_clock_deadline() { + let streams = stream_slot_fixture(7, 3); + let resolution = streams + .begin_picker_resolution() + .expect("picker resolution starts"); + let completion = super::lock(&streams.source_transaction) + .as_ref() + .expect("picker transaction is installed") + .completion + .clone(); + + assert_eq!(completion.current_deadline(), None); + assert!(completion.is_open()); + let settlement = streams.cancel_source_transaction(&resolution); + settlement + .expect("picker cancellation claims the source transaction") + .publish(); + + assert!(!completion.is_open()); + assert!(super::lock(&streams.source_transaction).is_none()); + } + + #[test] + fn source_success_remains_unpublished_until_resolution_commit() { + let streams = stream_slot_fixture(7, 3); + let resolution = streams + .begin_picker_resolution() + .expect("picker resolution starts"); + let completion = super::lock(&streams.source_transaction) + .as_ref() + .expect("source transaction is installed") + .completion + .clone(); + + let settlement = streams + .claim_source_transaction(&resolution) + .expect("source callback claims success"); + assert_eq!(completion.outcome(), None); + assert!(super::lock(&streams.source_transaction).is_none()); + + streams + .shared + .set_status(MacosProtectedSourceState::ReadyIdle); + assert_eq!(completion.outcome(), None); + settlement.publish(); + + assert_eq!(completion.outcome(), Some(Ok(()))); + assert_eq!( + streams.shared.status(), + MacosProtectedSourceState::ReadyIdle + ); + } + + #[test] + fn missing_first_complete_frame_rearms_and_times_out_the_candidate() { + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + streams.next_epoch.store(12, Ordering::Release); + let (transaction, _) = streams + .begin_request_candidate_fixture(next) + .expect("candidate transaction starts"); + + super::dispatch_stream_start_success(&Arc::downgrade(&streams), 12); + streams.drain_lifecycle_callbacks(); + let deadline = transaction + .current_deadline() + .expect("first frame transaction has a deadline"); + streams + .native_lifecycle + .deadlines() + .expire_through(deadline); + + assert_eq!( + transaction.wait(), + Err(MacosNativeTransactionError::TimedOut { + phase: MacosNativeTransactionPhase::FirstCompleteFrame, + generation: 12, + }) + ); + assert_eq!(streams.shared.current_epoch(), 7); + assert_eq!(super::lock(&streams.state).candidate_epoch, None); + } + + #[test] + fn observed_start_callback_retires_start_deadline_before_lifecycle_queue_delivery() { + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + streams.next_epoch.store(12, Ordering::Release); + let (transaction, _) = streams + .begin_request_candidate_fixture(next) + .expect("candidate transaction starts"); + let stale_start_deadline = transaction + .current_deadline() + .expect("start transaction has a deadline"); + let (blocked_tx, blocked_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + streams.lifecycle_callbacks.exec_async(move || { + blocked_tx + .send(()) + .expect("lifecycle queue block is observable"); + release_rx + .recv() + .expect("lifecycle queue block is released"); + }); + blocked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("lifecycle queue is blocked"); + + super::dispatch_stream_start_success(&Arc::downgrade(&streams), 12); + streams + .native_lifecycle + .deadlines() + .expire_through(stale_start_deadline); + + assert_eq!(transaction.try_recv(), Err(mpsc::TryRecvError::Empty)); + release_tx.send(()).expect("lifecycle queue should resume"); + streams.drain_lifecycle_callbacks(); + assert!(streams.activate_candidate_fixture(12)); + assert_eq!(transaction.wait(), Ok(())); + } + + #[test] + fn first_frame_and_timeout_commit_exactly_one_candidate_result() { + let next = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let winner = stream_slot_fixture(7, 3); + winner.next_epoch.store(12, Ordering::Release); + let (committed, _) = winner + .begin_request_candidate_fixture(next) + .expect("winning candidate starts"); + let stale_deadline = committed + .current_deadline() + .expect("winning candidate has a deadline"); + assert!(winner.activate_candidate_fixture(12)); + winner + .native_lifecycle + .deadlines() + .expire_through(stale_deadline); + assert_eq!(committed.wait(), Ok(())); + assert_eq!(winner.shared.current_epoch(), 12); + + let timed_out = stream_slot_fixture(7, 3); + timed_out.next_epoch.store(12, Ordering::Release); + let (rejected, _) = timed_out + .begin_request_candidate_fixture(next) + .expect("losing candidate starts"); + let deadline = rejected + .current_deadline() + .expect("losing candidate has a deadline"); + timed_out + .native_lifecycle + .deadlines() + .expire_through(deadline); + assert!(!timed_out.activate_candidate_fixture(12)); + assert!(matches!( + rejected.wait(), + Err(MacosNativeTransactionError::TimedOut { .. }) + )); + assert_eq!(timed_out.shared.current_epoch(), 7); + } + + #[test] + fn claimed_cancellation_retires_only_the_candidate_before_publishing() { + let request = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + let (transaction, _) = streams + .begin_request_candidate_fixture(request) + .expect("request candidate starts"); + let epoch = transaction.generation(); + let completion = super::lock(&streams.state) + .candidate_completion + .as_ref() + .cloned() + .expect("candidate completion is installed"); + let cancel_selected = Arc::new(std::sync::Barrier::new(2)); + let selected = Arc::clone(&cancel_selected); + let resume_cancel = Arc::new(std::sync::Barrier::new(2)); + let resume = Arc::clone(&resume_cancel); + let cancel_streams = Arc::clone(&streams); + completion.set_cancel(move |generation| { + selected.wait(); + resume.wait(); + cancel_streams.cancel_candidate_transaction(generation); + }); + let cancel = thread::spawn(move || transaction.cancel()); + cancel_selected.wait(); + + assert_eq!(completion.current_deadline(), None); + assert!(!completion.has_deadline_ticket()); + assert_eq!(completion.outcome(), None); + assert!(!streams.activate_candidate_fixture(epoch)); + assert_eq!(streams.shared.current_epoch(), 7); + + resume_cancel.wait(); + assert!(cancel.join().expect("cancellation attempt exits")); + assert!(matches!( + completion.outcome(), + Some(Err(MacosNativeTransactionError::Cancelled { .. })) + )); + + let state = super::lock(&streams.state); + assert_eq!(state.fixture_current_epoch, Some(7)); + assert_eq!(state.fixture_candidate_epoch, None); + assert_eq!(state.candidate_epoch, None); + assert!(state.candidate_completion.is_none()); + assert!(state.pending_request.is_none()); + assert_eq!(state.request, MacosStreamRequest::default()); + drop(state); + assert_eq!(streams.shared.current_epoch(), 7); + } + + #[test] + fn successful_claim_wakes_only_after_current_and_first_publication_commit() { + let request = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + let (transaction, _) = streams + .begin_request_candidate_fixture(request) + .expect("request candidate starts"); + let epoch = transaction.generation(); + let completion = super::lock(&streams.state) + .candidate_completion + .as_ref() + .cloned() + .expect("candidate completion is installed"); + let published = Arc::new(AtomicBool::new(false)); + let observed_publication = Arc::clone(&published); + let observer_streams = Arc::clone(&streams); + let (observed_tx, observed_rx) = mpsc::sync_channel(1); + let waiter = thread::spawn(move || { + let result = transaction.wait(); + let state = super::lock(&observer_streams.state); + observed_tx + .send(( + result, + observer_streams.shared.current_epoch(), + state.fixture_current_epoch, + state.pending_selection.is_none(), + state.request, + observed_publication.load(Ordering::Acquire), + )) + .expect("waiter observation is delivered"); + }); + let claim_reached = Arc::new(std::sync::Barrier::new(2)); + let claimed = Arc::clone(&claim_reached); + let resume_commit = Arc::new(std::sync::Barrier::new(2)); + let resume = Arc::clone(&resume_commit); + let publish_streams = Arc::clone(&streams); + let publish_flag = Arc::clone(&published); + let publisher = thread::spawn(move || { + publish_streams.publish_decoded_event_with_claim_hook( + epoch, + sdr_delivery_fixture(), + move || { + claimed.wait(); + resume.wait(); + }, + move || publish_flag.store(true, Ordering::Release), + ) + }); + claim_reached.wait(); + + assert_eq!(completion.current_deadline(), None); + assert!(!completion.has_deadline_ticket()); + assert_eq!(completion.outcome(), None); + assert_eq!(observed_rx.try_recv(), Err(mpsc::TryRecvError::Empty)); + assert!(!completion.cancel()); + + resume_commit.wait(); + assert!(publisher.join().expect("first publication exits")); + let observation = observed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("successful transaction wakes after publication"); + waiter.join().expect("request waiter exits"); + + assert_eq!(observation.0, Ok(())); + assert_eq!(observation.1, epoch); + assert_eq!(observation.2, Some(epoch)); + assert!(observation.3); + assert_eq!(observation.4, request); + assert!(observation.5); + assert!(!completion.is_open()); + } + + #[test] + fn panic_after_success_claim_cleans_candidate_before_failure_publication() { + let request = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + let (transaction, _) = streams + .begin_request_candidate_fixture(request) + .expect("request candidate starts"); + let epoch = transaction.generation(); + let completion = super::lock(&streams.state) + .candidate_completion + .as_ref() + .cloned() + .expect("candidate completion is installed"); + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + streams.publish_decoded_event_with_claim_hook( + epoch, + sdr_delivery_fixture(), + || panic!("abort after reserving transaction success"), + || panic!("publication must not run after claim abort"), + ) + })); + + assert!(unwind.is_err()); + assert!(matches!( + transaction.wait(), + Err(MacosNativeTransactionError::Cancelled { .. }) + )); + let state = super::lock(&streams.state); + assert_eq!(state.fixture_current_epoch, Some(7)); + assert_eq!(state.fixture_candidate_epoch, None); + assert_eq!(state.candidate_epoch, None); + assert!(state.candidate_completion.is_none()); + assert!(state.pending_request.is_none()); + assert_eq!(state.request, MacosStreamRequest::default()); + drop(state); + assert_eq!(streams.shared.current_epoch(), 7); + assert!(!completion.has_deadline_ticket()); + } + + #[test] + fn panic_before_first_publication_restores_prior_current_before_failure_wakes() { + let request = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + let (transaction, _) = streams + .begin_request_candidate_fixture(request) + .expect("request candidate starts"); + let epoch = transaction.generation(); + let completion = super::lock(&streams.state) + .candidate_completion + .as_ref() + .cloned() + .expect("candidate completion is installed"); + let previous_status = streams.shared.status(); + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + streams.publish_decoded_event_with(epoch, true, Some(sdr_delivery_fixture()), || { + panic!("abort before first publication commits") + }) + })); + + assert!(unwind.is_err()); + assert!(matches!( + transaction.wait(), + Err(MacosNativeTransactionError::Cancelled { .. }) + )); + let state = super::lock(&streams.state); + assert_eq!(state.fixture_current_epoch, Some(7)); + assert_eq!(state.fixture_candidate_epoch, None); + assert_eq!(state.candidate_epoch, None); + assert_eq!(state.request, MacosStreamRequest::default()); + assert!(state.pending_selection.is_none()); + assert!(state.pending_request.is_none()); + drop(state); + assert_eq!(streams.shared.current_epoch(), 7); + assert_eq!(streams.shared.status(), previous_status); + assert!(!completion.has_deadline_ticket()); + } + + #[test] + fn stream_slot_serializes_request_transactions_while_a_candidate_is_pending() { + let original = MacosStreamRequest::default(); + let first = MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("first fixture request is valid"); + let second = MacosStreamRequest::new_hdr(MacosCaptureCadence::NativeRefresh, true) + .expect("second fixture request is valid"); + let streams = stream_slot_fixture(7, 3); + super::lock(&streams.state).request = original; + let (pending, completion) = pending_request(12, first); + let (stage, _) = reserve_request_candidate_fixture(&streams, 12, first, pending) + .expect("first request reserves") + .expect("first request stages"); + assert!(streams.start_candidate_fixture(stage)); + let reserve_pool: super::PoolReservationFactory = + Arc::new(|_, _| -> Result { + unreachable!("serialized request never prepares another native stream") + }); + + let error = match streams.set_request(second, &reserve_pool) { + Ok(_) => panic!("a second request cannot overtake the pending transaction"), + Err(error) => error, + }; + + assert!(error.to_string().contains("still pending")); + assert_eq!( + completion.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ); + assert!(streams.activate_candidate_fixture(stage.epoch)); + assert_eq!(completion.recv(), Ok(Ok(()))); + assert_eq!(streams.committed_request(), first); + } + + #[test] + fn repeated_activate_deactivate_cancels_every_pending_transaction() { + let streams = stream_slot_fixture(7, 3); + let requests = [ + MacosStreamRequest::new(MacosCaptureCadence::FramesPerSecond(30), false) + .expect("first fixture request is valid"), + MacosStreamRequest::new_hdr(MacosCaptureCadence::NativeRefresh, true) + .expect("second fixture request is valid"), + ]; + + for request in requests { + streams + .begin_picker_resolution() + .expect("picker resolution begins"); + let (transaction, _) = streams + .begin_request_candidate_fixture(request) + .expect("request candidate starts"); + assert!(transaction.current_deadline().is_some()); + + assert!(streams.set_capture_active(false)); + assert!(transaction.current_deadline().is_none()); + assert!(matches!( + transaction.wait(), + Err(MacosNativeTransactionError::Cancelled { .. }) + )); + assert!(super::lock(&streams.source_transaction).is_none()); + let state = super::lock(&streams.state); + assert!(state.pending_request.is_none()); + assert!(state.candidate_completion.is_none()); + assert_eq!(state.candidate_epoch, None); + assert_eq!(state.staging_epoch, None); + drop(state); + + assert!(!streams.set_capture_active(false)); + assert!(streams.set_capture_active(true)); + } + } + + #[test] + fn timed_out_old_stop_error_cannot_degrade_a_live_successor() { + let shared = SessionShared::new( + MacosProtectedSourceState::Live, + super::MacosCaptureSelector::Auto, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + ); + shared.activate_epoch(41); + shared.record_retirement_error(&MacosCaptureError::StreamStopCompletionLost); + + shared.activate_epoch(42); + shared.set_status(MacosProtectedSourceState::Live); + shared.record_retirement_error(&MacosCaptureError::CaptureWorkerStartFailed( + "late stop callback failed".to_owned(), + )); + + assert_eq!(shared.current_epoch(), 42); + assert_eq!(shared.status(), MacosProtectedSourceState::Live); + assert!(!shared.mailbox.has_pending()); + assert_eq!(shared.diagnostics().total_dropped(), 2); + } + + #[test] + fn restart_diagnostic_requires_grant_enumeration_and_stream_permission_failure() { + let shared = SessionShared::new( + MacosProtectedSourceState::Starting, + super::MacosCaptureSelector::PrimaryDisplay, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + ); + let (resolution, completion) = shared + .begin_restart_diagnostic(true, 7) + .expect("diagnostic attempt begins"); + shared.record_filter_enumerated(&resolution, 42); + + assert_eq!( + shared.record_stream_diagnostic_result(42, MacosProtectedSourceState::PermissionDenied), + MacosProtectedSourceState::NeedsProcessRestart + ); + assert_eq!( + completion.recv(), + Ok(MacosProtectedSourceState::NeedsProcessRestart) + ); + } + + #[test] + fn restart_diagnostic_requires_its_exact_resolution_provenance() { + let shared = SessionShared::new( + MacosProtectedSourceState::Starting, + super::MacosCaptureSelector::PrimaryDisplay, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + ); + let (stale, stale_completion) = shared + .begin_restart_diagnostic(true, 7) + .expect("first diagnostic begins"); + let (fresh, fresh_completion) = shared + .begin_restart_diagnostic(true, 8) + .expect("second diagnostic supersedes it"); + assert_eq!( + stale_completion.recv(), + Ok(MacosProtectedSourceState::Failed) + ); + + shared.record_non_stream_diagnostic_failure(&stale, MacosProtectedSourceState::Failed); + assert_eq!( + fresh_completion.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ); + shared.record_filter_enumerated(&fresh, 43); + assert_eq!( + shared.record_stream_diagnostic_result(43, MacosProtectedSourceState::PermissionDenied), + MacosProtectedSourceState::NeedsProcessRestart + ); + assert_eq!( + fresh_completion.recv(), + Ok(MacosProtectedSourceState::NeedsProcessRestart) + ); + } + + #[test] + fn claimed_diagnostic_cancellation_cannot_be_overwritten_by_stream_success() { + let streams = stream_slot_fixture(0, 7); + let (resolution, transaction) = streams + .begin_restart_diagnostic(true, 7) + .expect("diagnostic transaction begins"); + streams.shared.record_filter_enumerated(&resolution, 42); + let completion = streams + .shared + .restart_diagnostic_completion(resolution.attempt) + .expect("diagnostic completion remains active"); + let cancel_selected = Arc::new(std::sync::Barrier::new(2)); + let selected = Arc::clone(&cancel_selected); + let resume_cancel = Arc::new(std::sync::Barrier::new(2)); + let resume = Arc::clone(&resume_cancel); + let cancel_streams = Arc::clone(&streams); + let attempt = resolution.attempt; + completion.set_cancel(move |_| { + selected.wait(); + resume.wait(); + cancel_streams.finish_restart_diagnostic(attempt); + }); + let cancellation = thread::spawn(move || transaction.cancel()); + cancel_selected.wait(); + + assert_eq!(completion.current_deadline(), None); + assert_eq!(completion.outcome(), None); + assert_eq!( + streams + .shared + .record_stream_diagnostic_result(42, MacosProtectedSourceState::PermissionDenied,), + MacosProtectedSourceState::PermissionDenied + ); + assert!( + streams + .shared + .restart_diagnostic_completion(resolution.attempt) + .is_some() + ); + + resume_cancel.wait(); + assert!(cancellation.join().expect("diagnostic cancellation exits")); + assert!(matches!( + completion.outcome(), + Some(Err(MacosNativeTransactionError::Cancelled { .. })) + )); + assert!( + streams + .shared + .restart_diagnostic_completion(resolution.attempt) + .is_none() + ); + assert_eq!(streams.shared.status(), MacosProtectedSourceState::Failed); + assert!(!completion.has_deadline_ticket()); + } + + #[test] + fn ordinary_resolution_supersedes_the_diagnostic_without_stranding_its_receiver() { + let shared = SessionShared::new( + MacosProtectedSourceState::Starting, + super::MacosCaptureSelector::PrimaryDisplay, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + ); + let (diagnostic, completion) = shared + .begin_restart_diagnostic(true, 7) + .expect("diagnostic begins"); + + let ordinary = shared + .begin_resolution() + .expect("ordinary resolution begins"); + + assert!(shared.resolution_is_current(ordinary)); + assert_eq!(completion.recv(), Ok(MacosProtectedSourceState::Failed)); + shared.record_filter_enumerated(&diagnostic, 42); + assert_eq!( + shared.record_stream_diagnostic_result(42, MacosProtectedSourceState::PermissionDenied), + MacosProtectedSourceState::PermissionDenied + ); + } + + #[test] + fn primary_display_diagnostic_clears_picker_identity_before_enumeration() { + let shared = Arc::new(SessionShared::new( + MacosProtectedSourceState::ReadyIdle, + super::MacosCaptureSelector::SessionScoped, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + )); + shared.set_unconfirmed_selection(super::MacosCaptureSelection::SessionScoped { + content_style: super::MacosCaptureContentStyle::Window, + }); + let streams = StreamSlot::new(Arc::clone(&shared), MacosStreamRequest::default()) + .expect("fixture native lifecycle starts"); + { + let mut state = super::lock(&streams.state); + state.staging_epoch = Some(8); + state.pending_request = Some(pending_request(8, MacosStreamRequest::default()).0); + } + + streams + .clear_selection() + .expect("diagnostic reset clears prior selection state"); + shared.set_selector(super::MacosCaptureSelector::PrimaryDisplay); + + let state = super::lock(&streams.state); + assert!(state.selected_filter.is_none()); + assert_eq!(state.staging_epoch, None); + assert!(state.pending_request.is_none()); + assert_eq!(shared.selection(), super::MacosCaptureSelection::None); + assert_eq!( + shared.selector(), + super::MacosCaptureSelector::PrimaryDisplay + ); + } + + #[test] + fn old_stream_completion_cannot_satisfy_primary_display_diagnostic() { + let shared = SessionShared::new( + MacosProtectedSourceState::Starting, + super::MacosCaptureSelector::PrimaryDisplay, + MacosTahoeCapabilities::from_probes(ABSENT_TAHOE_PROBES), + ); + let (resolution, completion) = shared + .begin_restart_diagnostic(true, 7) + .expect("diagnostic begins"); + shared.record_filter_enumerated(&resolution, 42); + + assert_eq!( + shared.record_stream_diagnostic_result(41, MacosProtectedSourceState::ReadyIdle), + MacosProtectedSourceState::ReadyIdle + ); + assert_eq!( + completion.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ); + assert_eq!( + shared.record_stream_diagnostic_result(42, MacosProtectedSourceState::PermissionDenied), + MacosProtectedSourceState::NeedsProcessRestart + ); + assert_eq!( + completion.recv(), + Ok(MacosProtectedSourceState::NeedsProcessRestart) + ); + } + + #[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 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 + // 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"); + 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 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 { + 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)); + } +} diff --git a/crates/hypercolor-macos-capture/src/native/lifecycle.rs b/crates/hypercolor-macos-capture/src/native/lifecycle.rs new file mode 100644 index 000000000..9eaf9c52a --- /dev/null +++ b/crates/hypercolor-macos-capture/src/native/lifecycle.rs @@ -0,0 +1,652 @@ +use std::collections::HashMap; +use std::fmt; +use std::io; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::Instant; + +use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained}; + +use super::transactions::{DeadlineScheduler, DeadlineTicket}; + +const COMPLETION_OPEN: u8 = 0; +const COMPLETION_INVOKED: u8 = 1; +const COMPLETION_DESTROYED: u8 = 2; +const STOP_PENDING: u8 = 0; +const STOP_TIMED_OUT: u8 = 1; +const STOP_COMPLETED: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CompletionDisposition { + Invoked, + Destroyed, +} + +type CompletionObserver = Box; + +struct CompletionFenceInner { + disposition: AtomicU8, + observers: Mutex>, +} + +#[derive(Clone)] +pub(super) struct CompletionFence { + inner: Arc, +} + +pub(super) struct CompletionWitness { + fence: CompletionFence, +} + +impl CompletionFence { + pub(super) fn new() -> Self { + Self { + inner: Arc::new(CompletionFenceInner { + disposition: AtomicU8::new(COMPLETION_OPEN), + observers: Mutex::new(Vec::new()), + }), + } + } + + pub(super) fn witness(&self) -> CompletionWitness { + CompletionWitness { + fence: self.clone(), + } + } + + pub(super) fn observe(&self, observer: impl FnOnce(CompletionDisposition) + Send + 'static) { + let disposition = self.disposition(); + if let Some(disposition) = disposition { + observer(disposition); + return; + } + let mut observers = lock(&self.inner.observers); + if let Some(disposition) = self.disposition() { + drop(observers); + observer(disposition); + } else { + observers.push(Box::new(observer)); + } + } + + fn disposition(&self) -> Option { + match self.inner.disposition.load(Ordering::Acquire) { + COMPLETION_OPEN => None, + COMPLETION_INVOKED => Some(CompletionDisposition::Invoked), + COMPLETION_DESTROYED => Some(CompletionDisposition::Destroyed), + value => unreachable!("invalid native completion disposition {value}"), + } + } + + fn settle(&self, disposition: CompletionDisposition) -> bool { + let value = match disposition { + CompletionDisposition::Invoked => COMPLETION_INVOKED, + CompletionDisposition::Destroyed => COMPLETION_DESTROYED, + }; + if self + .inner + .disposition + .compare_exchange(COMPLETION_OPEN, value, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return false; + } + let observers = std::mem::take(&mut *lock(&self.inner.observers)); + for observer in observers { + observer(disposition); + } + true + } +} + +impl fmt::Debug for CompletionFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CompletionFence") + .field("disposition", &self.disposition()) + .finish() + } +} + +impl CompletionWitness { + pub(super) fn complete(&self) -> bool { + self.fence.settle(CompletionDisposition::Invoked) + } +} + +impl Drop for CompletionWitness { + fn drop(&mut self) { + let _ = self.fence.settle(CompletionDisposition::Destroyed); + } +} + +trait RetirementExecutor: Send + Sync { + fn execute(&self, job: Box); +} + +struct DispatchRetirementExecutor { + queue: DispatchRetained, +} + +impl RetirementExecutor for DispatchRetirementExecutor { + fn execute(&self, job: Box) { + self.queue.exec_async(job); + } +} + +trait ErasedRetirementEntry: Send + Sync {} + +struct RetirementEntry { + id: u64, + owner: Mutex>, + start_completion_done: AtomicBool, + stop_disposition: AtomicU8, + worker_done: AtomicBool, + stop_deadline: Mutex>, +} + +impl ErasedRetirementEntry for RetirementEntry {} + +#[derive(Default)] +struct RetirementRegistry { + entries: Mutex>>, +} + +struct NativeLifecycleInner { + deadlines: DeadlineScheduler, + retirements: RetirementRegistry, + retirement_executor: Arc, + next_retirement_id: AtomicU64, +} + +#[derive(Clone)] +pub(super) struct NativeLifecycle { + inner: Arc, +} + +impl NativeLifecycle { + pub(super) fn start() -> io::Result { + static DEADLINES: OnceLock = OnceLock::new(); + static DEADLINE_START: Mutex<()> = Mutex::new(()); + let _start = lock(&DEADLINE_START); + let deadlines = match DEADLINES.get() { + Some(deadlines) => deadlines.clone(), + None => { + let deadlines = DeadlineScheduler::start("hypercolor-macos-native-deadlines")?; + let _ = DEADLINES.set(deadlines.clone()); + deadlines + } + }; + let retirement_executor = Arc::new(DispatchRetirementExecutor { + queue: DispatchQueue::new( + "tech.hyperbliss.hypercolor.screen-capture-retirement", + DispatchQueueAttr::concurrent(), + ), + }); + Ok(Self::with_parts(deadlines, retirement_executor)) + } + + fn with_parts( + deadlines: DeadlineScheduler, + retirement_executor: Arc, + ) -> Self { + Self { + inner: Arc::new(NativeLifecycleInner { + deadlines, + retirements: RetirementRegistry::default(), + retirement_executor, + next_retirement_id: AtomicU64::new(1), + }), + } + } + + pub(super) fn deadlines(&self) -> &DeadlineScheduler { + &self.inner.deadlines + } + + pub(super) fn retire( + &self, + owner: T, + start_completion: CompletionFence, + stop_deadline: Instant, + run: impl FnOnce(&mut T, CompletionWitness) + Send + 'static, + on_stop_timeout: impl Fn() + Send + Sync + 'static, + ) -> u64 { + self.retire_with_timeout_dequeued( + owner, + start_completion, + stop_deadline, + run, + on_stop_timeout, + || {}, + ) + } + + fn retire_with_timeout_dequeued( + &self, + owner: T, + start_completion: CompletionFence, + stop_deadline: Instant, + run: impl FnOnce(&mut T, CompletionWitness) + Send + 'static, + on_stop_timeout: impl Fn() + Send + Sync + 'static, + on_timeout_dequeued: impl FnOnce() + Send + 'static, + ) -> u64 { + let id = self.next_retirement_id(); + let entry = Arc::new(RetirementEntry { + id, + owner: Mutex::new(Some(owner)), + start_completion_done: AtomicBool::new(false), + stop_disposition: AtomicU8::new(STOP_PENDING), + worker_done: AtomicBool::new(false), + stop_deadline: Mutex::new(None), + }); + self.insert(id, Arc::clone(&entry) as Arc); + + let start_entry = Arc::downgrade(&entry); + let start_lifecycle = Arc::downgrade(&self.inner); + start_completion.observe(move |_| { + if let Some(entry) = start_entry.upgrade() { + entry.start_completion_done.store(true, Ordering::Release); + release_if_settled(&start_lifecycle, &entry); + } + }); + + let timeout_entry = Arc::downgrade(&entry); + let timeout = Arc::new(on_stop_timeout); + let scheduled_timeout = Arc::clone(&timeout); + match self.inner.deadlines.schedule(stop_deadline, move || { + if let Some(entry) = timeout_entry.upgrade() { + on_timeout_dequeued(); + if entry + .stop_disposition + .compare_exchange( + STOP_PENDING, + STOP_TIMED_OUT, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + scheduled_timeout(); + } + } + }) { + Ok(ticket) => *lock(&entry.stop_deadline) = Some(ticket), + Err(_) => { + if entry + .stop_disposition + .compare_exchange( + STOP_PENDING, + STOP_TIMED_OUT, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + timeout(); + } + } + } + + let stop_completion = CompletionFence::new(); + let stop_entry = Arc::downgrade(&entry); + let stop_lifecycle = Arc::downgrade(&self.inner); + stop_completion.observe(move |_| { + if let Some(entry) = stop_entry.upgrade() { + entry + .stop_disposition + .store(STOP_COMPLETED, Ordering::Release); + drop(lock(&entry.stop_deadline).take()); + release_if_settled(&stop_lifecycle, &entry); + } + }); + + let worker_entry = Arc::clone(&entry); + let worker_lifecycle = Arc::downgrade(&self.inner); + self.inner.retirement_executor.execute(Box::new(move || { + { + let mut owner = lock(&worker_entry.owner); + let owner = owner + .as_mut() + .expect("registered native retirement retains its owner"); + run(owner, stop_completion.witness()); + } + worker_entry.worker_done.store(true, Ordering::Release); + release_if_settled(&worker_lifecycle, &worker_entry); + })); + id + } + + pub(super) fn retire_without_native_stop( + &self, + owner: T, + start_completion: CompletionFence, + run: impl FnOnce(&mut T) + Send + 'static, + ) -> u64 { + let id = self.next_retirement_id(); + let entry = Arc::new(RetirementEntry { + id, + owner: Mutex::new(Some(owner)), + start_completion_done: AtomicBool::new(false), + stop_disposition: AtomicU8::new(STOP_COMPLETED), + worker_done: AtomicBool::new(false), + stop_deadline: Mutex::new(None), + }); + self.insert(id, Arc::clone(&entry) as Arc); + let start_entry = Arc::downgrade(&entry); + let start_lifecycle = Arc::downgrade(&self.inner); + start_completion.observe(move |_| { + if let Some(entry) = start_entry.upgrade() { + entry.start_completion_done.store(true, Ordering::Release); + release_if_settled(&start_lifecycle, &entry); + } + }); + let worker_entry = Arc::clone(&entry); + let worker_lifecycle = Arc::downgrade(&self.inner); + self.inner.retirement_executor.execute(Box::new(move || { + { + let mut owner = lock(&worker_entry.owner); + run(owner + .as_mut() + .expect("registered native retirement retains its owner")); + } + worker_entry.worker_done.store(true, Ordering::Release); + release_if_settled(&worker_lifecycle, &worker_entry); + })); + id + } + + fn next_retirement_id(&self) -> u64 { + self.inner + .next_retirement_id + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |id| id.checked_add(1)) + .expect("macOS native retirement identity must remain monotonic") + } + + fn insert(&self, id: u64, entry: Arc) { + let replaced = lock(&self.inner.retirements.entries).insert(id, entry); + debug_assert!(replaced.is_none(), "native retirement identity is unique"); + } + + #[cfg(test)] + fn pending_retirements(&self) -> usize { + lock(&self.inner.retirements.entries).len() + } +} + +fn release_if_settled(lifecycle: &Weak, entry: &Arc>) { + if !entry.start_completion_done.load(Ordering::Acquire) + || entry.stop_disposition.load(Ordering::Acquire) != STOP_COMPLETED + || !entry.worker_done.load(Ordering::Acquire) + { + return; + } + if let Some(lifecycle) = lifecycle.upgrade() { + lock(&lifecycle.retirements.entries).remove(&entry.id); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + use std::time::{Duration, Instant}; + + use super::{ + CompletionDisposition, CompletionFence, CompletionWitness, DeadlineScheduler, + NativeLifecycle, RetirementExecutor, lock, + }; + + #[derive(Default)] + struct ControlledExecutor { + jobs: Mutex>>, + } + + impl ControlledExecutor { + fn take(&self) -> Box { + lock(&self.jobs) + .pop_front() + .expect("controlled retirement job is pending") + } + } + + impl RetirementExecutor for ControlledExecutor { + fn execute(&self, job: Box) { + lock(&self.jobs).push_back(job); + } + } + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::AcqRel); + } + } + + struct FenceOwner { + _completion: CompletionFence, + _drop: DropProbe, + } + + fn lifecycle() -> (NativeLifecycle, Arc) { + let executor = Arc::new(ControlledExecutor::default()); + ( + NativeLifecycle::with_parts( + DeadlineScheduler::manual(), + Arc::clone(&executor) as Arc, + ), + executor, + ) + } + + fn completed_fence() -> CompletionFence { + let fence = CompletionFence::new(); + assert!(fence.witness().complete()); + fence + } + + #[test] + fn completion_witness_invokes_observers_exactly_once() { + let fence = CompletionFence::new(); + let witness = fence.witness(); + let calls = Arc::new(AtomicU64::new(0)); + let observed = Arc::clone(&calls); + fence.observe(move |disposition| { + assert_eq!(disposition, CompletionDisposition::Invoked); + observed.fetch_add(1, Ordering::AcqRel); + }); + + assert!(witness.complete()); + assert!(!witness.complete()); + drop(witness); + + assert_eq!(calls.load(Ordering::Acquire), 1); + } + + #[test] + fn destroying_completion_witness_settles_the_fence() { + let fence = CompletionFence::new(); + let witness = fence.witness(); + let disposition = Arc::new(Mutex::new(None)); + let observed = Arc::clone(&disposition); + fence.observe(move |value| *lock(&observed) = Some(value)); + + drop(witness); + + assert_eq!(*lock(&disposition), Some(CompletionDisposition::Destroyed)); + } + + #[test] + fn stop_timeout_keeps_owner_quarantined_until_late_completion() { + let (lifecycle, executor) = lifecycle(); + let drops = Arc::new(AtomicU64::new(0)); + let stop_witness = Arc::new(Mutex::new(None::)); + let captured_witness = Arc::clone(&stop_witness); + let timeouts = Arc::new(AtomicU64::new(0)); + let timeout_count = Arc::clone(&timeouts); + let deadline = Instant::now() + Duration::from_secs(5); + lifecycle.retire( + DropProbe(Arc::clone(&drops)), + completed_fence(), + deadline, + move |_, witness| *lock(&captured_witness) = Some(witness), + move || { + timeout_count.fetch_add(1, Ordering::AcqRel); + }, + ); + executor.take()(); + lifecycle.inner.deadlines.expire_through(deadline); + + assert_eq!(timeouts.load(Ordering::Acquire), 1); + assert_eq!(drops.load(Ordering::Acquire), 0); + assert_eq!(lifecycle.pending_retirements(), 1); + + assert!( + lock(&stop_witness) + .take() + .expect("late stop completion remains owned") + .complete() + ); + + assert_eq!(lifecycle.pending_retirements(), 0); + assert_eq!(drops.load(Ordering::Acquire), 1); + } + + #[test] + fn completed_stop_beats_a_dequeued_timeout_before_its_claim() { + let (lifecycle, executor) = lifecycle(); + let drops = Arc::new(AtomicU64::new(0)); + let stop_witness = Arc::new(Mutex::new(None::)); + let captured_witness = Arc::clone(&stop_witness); + let timeouts = Arc::new(AtomicU64::new(0)); + let timeout_count = Arc::clone(&timeouts); + let timeout_dequeued = Arc::new(Barrier::new(2)); + let timeout_observed = Arc::clone(&timeout_dequeued); + let resume_timeout = Arc::new(Barrier::new(2)); + let timeout_resume = Arc::clone(&resume_timeout); + let deadline = Instant::now() + Duration::from_secs(5); + lifecycle.retire_with_timeout_dequeued( + DropProbe(Arc::clone(&drops)), + completed_fence(), + deadline, + move |_, witness| *lock(&captured_witness) = Some(witness), + move || { + timeout_count.fetch_add(1, Ordering::AcqRel); + }, + move || { + timeout_observed.wait(); + timeout_resume.wait(); + }, + ); + executor.take()(); + + let deadlines = lifecycle.inner.deadlines.clone(); + let timeout = thread::spawn(move || deadlines.expire_through(deadline)); + timeout_dequeued.wait(); + + let witness = lock(&stop_witness) + .take() + .expect("native stop completion remains owned"); + assert!(witness.complete()); + assert!(!witness.complete()); + assert_eq!(lifecycle.pending_retirements(), 0); + assert_eq!(drops.load(Ordering::Acquire), 0); + + resume_timeout.wait(); + timeout.join().expect("dequeued timeout exits"); + + assert_eq!(timeouts.load(Ordering::Acquire), 0); + assert_eq!(drops.load(Ordering::Acquire), 1); + } + + #[test] + fn completion_destruction_releases_timed_out_owner() { + let (lifecycle, executor) = lifecycle(); + let drops = Arc::new(AtomicU64::new(0)); + let stop_witness = Arc::new(Mutex::new(None::)); + let captured_witness = Arc::clone(&stop_witness); + let deadline = Instant::now() + Duration::from_secs(5); + lifecycle.retire( + DropProbe(Arc::clone(&drops)), + completed_fence(), + deadline, + move |_, witness| *lock(&captured_witness) = Some(witness), + || {}, + ); + executor.take()(); + lifecycle.inner.deadlines.expire_through(deadline); + + drop(lock(&stop_witness).take()); + + assert_eq!(lifecycle.pending_retirements(), 0); + assert_eq!(drops.load(Ordering::Acquire), 1); + } + + #[test] + fn dropping_lifecycle_tears_down_a_missing_completion_quarantine() { + let (lifecycle, executor) = lifecycle(); + let drops = Arc::new(AtomicU64::new(0)); + let start_completion = CompletionFence::new(); + let stop_witness = Arc::new(Mutex::new(None::)); + let captured_witness = Arc::clone(&stop_witness); + lifecycle.retire( + FenceOwner { + _completion: start_completion.clone(), + _drop: DropProbe(Arc::clone(&drops)), + }, + start_completion, + Instant::now() + Duration::from_secs(5), + move |_, witness| *lock(&captured_witness) = Some(witness), + || {}, + ); + executor.take()(); + assert_eq!(lifecycle.pending_retirements(), 1); + + drop(lifecycle); + + assert_eq!(drops.load(Ordering::Acquire), 1); + drop(lock(&stop_witness).take()); + } + + #[test] + fn independent_retirements_have_no_serial_head_of_line_blocking() { + let (lifecycle, executor) = lifecycle(); + let blocked = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let first_blocked = Arc::clone(&blocked); + let first_release = Arc::clone(&release); + let first_done = Arc::new(AtomicU64::new(0)); + let first_result = Arc::clone(&first_done); + lifecycle.retire_without_native_stop((), completed_fence(), move |_| { + first_blocked.wait(); + first_release.wait(); + first_result.fetch_add(1, Ordering::AcqRel); + }); + let second_done = Arc::new(AtomicU64::new(0)); + let second_result = Arc::clone(&second_done); + lifecycle.retire_without_native_stop((), completed_fence(), move |_| { + second_result.fetch_add(1, Ordering::AcqRel); + }); + let first = executor.take(); + let second = executor.take(); + let first_thread = thread::spawn(first); + blocked.wait(); + + second(); + + assert_eq!(second_done.load(Ordering::Acquire), 1); + assert_eq!(first_done.load(Ordering::Acquire), 0); + release.wait(); + first_thread.join().expect("blocked retirement exits"); + assert_eq!(lifecycle.pending_retirements(), 0); + } +} diff --git a/crates/hypercolor-macos-capture/src/native/transactions.rs b/crates/hypercolor-macos-capture/src/native/transactions.rs new file mode 100644 index 000000000..efdead7ca --- /dev/null +++ b/crates/hypercolor-macos-capture/src/native/transactions.rs @@ -0,0 +1,1265 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, Weak}; +use std::thread; +use std::time::Instant; + +use crate::{MacosCaptureError, MacosProtectedSourceState}; + +type TransactionHook = Arc; +/// Cancel hooks receive the generation the cell held when the cancel +/// claimed it. Hooks must target that value rather than a generation +/// captured at registration time: stage adoption rekeys the cell, and a +/// captured generation goes stale the moment it does. +type TransactionCancelHook = Arc; +type DeadlineCallback = Box; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacosNativeTransactionPhase { + SourceResolution, + StreamStart, + FirstCompleteFrame, +} + +impl fmt::Display for MacosNativeTransactionPhase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::SourceResolution => "source resolution", + Self::StreamStart => "stream start", + Self::FirstCompleteFrame => "first complete frame", + }) + } +} + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum MacosNativeTransactionError { + #[error("macOS {phase} transaction {generation} was cancelled")] + Cancelled { + phase: MacosNativeTransactionPhase, + generation: u64, + }, + #[error("macOS {phase} transaction {generation} timed out")] + TimedOut { + phase: MacosNativeTransactionPhase, + generation: u64, + }, + #[error(transparent)] + Capture(#[from] MacosCaptureError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct TransactionIdentity { + pub(super) generation: u64, + pub(super) phase: MacosNativeTransactionPhase, +} + +struct ScheduledDeadline { + callback: Option, +} + +#[derive(Default)] +struct DeadlineQueue { + deadlines: BTreeMap<(Instant, u64), ScheduledDeadline>, + deadline_by_id: HashMap, +} + +struct DeadlineSchedulerInner { + next_id: AtomicU64, + queue: Mutex, + ready: Condvar, +} + +#[derive(Clone)] +pub(super) struct DeadlineScheduler { + inner: Arc, +} + +pub(super) struct DeadlineTicket { + scheduler: Weak, + id: u64, + armed: bool, +} + +impl DeadlineScheduler { + pub(super) fn start(thread_name: &str) -> io::Result { + let scheduler = Self::manual(); + let inner = Arc::clone(&scheduler.inner); + thread::Builder::new() + .name(thread_name.to_owned()) + .spawn(move || deadline_loop(&inner))?; + Ok(scheduler) + } + + pub(super) fn manual() -> Self { + Self { + inner: Arc::new(DeadlineSchedulerInner { + next_id: AtomicU64::new(1), + queue: Mutex::new(DeadlineQueue::default()), + ready: Condvar::new(), + }), + } + } + + pub(super) fn schedule( + &self, + deadline: Instant, + callback: impl FnOnce() + Send + 'static, + ) -> io::Result { + let id = self + .inner + .next_id + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |id| id.checked_add(1)) + .map_err(|_| io::Error::other("macOS native deadline identity exhausted"))?; + let mut queue = lock(&self.inner.queue); + queue.deadline_by_id.insert(id, deadline); + queue.deadlines.insert( + (deadline, id), + ScheduledDeadline { + callback: Some(Box::new(callback)), + }, + ); + drop(queue); + self.inner.ready.notify_one(); + Ok(DeadlineTicket { + scheduler: Arc::downgrade(&self.inner), + id, + armed: true, + }) + } + + #[cfg(test)] + pub(super) fn expire_through(&self, now: Instant) { + run_due_callbacks(&self.inner, now); + } + + #[cfg(test)] + pub(super) fn pending(&self) -> usize { + lock(&self.inner.queue).deadlines.len() + } +} + +impl DeadlineTicket { + fn cancel(&mut self) { + if !self.armed { + return; + } + self.armed = false; + let Some(scheduler) = self.scheduler.upgrade() else { + return; + }; + let mut queue = lock(&scheduler.queue); + if let Some(deadline) = queue.deadline_by_id.remove(&self.id) { + queue.deadlines.remove(&(deadline, self.id)); + } + drop(queue); + scheduler.ready.notify_one(); + } +} + +impl Drop for DeadlineTicket { + fn drop(&mut self) { + self.cancel(); + } +} + +fn deadline_loop(scheduler: &DeadlineSchedulerInner) { + loop { + let queue = lock(&scheduler.queue); + let Some((deadline, _)) = queue.deadlines.first_key_value().map(|(key, _)| *key) else { + drop( + scheduler + .ready + .wait(queue) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + continue; + }; + let now = Instant::now(); + if deadline > now { + let (waiting, _) = scheduler + .ready + .wait_timeout(queue, deadline.duration_since(now)) + .unwrap_or_else(std::sync::PoisonError::into_inner); + drop(waiting); + continue; + } + drop(queue); + run_due_callbacks(scheduler, now); + } +} + +fn run_due_callbacks(scheduler: &DeadlineSchedulerInner, now: Instant) { + let callbacks = { + let mut queue = lock(&scheduler.queue); + let due = queue + .deadlines + .range(..=(now, u64::MAX)) + .map(|(key, _)| *key) + .collect::>(); + due.into_iter() + .filter_map(|key| { + queue.deadline_by_id.remove(&key.1); + queue + .deadlines + .remove(&key) + .and_then(|mut deadline| deadline.callback.take()) + }) + .collect::>() + }; + for callback in callbacks { + callback(); + } +} + +struct TransactionState { + generation: u64, + phase: MacosNativeTransactionPhase, + claimed: bool, + outcome: Option>, + deadline: Option, + deadline_revision: u64, + deadline_ticket: Option, + timeout: Option, + cancel: Option, +} + +struct TransactionCell { + state: Mutex>, + ready: Condvar, +} + +/// Cancels the transaction when the last completer clone drops so an +/// abandoned cell can never strand its waiter. The registered cancel hook +/// is deliberately not run here: completer drop happens on the owning +/// (native) side, often while its state lock is held, and the hook exists +/// to actuate native-side cancellation that the dropping owner is already +/// performing. +struct CompleterGuard { + cell: Arc>, +} + +impl Drop for CompleterGuard { + fn drop(&mut self) { + let Some((settlement, _)) = claim_with(&self.cell, |state| { + Some(( + Err(MacosNativeTransactionError::Cancelled { + phase: state.phase, + generation: state.generation, + }), + None::, + )) + }) else { + return; + }; + settlement.publish(); + } +} + +pub(super) struct TransactionCompleter { + cell: Arc>, + guard: Arc>, +} + +pub(super) struct TransactionSettlement { + cell: Arc>, + outcome: Option>, +} + +struct TransactionWaiter { + cell: Arc>, + cancel_on_drop: bool, +} + +impl Clone for TransactionCompleter { + fn clone(&self) -> Self { + Self { + cell: Arc::clone(&self.cell), + guard: Arc::clone(&self.guard), + } + } +} + +impl fmt::Debug for TransactionCompleter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TransactionCompleter") + .field("identity", &self.identity()) + .field("open", &self.is_open()) + .finish() + } +} + +impl TransactionCompleter { + pub(super) fn new(identity: TransactionIdentity) -> Self { + let cell = Arc::new(TransactionCell { + state: Mutex::new(TransactionState { + generation: identity.generation, + phase: identity.phase, + claimed: false, + outcome: None, + deadline: None, + deadline_revision: 0, + deadline_ticket: None, + timeout: None, + cancel: None, + }), + ready: Condvar::new(), + }); + Self { + guard: Arc::new(CompleterGuard { + cell: Arc::clone(&cell), + }), + cell, + } + } + + fn waiter(&self) -> TransactionWaiter { + TransactionWaiter { + cell: Arc::clone(&self.cell), + cancel_on_drop: true, + } + } + + pub(super) fn identity(&self) -> TransactionIdentity { + let state = lock(&self.cell.state); + TransactionIdentity { + generation: state.generation, + phase: state.phase, + } + } + + /// Rebinds the transaction to a new stage generation when an in-flight + /// request is adopted by a fresh candidate stage (source pick, + /// interrupted-stream recovery). Generation-filtered operations key on + /// the live cell generation, so adoption must rekey the cell or every + /// later arm/cancel/claim silently misses the adopted transaction. The + /// previous generation's deadline is retired in the same breath: its + /// timeout targets the stage the transaction just left. + pub(super) fn rekey_generation(&self, generation: u64) -> bool { + let retired = { + let mut state = lock(&self.cell.state); + if state.claimed { + return false; + } + let Some(revision) = state.deadline_revision.checked_add(1) else { + // Revision exhaustion means in-flight deadline callbacks can + // no longer be invalidated, so the rekey must refuse rather + // than adopt a cell it cannot quarantine. + return false; + }; + state.generation = generation; + state.deadline = None; + state.deadline_revision = revision; + (state.timeout.take(), state.deadline_ticket.take()) + }; + drop(retired); + true + } + + pub(super) fn set_cancel(&self, cancel: impl Fn(u64) + Send + Sync + 'static) { + let mut state = lock(&self.cell.state); + if !state.claimed { + state.cancel = Some(Arc::new(cancel)); + } + } + + pub(super) fn arm( + &self, + scheduler: &DeadlineScheduler, + deadline: Instant, + timeout: impl Fn() + Send + Sync + 'static, + ) -> io::Result + where + T: Send + 'static, + { + self.arm_gated(scheduler, deadline, |state| !state.claimed, timeout) + } + + /// Set the phase and arm a deadline on behalf of a specific stage + /// generation, atomically with the generation check. + /// + /// The check must live under the cell lock in the same critical section + /// that mutates the phase and allocates the deadline revision: an arm + /// that validated the generation under the stream-state lock and was + /// then preempted across an adoption rekey would otherwise re-install a + /// deadline whose timeout hook targets the superseded stage and no-ops, + /// wedging the adopted candidate. A rekey landing between this section + /// and the ticket commit bumps the deadline revision, so the commit + /// check rejects that interleaving. + pub(super) fn arm_for_generation( + &self, + scheduler: &DeadlineScheduler, + deadline: Instant, + expected_generation: u64, + phase: MacosNativeTransactionPhase, + timeout: impl Fn() + Send + Sync + 'static, + ) -> io::Result + where + T: Send + 'static, + { + self.arm_gated( + scheduler, + deadline, + move |state| { + if state.claimed || state.generation != expected_generation { + return false; + } + state.phase = phase; + true + }, + timeout, + ) + } + + fn arm_gated( + &self, + scheduler: &DeadlineScheduler, + deadline: Instant, + gate: impl FnOnce(&mut TransactionState) -> bool, + timeout: impl Fn() + Send + Sync + 'static, + ) -> io::Result + where + T: Send + 'static, + { + let timeout: TransactionHook = Arc::new(timeout); + let scheduled_timeout = Arc::clone(&timeout); + let revision = { + let mut state = lock(&self.cell.state); + if !gate(&mut state) { + return Ok(false); + } + state.deadline_revision = state + .deadline_revision + .checked_add(1) + .ok_or_else(|| io::Error::other("macOS transaction deadline revision exhausted"))?; + state.deadline_revision + }; + let timeout_cell = Arc::downgrade(&self.cell); + let ticket = match scheduler.schedule(deadline, move || { + if let Some(cell) = timeout_cell.upgrade() { + let _ = claim_timeout(&cell, Some(revision), Some(scheduled_timeout)); + } + }) { + Ok(ticket) => ticket, + Err(error) => { + let previous = { + let mut state = lock(&self.cell.state); + if !state.claimed && state.deadline_revision == revision { + state.deadline = None; + state.timeout = None; + state.deadline_ticket.take() + } else { + None + } + }; + drop(previous); + return Err(error); + } + }; + let mut state = lock(&self.cell.state); + if state.claimed || state.deadline_revision != revision { + drop(state); + drop(ticket); + return Ok(false); + } + let previous = state.deadline_ticket.replace(ticket); + state.deadline = Some(deadline); + state.timeout = Some(timeout); + drop(state); + drop(previous); + Ok(true) + } + + pub(super) fn claim( + &self, + outcome: Result, + ) -> Option> { + claim_with(&self.cell, move |_| { + Some((outcome, None::)) + }) + .map(|(settlement, _)| settlement) + } + + #[cfg(test)] + pub(super) fn finish(&self, outcome: Result) -> bool { + self.claim(outcome).is_some_and(|settlement| { + settlement.publish(); + true + }) + } + + pub(super) fn is_open(&self) -> bool { + !lock(&self.cell.state).claimed + } + + pub(super) fn shares_cell(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.cell, &other.cell) + } + + #[cfg(test)] + pub(super) fn current_deadline(&self) -> Option { + lock(&self.cell.state).deadline + } + + #[cfg(test)] + pub(super) fn has_deadline_ticket(&self) -> bool { + lock(&self.cell.state).deadline_ticket.is_some() + } + + #[cfg(test)] + pub(super) fn outcome(&self) -> Option> + where + T: Clone, + { + lock(&self.cell.state).outcome.clone() + } + + #[cfg(test)] + pub(super) fn cancel(&self) -> bool { + claim_cancel(&self.cell) + } +} + +impl TransactionSettlement { + pub(super) fn publish(mut self) { + self.publish_inner(false); + } + + fn publish_inner(&mut self, abandoned: bool) { + let Some(mut outcome) = self.outcome.take() else { + return; + }; + let mut state = lock(&self.cell.state); + debug_assert!(state.claimed, "published transaction was claimed"); + debug_assert!(state.outcome.is_none(), "transaction publishes once"); + if abandoned && outcome.is_ok() { + outcome = Err(MacosNativeTransactionError::Cancelled { + phase: state.phase, + generation: state.generation, + }); + } + state.outcome = Some(outcome); + drop(state); + self.cell.ready.notify_all(); + } +} + +impl Drop for TransactionSettlement { + fn drop(&mut self) { + self.publish_inner(true); + } +} + +impl TransactionWaiter { + fn wait(mut self) -> Result { + let mut state = lock(&self.cell.state); + while state.outcome.is_none() { + state = self + .cell + .ready + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + self.cancel_on_drop = false; + state + .outcome + .take() + .expect("settled macOS native transaction has an outcome") + } + + fn cancel(mut self) -> bool { + self.cancel_on_drop = false; + claim_cancel(&self.cell) + } + + fn current_deadline(&self) -> Option { + lock(&self.cell.state).deadline + } + + fn wait_until(mut self, deadline: Instant) -> Result { + let mut state = lock(&self.cell.state); + while state.outcome.is_none() { + if state.claimed { + state = self + .cell + .ready + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + continue; + } + let now = Instant::now(); + if now >= deadline { + drop(state); + let _ = claim_timeout(&self.cell, None, None); + state = lock(&self.cell.state); + continue; + } + let (waiting, _) = self + .cell + .ready + .wait_timeout(state, deadline.duration_since(now)) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = waiting; + } + self.cancel_on_drop = false; + state + .outcome + .take() + .expect("settled macOS native transaction has an outcome") + } +} + +#[cfg(test)] +impl TransactionWaiter { + fn try_outcome(&self) -> Option> { + lock(&self.cell.state).outcome.clone() + } + + fn wait_outcome(&self) -> Result { + let mut state = lock(&self.cell.state); + while state.outcome.is_none() { + state = self + .cell + .ready + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + state + .outcome + .clone() + .expect("settled macOS native transaction has an outcome") + } +} + +impl Drop for TransactionWaiter { + fn drop(&mut self) { + if self.cancel_on_drop { + let _ = claim_cancel(&self.cell); + } + } +} + +fn claim_with( + cell: &Arc>, + decide: impl FnOnce( + &mut TransactionState, + ) -> Option<(Result, Option)>, +) -> Option<(TransactionSettlement, Option)> { + let (outcome, hook, retired_hooks, ticket) = { + let mut state = lock(&cell.state); + if state.claimed { + return None; + } + let (outcome, hook) = decide(&mut state)?; + state.claimed = true; + state.deadline = None; + // Retired hooks drop outside the state lock: a hook that captured a + // completer clone would otherwise run the completer drop guard while + // this cell's mutex is held. + let retired_hooks = (state.timeout.take(), state.cancel.take()); + (outcome, hook, retired_hooks, state.deadline_ticket.take()) + }; + drop(ticket); + drop(retired_hooks); + Some(( + TransactionSettlement { + cell: Arc::clone(cell), + outcome: Some(outcome), + }, + hook, + )) +} + +fn claim_cancel(cell: &Arc>) -> bool { + let Some((settlement, hook)) = claim_with(cell, move |state| { + let generation = state.generation; + Some(( + Err(MacosNativeTransactionError::Cancelled { + phase: state.phase, + generation, + }), + state.cancel.take().map(|hook| (hook, generation)), + )) + }) else { + return false; + }; + if let Some((hook, generation)) = hook { + hook(generation); + } + settlement.publish(); + true +} + +fn claim_timeout( + cell: &Arc>, + deadline_revision: Option, + timeout: Option, +) -> bool { + let Some((settlement, hook)) = claim_with(cell, move |state| { + if deadline_revision.is_some_and(|revision| state.deadline_revision != revision) { + return None; + } + Some(( + Err(MacosNativeTransactionError::TimedOut { + phase: state.phase, + generation: state.generation, + }), + timeout.or_else(|| state.timeout.take()), + )) + }) else { + return false; + }; + if let Some(hook) = hook { + hook(); + } + settlement.publish(); + true +} + +pub struct MacosStreamRequestTransaction { + generation: u64, + waiter: Option>, +} + +impl MacosStreamRequestTransaction { + #[must_use] + pub const fn generation(&self) -> u64 { + self.generation + } + + #[must_use] + pub fn current_deadline(&self) -> Option { + self.waiter + .as_ref() + .and_then(TransactionWaiter::current_deadline) + } + + pub fn wait(mut self) -> Result<(), MacosNativeTransactionError> { + self.waiter + .take() + .expect("macOS stream request transaction waits once") + .wait() + } + + pub fn cancel(mut self) -> bool { + self.waiter + .take() + .expect("macOS stream request transaction cancels once") + .cancel() + } +} + +impl fmt::Debug for MacosStreamRequestTransaction { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacosStreamRequestTransaction") + .field("generation", &self.generation) + .field("deadline", &self.current_deadline()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +impl MacosStreamRequestTransaction { + pub(super) fn try_recv( + &self, + ) -> Result, std::sync::mpsc::TryRecvError> { + self.waiter + .as_ref() + .and_then(TransactionWaiter::try_outcome) + .map(map_test_request_outcome) + .ok_or(std::sync::mpsc::TryRecvError::Empty) + } + + pub(super) fn recv(&self) -> Result, std::sync::mpsc::RecvError> { + Ok(map_test_request_outcome( + self.waiter + .as_ref() + .expect("test request transaction retains its waiter") + .wait_outcome(), + )) + } +} + +#[cfg(test)] +fn map_test_request_outcome( + outcome: Result<(), MacosNativeTransactionError>, +) -> Result<(), MacosCaptureError> { + outcome.map_err(|error| match error { + MacosNativeTransactionError::Capture(error) => error, + error => MacosCaptureError::CaptureWorkerStartFailed(error.to_string()), + }) +} + +pub struct MacosStreamDiagnosticTransaction { + generation: u64, + waiter: Option>, +} + +impl MacosStreamDiagnosticTransaction { + #[must_use] + pub const fn generation(&self) -> u64 { + self.generation + } + + #[must_use] + pub fn current_deadline(&self) -> Option { + self.waiter + .as_ref() + .and_then(TransactionWaiter::current_deadline) + } + + pub fn wait(mut self) -> Result { + self.waiter + .take() + .expect("macOS stream diagnostic transaction waits once") + .wait() + } + + pub fn wait_until( + mut self, + deadline: Instant, + ) -> Result { + self.waiter + .take() + .expect("macOS stream diagnostic transaction waits once") + .wait_until(deadline) + } + + pub fn cancel(mut self) -> bool { + self.waiter + .take() + .expect("macOS stream diagnostic transaction cancels once") + .cancel() + } +} + +impl fmt::Debug for MacosStreamDiagnosticTransaction { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacosStreamDiagnosticTransaction") + .field("generation", &self.generation) + .field("deadline", &self.current_deadline()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +impl MacosStreamDiagnosticTransaction { + pub(super) fn try_recv( + &self, + ) -> Result { + self.waiter + .as_ref() + .and_then(TransactionWaiter::try_outcome) + .map(|outcome| outcome.expect("fixture diagnostic transaction succeeds")) + .ok_or(std::sync::mpsc::TryRecvError::Empty) + } + + pub(super) fn recv(&self) -> Result { + Ok(self + .waiter + .as_ref() + .expect("test diagnostic transaction retains its waiter") + .wait_outcome() + .expect("fixture diagnostic transaction succeeds")) + } +} + +pub(super) fn stream_request_transaction( + generation: u64, +) -> (MacosStreamRequestTransaction, TransactionCompleter<()>) { + let completer = TransactionCompleter::new(TransactionIdentity { + generation, + phase: MacosNativeTransactionPhase::StreamStart, + }); + let transaction = MacosStreamRequestTransaction { + generation, + waiter: Some(completer.waiter()), + }; + (transaction, completer) +} + +pub(super) fn stream_diagnostic_transaction( + generation: u64, +) -> ( + MacosStreamDiagnosticTransaction, + TransactionCompleter, +) { + let completer = TransactionCompleter::new(TransactionIdentity { + generation, + phase: MacosNativeTransactionPhase::SourceResolution, + }); + let transaction = MacosStreamDiagnosticTransaction { + generation, + waiter: Some(completer.waiter()), + }; + (transaction, completer) +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Barrier}; + use std::thread; + use std::time::{Duration, Instant}; + + use super::{ + DeadlineScheduler, MacosNativeTransactionError, MacosNativeTransactionPhase, + TransactionCompleter, TransactionIdentity, + }; + + fn fixture_transaction() -> (TransactionCompleter, super::TransactionWaiter) { + let completer = TransactionCompleter::new(TransactionIdentity { + generation: 7, + phase: MacosNativeTransactionPhase::StreamStart, + }); + let waiter = completer.waiter(); + (completer, waiter) + } + + #[test] + fn completed_deadline_is_physically_removed() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + let deadline = Instant::now() + Duration::from_secs(60); + completer + .arm(&scheduler, deadline, || panic!("cancelled deadline fired")) + .expect("fixture deadline schedules"); + assert_eq!(scheduler.pending(), 1); + + assert!(completer.finish(Ok(11))); + assert_eq!(scheduler.pending(), 0); + assert_eq!(waiter.wait(), Ok(11)); + } + + #[test] + fn consuming_the_result_does_not_reopen_the_transaction() { + let (completer, waiter) = fixture_transaction(); + + assert!(completer.finish(Ok(11))); + assert_eq!(waiter.wait(), Ok(11)); + + assert!(!completer.is_open()); + assert!(!completer.finish(Ok(12))); + } + + #[test] + fn claimed_result_does_not_wake_until_published() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + completer + .arm(&scheduler, Instant::now() + Duration::from_secs(60), || {}) + .expect("fixture deadline schedules"); + let settlement = completer.claim(Ok(11)).expect("success claims open cell"); + + assert!(!completer.is_open()); + assert_eq!(completer.current_deadline(), None); + assert_eq!(scheduler.pending(), 0); + assert_eq!(waiter.try_outcome(), None); + + settlement.publish(); + assert_eq!(waiter.wait(), Ok(11)); + } + + #[test] + fn abandoned_success_claim_publishes_failure_during_unwind() { + let (completer, waiter) = fixture_transaction(); + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _settlement = completer.claim(Ok(11)).expect("success claims open cell"); + panic!("fixture aborts before side effects commit"); + })); + + assert!(unwind.is_err()); + + assert_eq!( + waiter.wait(), + Err(MacosNativeTransactionError::Cancelled { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 7, + }) + ); + } + + #[test] + fn manual_deadline_settles_without_sleep_or_polling() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + let deadline = Instant::now() + Duration::from_secs(5); + completer + .arm(&scheduler, deadline, || {}) + .expect("fixture deadline schedules"); + + scheduler.expire_through(deadline); + + assert_eq!( + waiter.wait(), + Err(MacosNativeTransactionError::TimedOut { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 7, + }) + ); + assert_eq!(scheduler.pending(), 0); + } + + #[test] + fn earlier_wait_deadline_invokes_the_registered_timeout_transaction() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + let scheduled = Instant::now() + Duration::from_secs(60); + completer + .arm(&scheduler, scheduled, || {}) + .expect("fixture deadline schedules"); + + assert_eq!( + waiter.wait_until(Instant::now()), + Err(MacosNativeTransactionError::TimedOut { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 7, + }) + ); + assert_eq!(scheduler.pending(), 0); + } + + #[test] + fn completion_and_timeout_commit_exactly_one_result() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + let deadline = Instant::now() + Duration::from_secs(5); + let barrier = Arc::new(Barrier::new(3)); + let wins = Arc::new(AtomicU64::new(0)); + let timeout_wins = Arc::clone(&wins); + let timeout_barrier = Arc::clone(&barrier); + completer + .arm(&scheduler, deadline, move || { + timeout_barrier.wait(); + timeout_wins.fetch_add(1, Ordering::AcqRel); + }) + .expect("fixture deadline schedules"); + let completion = completer.clone(); + let completion_wins = Arc::clone(&wins); + let completion_barrier = Arc::clone(&barrier); + let complete = thread::spawn(move || { + completion_barrier.wait(); + if completion.finish(Ok(19)) { + completion_wins.fetch_add(1, Ordering::AcqRel); + } + }); + let expirer = thread::spawn(move || scheduler.expire_through(deadline)); + barrier.wait(); + complete.join().expect("completion race exits"); + expirer.join().expect("timeout race exits"); + + assert_eq!(wins.load(Ordering::Acquire), 1); + assert!(matches!( + waiter.wait(), + Ok(19) + | Err(MacosNativeTransactionError::TimedOut { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 7, + }) + )); + } + + #[test] + fn preselected_timeout_cannot_override_an_unpublished_success_claim() { + let scheduler = DeadlineScheduler::manual(); + let (completer, waiter) = fixture_transaction(); + let deadline = Instant::now() + Duration::from_secs(5); + completer + .arm(&scheduler, deadline, || {}) + .expect("fixture deadline schedules"); + let timeout_selected = Arc::new(Barrier::new(2)); + let selected = Arc::clone(&timeout_selected); + let resume_timeout = Arc::new(Barrier::new(2)); + let resume = Arc::clone(&resume_timeout); + let timeout_cell = Arc::clone(&completer.cell); + let timeout = thread::spawn(move || { + selected.wait(); + resume.wait(); + super::claim_timeout(&timeout_cell, None, None) + }); + timeout_selected.wait(); + + let settlement = completer.claim(Ok(19)).expect("success claims open cell"); + assert_eq!(scheduler.pending(), 0); + assert_eq!(waiter.try_outcome(), None); + resume_timeout.wait(); + assert!(!timeout.join().expect("preselected timeout exits")); + assert_eq!(waiter.try_outcome(), None); + + settlement.publish(); + assert_eq!(waiter.wait(), Ok(19)); + } + + #[test] + fn dropping_waiter_invokes_cancellation_once() { + let (completer, waiter) = fixture_transaction(); + let cancellations = Arc::new(AtomicU64::new(0)); + let cancellation_count = Arc::clone(&cancellations); + completer.set_cancel(move |_| { + cancellation_count.fetch_add(1, Ordering::AcqRel); + }); + + drop(waiter); + drop(completer); + + assert_eq!(cancellations.load(Ordering::Acquire), 1); + } + + #[test] + fn cancel_hook_receives_the_rekeyed_generation() { + let (completer, waiter) = fixture_transaction(); + let observed = Arc::new(AtomicU64::new(0)); + let observed_generation = Arc::clone(&observed); + completer.set_cancel(move |generation| { + observed_generation.store(generation, Ordering::Release); + }); + + assert!(completer.rekey_generation(43)); + assert_eq!(completer.identity().generation, 43); + + drop(waiter); + assert_eq!(observed.load(Ordering::Acquire), 43); + assert_eq!( + completer.outcome(), + Some(Err(MacosNativeTransactionError::Cancelled { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 43, + })) + ); + } + + #[test] + fn rekeying_retires_the_previous_generations_deadline() { + let scheduler = DeadlineScheduler::manual(); + let (completer, _waiter) = fixture_transaction(); + let deadline = Instant::now() + Duration::from_secs(5); + completer + .arm(&scheduler, deadline, || panic!("retired deadline fired")) + .expect("fixture deadline schedules"); + assert_eq!(scheduler.pending(), 1); + + assert!(completer.rekey_generation(43)); + + assert_eq!(scheduler.pending(), 0); + assert_eq!(completer.current_deadline(), None); + scheduler.expire_through(deadline); + assert!(completer.is_open()); + } + + #[test] + fn arm_for_a_superseded_generation_is_refused() { + let scheduler = DeadlineScheduler::manual(); + let (completer, _waiter) = fixture_transaction(); + assert!(completer.rekey_generation(43)); + + let stale = completer + .arm_for_generation( + &scheduler, + Instant::now() + Duration::from_secs(5), + 7, + MacosNativeTransactionPhase::FirstCompleteFrame, + || panic!("stale-generation deadline fired"), + ) + .expect("stale arm should decline without error"); + assert!( + !stale, + "an arm validated before a rekey must die at the cell" + ); + assert_eq!(scheduler.pending(), 0); + assert_eq!( + completer.identity().phase, + MacosNativeTransactionPhase::StreamStart, + "a refused arm must not mutate the phase" + ); + + let current = completer + .arm_for_generation( + &scheduler, + Instant::now() + Duration::from_secs(5), + 43, + MacosNativeTransactionPhase::FirstCompleteFrame, + || {}, + ) + .expect("current-generation arm should schedule"); + assert!(current); + assert_eq!(scheduler.pending(), 1); + assert_eq!( + completer.identity().phase, + MacosNativeTransactionPhase::FirstCompleteFrame + ); + } + + #[test] + fn rekeying_a_claimed_transaction_is_refused() { + let (completer, waiter) = fixture_transaction(); + assert!(completer.finish(Ok(11))); + assert!(!completer.rekey_generation(43)); + assert_eq!(completer.identity().generation, 7); + assert_eq!(waiter.wait(), Ok(11)); + } + + #[test] + fn dropping_the_last_completer_cancels_instead_of_stranding_the_waiter() { + let (completer, waiter) = fixture_transaction(); + let clone = completer.clone(); + + drop(completer); + assert_eq!(waiter.try_outcome(), None); + + drop(clone); + assert_eq!( + waiter.wait_outcome(), + Err(MacosNativeTransactionError::Cancelled { + phase: MacosNativeTransactionPhase::StreamStart, + generation: 7, + }) + ); + } + + #[test] + fn completer_drop_does_not_run_the_cancel_hook() { + let (completer, waiter) = fixture_transaction(); + let cancellations = Arc::new(AtomicU64::new(0)); + let cancellation_count = Arc::clone(&cancellations); + completer.set_cancel(move |_| { + cancellation_count.fetch_add(1, Ordering::AcqRel); + }); + + drop(completer); + + assert!(matches!( + waiter.wait_outcome(), + Err(MacosNativeTransactionError::Cancelled { .. }) + )); + assert_eq!(cancellations.load(Ordering::Acquire), 0); + } + + #[test] + fn rearming_replaces_the_prior_deadline_without_a_tombstone() { + let scheduler = DeadlineScheduler::manual(); + let (completer, _waiter) = fixture_transaction(); + let first = Instant::now() + Duration::from_secs(5); + let second = first + Duration::from_secs(5); + completer + .arm(&scheduler, first, || panic!("superseded deadline fired")) + .expect("first deadline schedules"); + completer + .arm(&scheduler, second, || {}) + .expect("second deadline schedules"); + + assert_eq!(completer.current_deadline(), Some(second)); + assert_eq!(scheduler.pending(), 1); + scheduler.expire_through(first); + assert!(completer.is_open()); + } +} diff --git a/crates/hypercolor-macos-capture/src/screenshot.rs b/crates/hypercolor-macos-capture/src/screenshot.rs new file mode 100644 index 000000000..0fed78799 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/screenshot.rs @@ -0,0 +1,715 @@ +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 { + 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: rgba8 is a fixed RGBA buffer retained until the + // context creates its immutable CGImage copy. + let context = unsafe { + CGBitmapContextCreate( + rgba8.as_mut_ptr().cast(), + usize::try_from(width).expect("fixture width fits usize"), + usize::try_from(height).expect("fixture height fits usize"), + 8, + bytes_per_row, + 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: 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), + }, + } + } +} + +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, + }, +} + +#[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; + +#[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); + } + + #[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( + 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 { .. } + )); + } +} diff --git a/crates/hypercolor-macos-capture/src/session.rs b/crates/hypercolor-macos-capture/src/session.rs new file mode 100644 index 000000000..c8a47d5fa --- /dev/null +++ b/crates/hypercolor-macos-capture/src/session.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use crate::{ + MacosCaptureCapabilities, MacosCaptureDynamicRange, MacosCaptureError, MacosStreamPreset, +}; + +#[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())), + }) + } + } + } + + #[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)] +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, + 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, + pub dynamic_range: MacosCaptureDynamicRange, +} + +impl MacosStreamRequest { + /// Resolve the strongest truthful production request for this host. + pub fn for_capabilities( + cadence: MacosCaptureCadence, + cursor_composed: bool, + capabilities: MacosCaptureCapabilities, + ) -> Result { + if capabilities.hdr_stream.is_present() { + Self::new_hdr(cadence, cursor_composed) + } else { + Self::new(cadence, cursor_composed) + } + } + + pub fn new( + cadence: MacosCaptureCadence, + cursor_composed: bool, + ) -> Result { + cadence.timescale()?; + 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 { + fn default() -> Self { + 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..490767712 --- /dev/null +++ b/crates/hypercolor-macos-capture/src/stream_contract.rs @@ -0,0 +1,613 @@ +use std::sync::Arc; + +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 screenshot_api: MacosRuntimeCapability, +} + +impl MacosTahoeCapabilities { + #[must_use] + pub const fn from_probes(probes: MacosTahoeRuntimeProbes) -> Self { + let screenshot_api = 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, + 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 + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Default)] +pub(crate) struct MacosTahoeSelectionCapabilityState { + current: Option, +} + +#[cfg(any(target_os = "macos", test))] +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, + 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", + ), + ) + } +} + +#[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/src/worker.rs b/crates/hypercolor-macos-capture/src/worker.rs new file mode 100644 index 000000000..6b9ecc8df --- /dev/null +++ b/crates/hypercolor-macos-capture/src/worker.rs @@ -0,0 +1,463 @@ +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; +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>, + pending_invalidations: Arc, +} + +pub(crate) struct SamplePublication { + pending_invalidations: Arc, +} + +struct PendingInvalidation<'a> { + pending: &'a AtomicU64, + ready: &'a Condvar, +} + +#[derive(Debug)] +struct LatestSampleInner { + state: Mutex>, + ready: Condvar, +} + +#[derive(Debug)] +struct LatestSampleState { + latest: Option>, + generation: u64, + closed: bool, +} + +#[derive(Debug)] +struct GenerationStamped { + generation: u64, + sample: T, +} + +pub(crate) struct LatestSampleWorker { + input: LatestSampleInput, + worker: Option>, +} + +impl Clone for LatestSampleInput { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + pending_invalidations: Arc::clone(&self.pending_invalidations), + } + } +} + +impl SamplePublication { + pub(crate) fn is_current(&self) -> bool { + self.pending_invalidations.load(Ordering::Acquire) == 0 + } +} + +impl<'a> PendingInvalidation<'a> { + fn begin(pending: &'a AtomicU64, ready: &'a Condvar) -> Self { + pending.fetch_add(1, Ordering::AcqRel); + Self { pending, ready } + } +} + +impl Drop for PendingInvalidation<'_> { + fn drop(&mut self) { + self.pending.fetch_sub(1, Ordering::AcqRel); + self.ready.notify_all(); + } +} + +impl LatestSampleInput { + fn new() -> Self { + Self { + inner: Arc::new(LatestSampleInner { + state: Mutex::new(LatestSampleState { + latest: None, + generation: 0, + closed: false, + }), + ready: Condvar::new(), + }), + pending_invalidations: Arc::new(AtomicU64::new(0)), + } + } + + pub(crate) fn publish(&self, sample: T) -> SamplePublishOutcome { + let mut state = self.lock(); + if state.closed { + return SamplePublishOutcome::Closed; + } + let generation = state.generation; + let outcome = if state + .latest + .replace(GenerationStamped { generation, sample }) + .is_some() + { + SamplePublishOutcome::Superseded + } else { + SamplePublishOutcome::Accepted + }; + drop(state); + self.inner.ready.notify_one(); + outcome + } + + fn close(&self) { + let mut state = self.lock(); + Self::advance_generation(&mut state); + state.closed = true; + drop(state); + self.inner.ready.notify_all(); + } + + pub(crate) fn invalidate_if(&self, invalidate: impl FnOnce() -> bool) -> bool { + self.invalidate_if_with(|| {}, invalidate) + } + + fn invalidate_if_with( + &self, + requested: impl FnOnce(), + invalidate: impl FnOnce() -> bool, + ) -> bool { + let pending = PendingInvalidation::begin(&self.pending_invalidations, &self.inner.ready); + requested(); + let mut state = self.lock(); + let invalidated = !state.closed && invalidate(); + if invalidated { + Self::advance_generation(&mut state); + } + drop(state); + drop(pending); + invalidated + } + + #[cfg(test)] + pub(crate) fn invalidate_if_observed( + &self, + requested: impl FnOnce(), + invalidate: impl FnOnce() -> bool, + ) -> bool { + self.invalidate_if_with(requested, invalidate) + } + + pub(crate) fn synchronize_if(&self, synchronize: impl FnOnce() -> bool) -> bool { + let state = self.lock(); + let state = self + .inner + .ready + .wait_while(state, |_| { + self.pending_invalidations.load(Ordering::Acquire) != 0 + }) + .unwrap_or_else(std::sync::PoisonError::into_inner); + !state.closed && synchronize() + } + + #[cfg(test)] + pub(crate) fn generation(&self) -> u64 { + self.lock().generation + } + + 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 publish_if_current(&self, generation: u64, publish: impl FnOnce(SamplePublication)) { + let state = self.lock(); + if !state.closed && state.generation == generation { + publish(SamplePublication { + pending_invalidations: Arc::clone(&self.pending_invalidations), + }); + } + } + + fn advance_generation(state: &mut LatestSampleState) { + state.generation = state + .generation + .checked_add(1) + .expect("macOS decode generation must remain monotonic"); + state.latest = None; + } + + 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, SamplePublication) + 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(stamped) = worker_input.take_next() { + let decoded = decode(stamped.sample); + worker_input.publish_if_current(stamped.generation, |publication| { + publish(decoded, publication); + }); + } + })?; + 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, _publication| { + 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, _publication| { + 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 blocked_pre_suspend_decode_cannot_publish_after_restart_generation() { + 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-generation-fence-test", + move |sample| { + if first { + first = false; + decode_started_tx + .send(()) + .expect("blocked decode should be observable"); + release_decode_rx.recv().expect("decode should resume"); + } + sample + }, + move |sample, _publication| { + published_tx + .send(sample) + .expect("current generation 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 the old generation"); + assert!(input.invalidate_if(|| true)); + assert!(input.invalidate_if(|| true)); + release_decode_tx + .send(()) + .expect("old generation decode should resume"); + assert_eq!( + published_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ); + + assert_eq!(input.publish(2), SamplePublishOutcome::Accepted); + assert_eq!( + published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("new generation should publish"), + 2 + ); + worker.close(); + worker.join().expect("worker should join"); + } + + #[test] + fn rejected_invalidation_does_not_advance_the_decode_generation() { + let (decode_started_tx, decode_started_rx) = mpsc::sync_channel(1); + let (release_decode_tx, release_decode_rx) = mpsc::sync_channel(1); + let (published_tx, published_rx) = mpsc::sync_channel(1); + let mut worker = LatestSampleWorker::spawn( + "macos-capture-rejected-invalidation-test", + move |sample| { + decode_started_tx + .send(()) + .expect("decode should be observable"); + release_decode_rx.recv().expect("decode should resume"); + sample + }, + move |sample, _publication| { + published_tx + .send(sample) + .expect("unchanged generation 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("decode should start"); + assert!(!input.invalidate_if(|| false)); + release_decode_tx.send(()).expect("decode should resume"); + assert_eq!( + published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("rejected invalidation must retain the generation"), + 1 + ); + + 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 |_, _publication| { + 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); + } +} 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..af803325a --- /dev/null +++ b/crates/hypercolor-macos-capture/tests/capture_contract_tests.rs @@ -0,0 +1,1580 @@ +use std::sync::Arc; + +use hypercolor_macos_capture::{ + MACOS_STREAM_QUEUE_DEPTH, MacosAttachment, MacosCaptureCadence, + MacosCaptureCallbackDiagnostics, MacosCaptureCapabilities, MacosCaptureColorimetry, + MacosCaptureDynamicRange, MacosCaptureError, MacosCapturePixelFormat, MacosCaptureSelector, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + MacosConfiguredStream, MacosDeliveredFrameMetadata, MacosDisplayClock, MacosDisplayClockError, + MacosFrameDecoder, MacosFrameDropReason, MacosFrameEvent, MacosFrameMailbox, MacosFrameStatus, + MacosGeometryError, MacosHostArchitecture, MacosPixelExtent, MacosPixelRect, MacosPointRect, + MacosRawCapturePlane, MacosRawCaptureSample, MacosRawCompleteFrame, MacosRawFrameAttachments, + MacosRuntimeCapability, MacosScale, MacosStreamDeliveryRejection, MacosStreamDeliveryState, + MacosStreamDeliveryValidator, MacosStreamPreset, MacosStreamRequest, MacosTahoeRuntimeProbes, + MacosTransferFunction, MacosYuvMatrix, +}; +use std::time::{Duration, Instant}; + +const BGRA8: u32 = 0x4247_5241; +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; +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"); +} + +#[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); +} + +#[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) + ); + 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 production_requests_use_canonical_hdr_on_apple_silicon_and_sdr_on_intel() { + let probes = MacosTahoeRuntimeProbes { + content_tone_mapping_info_symbol: MacosRuntimeCapability::Present, + screenshot_configuration_class: MacosRuntimeCapability::Present, + screenshot_dynamic_range_selector: MacosRuntimeCapability::Present, + screenshot_capture_selector: MacosRuntimeCapability::Present, + }; + let apple_silicon = + MacosCaptureCapabilities::from_runtime(MacosHostArchitecture::AppleSilicon, false, probes); + let apple_request = MacosStreamRequest::for_capabilities( + MacosCaptureCadence::NativeRefresh, + false, + apple_silicon, + ) + .expect("Apple Silicon production request is representable"); + assert_eq!(apple_request.dynamic_range, MacosCaptureDynamicRange::Hdr); + assert_eq!( + apple_request.preset(), + MacosStreamPreset::CaptureHdrStreamCanonicalDisplay + ); + + let intel = MacosCaptureCapabilities::from_runtime(MacosHostArchitecture::Intel, false, probes); + let intel_request = + MacosStreamRequest::for_capabilities(MacosCaptureCadence::FramesPerSecond(60), true, intel) + .expect("Intel production request is representable"); + assert_eq!(intel_request.dynamic_range, MacosCaptureDynamicRange::Sdr); + assert_eq!(intel_request.preset(), MacosStreamPreset::SdrDefault); +} + +#[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 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 { + 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.screenshot_api, + 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.screenshot_api, + MacosRuntimeCapability::Absent + ); +} + +#[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() + )) + ); + + 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] +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 malformed_dirty_rects_degrade_to_full_content_damage() { + let mut sample = complete_sample(); + sample.attachments.dirty_rects = MacosAttachment::Malformed; + let frame = decode_frame(&mut MacosFrameDecoder::new(1), sample.clone()); + let expected = decode_frame(&mut MacosFrameDecoder::new(1), { + let mut missing = sample; + missing.attachments.dirty_rects = MacosAttachment::Missing; + missing + }); + assert_eq!(frame.damage, expected.damage); +} + +#[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(), Some(99)); + drop(frame); + 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 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 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 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!( + malformed.copy_source_rgba32f_to(&mut untouched, 128), + Err(MacosCaptureError::CpuPlaneLayoutMismatch) + ); + assert_eq!(untouched, [0x5a; 768]); +} + +#[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 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 mailbox_terminal_control_survives_latest_frame_pressure() { + let mailbox = MacosFrameMailbox::new(); + for sequence in 0..64 { + let mut frame = bgra_cpu_frame([sequence, 0, 0, 255], rgb_color()); + frame.sequence = u64::from(sequence); + mailbox.publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); + } + mailbox.publish(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Suspended))); + for sequence in 64..128 { + let mut frame = bgra_cpu_frame([sequence, 0, 0, 255], rgb_color()); + frame.sequence = u64::from(sequence); + mailbox.publish(Ok(MacosFrameEvent::Frame(Box::new(frame)))); + } + + let (_, invalidation_generation, terminal) = mailbox + .take_latest_with_generation() + .expect("terminal control remains pending"); + assert_eq!(invalidation_generation, 1); + assert!(matches!( + terminal, + Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Suspended)) + )); + let (_, frame_generation, latest_frame) = mailbox + .take_latest_with_generation() + .expect("latest frame remains independently pending"); + assert_eq!(frame_generation, invalidation_generation); + assert!(matches!( + latest_frame, + Ok(MacosFrameEvent::Frame(frame)) if frame.sequence == 127 + )); + assert!(!mailbox.has_pending()); +} + +#[test] +fn mailbox_recoverable_diagnostic_retains_control_and_last_good_frame() { + let mailbox = MacosFrameMailbox::new(); + mailbox.publish(Ok(MacosFrameEvent::Frame(Box::new(bgra_cpu_frame( + [3, 2, 1, 255], + rgb_color(), + ))))); + mailbox.publish(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))); + mailbox.publish(Ok(MacosFrameEvent::RecoverableError(Box::new( + MacosCaptureError::CaptureWorkerStartFailed("recoverable fixture".to_owned()), + )))); + + assert!(matches!( + mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Lifecycle(MacosFrameStatus::Started))) + )); + assert!(matches!( + mailbox.take_latest(), + Some(Ok(MacosFrameEvent::Frame(_))) + )); + assert!(matches!( + mailbox.take_latest(), + Some(Ok(MacosFrameEvent::RecoverableError(_))) + )); +} + +#[test] +fn mailbox_frame_and_diagnostic_revision_order_preserves_newest_health() { + fn drain_health(mailbox: &MacosFrameMailbox) -> &'static str { + let mut health = "unknown"; + while let Some(delivery) = mailbox.take_latest() { + match delivery { + Ok(MacosFrameEvent::Frame(_)) => health = "healthy", + Ok(MacosFrameEvent::RecoverableError(_)) => health = "recovering", + Ok(MacosFrameEvent::Lifecycle(_)) | Err(_) => {} + } + } + health + } + + let newer_frame = MacosFrameMailbox::new(); + newer_frame.publish(Ok(MacosFrameEvent::RecoverableError(Box::new( + MacosCaptureError::CaptureWorkerStartFailed("older diagnostic".to_owned()), + )))); + newer_frame.publish(Ok(MacosFrameEvent::Frame(Box::new(bgra_cpu_frame( + [4, 3, 2, 255], + rgb_color(), + ))))); + assert_eq!(drain_health(&newer_frame), "healthy"); + + let newer_diagnostic = MacosFrameMailbox::new(); + newer_diagnostic.publish(Ok(MacosFrameEvent::Frame(Box::new(bgra_cpu_frame( + [2, 3, 4, 255], + rgb_color(), + ))))); + newer_diagnostic.publish(Ok(MacosFrameEvent::RecoverableError(Box::new( + MacosCaptureError::CaptureWorkerStartFailed("newer diagnostic".to_owned()), + )))); + assert_eq!(drain_health(&newer_diagnostic), "recovering"); +} + +#[test] +fn mailbox_terminal_deliveries_advance_ordered_invalidation_generations() { + let mailbox = MacosFrameMailbox::new(); + for message in ["first fatal callback", "duplicate fatal callback"] { + mailbox.publish(Err(MacosCaptureError::CaptureWorkerStartFailed( + message.to_owned(), + ))); + } + + let (_, invalidation_generation, fatal) = mailbox + .take_latest_with_generation() + .expect("fatal control remains pending"); + assert_eq!(invalidation_generation, 2); + assert!(fatal.is_err()); + assert!(!mailbox.has_pending()); +} + +#[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(); + 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); + } +} + +#[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); + 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 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 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") +} + +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:?}"), + MacosFrameEvent::RecoverableError(error) => { + panic!("expected frame, got recoverable error: {error}") + } + } +} + +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 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, + 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") +} + +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") +} diff --git a/crates/hypercolor-macos-gpu-interop/Cargo.toml b/crates/hypercolor-macos-gpu-interop/Cargo.toml index b4bac9660..11e47d25a 100644 --- a/crates/hypercolor-macos-gpu-interop/Cargo.toml +++ b/crates/hypercolor-macos-gpu-interop/Cargo.toml @@ -10,32 +10,100 @@ description = "macOS IOSurface/Metal texture import boundary for Hypercolor" [features] default = [] +screen-capture = ["dep:hypercolor-macos-capture", "dep:objc2-core-video"] +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] +hypercolor-macos-capture = { workspace = true, optional = true } 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-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 } -surfman = { workspace = true } -tracing = { workspace = true } -webrender_api = { workspace = true } +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", + "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 } +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]] +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"] + +[[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..561962164 --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/examples/bench_macos_reduction.rs @@ -0,0 +1,2071 @@ +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 = 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 { + 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", MIN_ITERATIONS, MAX_ITERATIONS)?; + } + "--warmup" => { + parsed.warmup = parse_bounded_usize(&value, "warmup", MIN_WARMUP, 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>; 8], + }, + 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() { + 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 { + 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) +} + +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() { + 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::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, + MacosCaptureSurface, MacosColorPrimaries, MacosColorRange, MacosPixelExtent, + MacosPixelRect, MacosPointRect, MacosScale, MacosTransferFunction, + }; + use hypercolor_macos_gpu_interop::{ + MacosMetal4CapabilityProbe, MacosNativeReducer, MacosNativeReductionDescriptor, + 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, + } + + 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 wgpu = WgpuFixture::new()?; + 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| 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, + args.output.width, + args.output.height, + MacosNativeTargetFormat::Rgba8, + ) + .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) + .map_err(|error| Metal4RunError::new(Metal4Failure::Warmup, error))?; + reduce_wgpu( + &wgpu.device, + &wgpu.queue, + &reducer, + &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) + }) + .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) + .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(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) + .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> { + 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_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, + 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, + wgpu: &WgpuMeasurement, + metal4: Metal4Report, + ) { + 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_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"); + 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 { + _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)) + } + + 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!( + parse(&[ + "bench", + "--source", + "3840x2160", + "--output", + "640x480", + "--filter", + "bilinear", + "--iterations", + "100", + "--warmup", + "10", + ]), + Ok(Args { + source: Extent { + width: 3840, + height: 2160, + }, + output: Extent { + width: 640, + height: 480, + }, + filter: Filter::Bilinear, + iterations: 100, + warmup: 10, + }) + ); + } + + #[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", "--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()); + assert!( + parse(&[ + "bench", + "--source", + "1x1", + "--output", + "1x1", + "--filter", + "area", + "--iterations", + "100", + "--warmup", + "10", + "--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()); + 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 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 + }, + Metal4Evaluation::NotRun, + ), + Metal4Decision::NotQualified { + 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/lib.rs b/crates/hypercolor-macos-gpu-interop/src/lib.rs index 2a6632f4f..e829647b0 100644 --- a/crates/hypercolor-macos-gpu-interop/src/lib.rs +++ b/crates/hypercolor-macos-gpu-interop/src/lib.rs @@ -1,17 +1,25 @@ #![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(target_os = "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; #[cfg(not(target_os = "macos"))] mod stubs; #[cfg(target_os = "macos")] pub use macos::*; -#[cfg(target_os = "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::*; #[cfg(not(target_os = "macos"))] pub use stubs::*; diff --git a/crates/hypercolor-macos-gpu-interop/src/macos.rs b/crates/hypercolor-macos-gpu-interop/src/macos.rs index 734e751b8..9f93845b0 100644 --- a/crates/hypercolor-macos-gpu-interop/src/macos.rs +++ b/crates/hypercolor-macos-gpu-interop/src/macos.rs @@ -4,21 +4,29 @@ 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, 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, kIOSurfaceHeight, kIOSurfacePixelFormat, kIOSurfaceWidth, }; use objc2_metal::{ - MTLDevice, MTLPixelFormat, MTLStorageMode, MTLTextureDescriptor, MTLTextureType, - MTLTextureUsage, + MTL4CommitOptions, MTLCreateSystemDefaultDevice, MTLDevice, MTLGPUFamily, MTLPixelFormat, + MTLResource, MTLStorageMode, MTLTexture, MTLTextureDescriptor, MTLTextureType, MTLTextureUsage, }; 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,13 +35,162 @@ 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; +/// 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 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. +#[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] + pub const fn all_required_facilities(self) -> bool { + self.metal4_family + && 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>; 8] { + [ + 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.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") + }, + ] + } +} + +/// 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(); + 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, + command_allocator: raw_device.respondsToSelector(sel!(newCommandAllocator)), + 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, + }) +} + +/// 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, @@ -75,6 +232,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,17 +302,138 @@ 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, + + /// 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 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. +#[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. 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 { @@ -157,18 +442,38 @@ 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, } } } /// 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, @@ -183,7 +488,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 }) @@ -231,6 +536,37 @@ pub struct ImportedFrameTimings { struct CachedIosurfaceWrap { texture: Arc, view: Arc, + capture_owner: Option, +} + +#[derive(Clone)] +pub(crate) struct MacosCaptureCacheOwner { + _owner: Arc, +} + +impl MacosCaptureCacheOwner { + pub(crate) fn new(owner: Arc) -> Self + where + T: Send + Sync + 'static, + { + Self { _owner: owner } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct IosurfaceWrapKey { + capture_session_generation: u64, + resource_generation: u64, + surface_id: u32, + plane: usize, + width: u32, + height: u32, + bytes_per_row: usize, + source_pixel_format: u32, + allocation_bytes: u64, + format: ImportedFrameFormat, + storage_mode: MacosMetalStorageMode, + metal_registry_id: u64, } /// Reusable importer for wrapping IOSurfaces as wgpu textures. @@ -240,7 +576,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 +589,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 +604,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 { @@ -281,11 +633,76 @@ impl MacosIosurfaceImporter { iosurface: &IOSurfaceRef, content_generation: u64, ) -> Result { - validate_iosurface_shape(self.descriptor, iosurface)?; + 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 { + self.import_iosurface_plane_scoped( + device, + iosurface, + content_generation, + capture_session_generation, + resource_generation, + 0, + PIXEL_FORMAT_BGRA as u32, + None, + ) + } + + #[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, + capture_owner: Option<&MacosCaptureCacheOwner>, + ) -> 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 { + 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 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, + width: self.descriptor.width, + height: self.descriptor.height, + 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, + }; + if let Some(cached) = self.wraps.get_mut(&cache_key) { + if let Some(capture_owner) = capture_owner { + cached.capture_owner = Some(capture_owner.clone()); + } return Ok(ImportedEffectFrame { width: self.descriptor.width, height: self.descriptor.height, @@ -303,12 +720,21 @@ 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) + .newTextureWithDescriptor_iosurface_plane(&descriptor, iosurface, plane) .ok_or(MacosGpuInteropError::MetalTextureCreateFailed)? }; + 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); @@ -343,10 +769,11 @@ impl MacosIosurfaceImporter { self.wraps.clear(); } self.wraps.insert( - surface_id, + cache_key, CachedIosurfaceWrap { texture: Arc::clone(&texture), view: Arc::clone(&view), + capture_owner: capture_owner.cloned(), }, ); @@ -396,7 +823,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, @@ -406,11 +833,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 @@ -426,7 +854,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 { [ @@ -440,7 +868,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), ]; @@ -471,6 +899,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. @@ -483,8 +912,8 @@ fn metal_texture_descriptor( ) }; texture_descriptor.setTextureType(MTLTextureType::Type2D); - texture_descriptor.setUsage(MTLTextureUsage::ShaderRead | MTLTextureUsage::RenderTarget); - texture_descriptor.setStorageMode(MTLStorageMode::Shared); + texture_descriptor.setUsage(MTLTextureUsage::ShaderRead); + texture_descriptor.setStorageMode(storage_mode.native()); texture_descriptor } @@ -502,9 +931,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: &[], } } @@ -512,21 +939,127 @@ 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(iosurface: &IOSurfaceRef, expected: u32) -> Result<()> { + 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_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 { + 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, + }); + } + 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)> { + 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 + '_> { @@ -536,6 +1069,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()) }; @@ -594,3 +1342,60 @@ 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)); + } + + #[test] + fn capture_cache_owner_lives_until_explicit_cache_clear() { + let external = Arc::new(()); + let retained = Arc::downgrade(&external); + let mut cache = HashMap::from([(1_u8, MacosCaptureCacheOwner::new(external))]); + + assert!(retained.upgrade().is_some()); + cache.clear(); + assert!(retained.upgrade().is_none()); + } + + #[test] + fn cache_reimport_replaces_owner_without_losing_live_ownership() { + let first = Arc::new(()); + let first_retained = Arc::downgrade(&first); + let second = Arc::new(()); + let second_retained = Arc::downgrade(&second); + let mut cached = MacosCaptureCacheOwner::new(first); + + assert_eq!(Arc::strong_count(&cached._owner), 1); + cached = MacosCaptureCacheOwner::new(second); + + assert!(first_retained.upgrade().is_none()); + assert!(second_retained.upgrade().is_some()); + drop(cached); + assert!(second_retained.upgrade().is_none()); + } +} 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 new file mode 100644 index 000000000..a34b8ec35 --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/src/screen_capture.rs @@ -0,0 +1,1203 @@ +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, MacosCaptureCacheOwner, MacosGpuInteropError, + MacosIosurfaceImportDescriptor, 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)] +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 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. + 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 { + content_sequence: u64, + capture: Arc, + 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, +} + +#[derive(Clone)] +struct CachedMacosScreenPlane { + plane: ImportedMacosScreenPlane, + capture_owner: MacosCaptureCacheOwner, +} + +// 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, + /// 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 { + 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 }) + } + 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 }) + } + } + } + + 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, + 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. + /// + /// Packed RGB frames have exactly one plane. Multi-plane callers should + /// inspect [`Self::planes`] instead. + #[must_use] + pub fn storage_identity(&self) -> MacosScreenStorageIdentity { + self.first_plane().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 + } + + /// 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) -> 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, + } + } + + /// Whether Core Video owns the retained Metal texture wrapper. + #[must_use] + 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 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), + /// 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>, + 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()), + }) + } + + /// 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 every directly representable plane without a full-frame CPU copy. + pub fn import_frame( + &self, + 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)?; + 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), + }), + } + }) + .map_err(|error| MacosScreenBridgeError::SurfaceHandoff(error.to_string()))??; + + Ok(ImportedMacosScreenFrame { + content_sequence: frame.sequence, + capture: frame, + planes: imported_planes.into(), + }) + } + + /// 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, + candidate: MacosScreenImporterCandidate, + device: &wgpu::Device, + iosurface: &IOSurfaceRef, + pixel_buffer: &CVPixelBuffer, + frame: &Arc, + 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: &Arc, + resource_generation: u64, + source_pixel_format: u32, + descriptors: &[CapturePlaneImportDescriptor], + ) -> Result, MacosScreenBridgeError> { + let capture_owner = MacosCaptureCacheOwner::new(Arc::clone(frame)); + 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, + Some(&capture_owner), + )?; + ImportedMacosScreenPlane { + storage_identity, + format: ImportedMacosScreenPlaneFormat::Wgpu(descriptor.format), + storage: ImportedMacosScreenPlaneStorage::Wgpu(imported), + core_video_wrapper: None, + } + } + CapturePlaneImportDescriptor::Bgr10A2Unorm { width, height } + | CapturePlaneImportDescriptor::R16Unorm { width, height } + | CapturePlaneImportDescriptor::Rg16Unorm { width, height } => self + .native_wrapper_or_insert(storage_identity, &capture_owner, || { + let (format, metal_format) = native_plane_format(*descriptor); + let texture = import_iosurface_metal_texture_plane( + device, + iosurface, + *width, + *height, + plane_index, + source_pixel_format, + metal_format, + self.storage_mode, + )?; + Ok(ImportedMacosScreenPlane { + storage_identity, + format, + 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: &Arc, + resource_generation: u64, + source_pixel_format: u32, + descriptors: &[CapturePlaneImportDescriptor], + ) -> Result, MacosScreenBridgeError> { + let capture_owner = MacosCaptureCacheOwner::new(Arc::clone(frame)); + 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_mut(&storage_identity) { + cached.capture_owner = capture_owner.clone(); + imported_planes.push(cached.plane.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 } + | 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, + metal_format, + frame.surface.iosurface_id, + self.storage_mode, + )?; + ImportedMacosScreenPlane { + storage_identity, + format, + 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, + CachedMacosScreenPlane { + plane: imported_plane.clone(), + capture_owner: capture_owner.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 { + let direct = self + .importers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .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(), + ) + } + + /// Release every cached wrapper and its retained capture owner. + pub fn clear_capture_caches(&self) { + self.importers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + self.native_wrappers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + self.flush_core_video_cache(); + } + + fn native_wrapper_or_insert( + &self, + identity: MacosScreenStorageIdentity, + capture_owner: &MacosCaptureCacheOwner, + 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_mut(&identity) { + cached.capture_owner = capture_owner.clone(); + return Ok(cached.plane.clone()); + } + let plane = create()?; + let flush_core_video = wrappers.len() >= MAX_CORE_VIDEO_WRAPPERS; + if flush_core_video { + wrappers.clear(); + } + wrappers.insert( + identity, + CachedMacosScreenPlane { + plane: plane.clone(), + capture_owner: capture_owner.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_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> { + if frame.epoch == 0 || resource_generation == 0 { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture and resource generations must be nonzero", + )); + } + let expected_formats = capture_plane_formats(frame.pixel_format)?; + if frame.planes.len() != expected_formats.len() { + return Err(MacosScreenBridgeError::InvalidFrame( + "capture plane count does not match its pixel format", + )); + } + 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::R16Unorm, + ImportedMacosScreenPlaneFormat::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), + } +} + +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, + plane: usize, +) -> MacosPixelExtent { + if matches!( + pixel_format, + MacosCapturePixelFormat::Yuv420VideoRange | MacosCapturePixelFormat::Yuv420FullRange + ) && plane == 1 + { + MacosPixelExtent { + width: storage_extent.width.div_ceil(2), + height: storage_extent.height.div_ceil(2), + } + } else { + storage_extent + } +} + +fn validate_native_surface( + iosurface: &IOSurfaceRef, + frame: &MacosCaptureFrame, +) -> Result<(), MacosScreenBridgeError> { + let allocation_bytes = u64::try_from(iosurface.alloc_size()) + .map_err(|_| MacosScreenBridgeError::InvalidFrame("IOSurface allocation exceeds u64"))?; + 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::R16Unorm, + ImportedMacosScreenPlaneFormat::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 1d2d7fd84..8b5cdf46f 100644 --- a/crates/hypercolor-macos-gpu-interop/src/stubs.rs +++ b/crates/hypercolor-macos-gpu-interop/src/stubs.rs @@ -2,11 +2,101 @@ 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; +/// 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 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 { + /// 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.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>; 8] { + [ + 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.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") + }, + ] + } +} + +/// 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] @@ -34,12 +124,31 @@ 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. 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 { @@ -48,12 +157,27 @@ 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, } } } /// 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, @@ -68,7 +192,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 }) @@ -115,6 +239,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 +259,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/descriptor_tests.rs b/crates/hypercolor-macos-gpu-interop/tests/descriptor_tests.rs index 46b20ca65..e23566e6c 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,63 @@ 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, + argument_table: true, + residency_set: true, + shared_event: true, + commit_feedback: true, + }; + assert!(complete.all_required_facilities()); + assert_eq!(complete.missing_facilities(), [None; 8]); + + 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, + 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"), + ] + ); +} + #[test] fn descriptor_rejects_iosurface_row_shapes_that_exceed_cfnumber_i32() { let width = i32::MAX as u32 / 4 + 1; @@ -30,3 +88,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/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())?; 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..8f2255225 --- /dev/null +++ b/crates/hypercolor-macos-gpu-interop/tests/screen_capture_bridge_tests.rs @@ -0,0 +1,712 @@ +#![cfg(target_os = "macos")] + +use std::sync::{Arc, mpsc}; + +use hypercolor_macos_capture::{ + MacosCaptureColorimetry, MacosCaptureFrame, MacosCaptureGeometry, MacosCapturePixelFormat, + MacosCaptureSurface, MacosChromaLocation, MacosColorPrimaries, MacosColorRange, + 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, + MacosNativeTargetFormat, 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(), 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 + ); + assert_eq!( + first.storage_identity().bytes_per_row, + frame.planes[0].bytes_per_row + ); + assert!(Arc::ptr_eq(first.capture(), &frame)); + 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,)?, + fixture_pixels() + ); + + let next_resource = bridge + .import_bgra_frame(&wgpu.device, 12, Arc::clone(&frame)) + .map_err(|error| error.to_string())?; + 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); + assert_eq!(Arc::strong_count(first.capture()), 5); + bridge.clear_capture_caches(); + assert_eq!(bridge.cached_wrap_count(), 0); + assert_eq!(Arc::strong_count(first.capture()), 3); + assert_eq!(first.capture().surface.retained_owner_count(), 1); + 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()?; + 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(); + let (surface, plane) = MacosCaptureSurface::new_native_bgra_fixture(extent, &pixels) + .map_err(|error| error.to_string())?; + Ok(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).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, + }) +} + +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, + 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) +} diff --git a/crates/hypercolor-macos-input/Cargo.toml b/crates/hypercolor-macos-input/Cargo.toml new file mode 100644 index 000000000..39c9476fc --- /dev/null +++ b/crates/hypercolor-macos-input/Cargo.toml @@ -0,0 +1,42 @@ +[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] +crossbeam-queue = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { workspace = true, features = ["std"] } +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", + "libc", +] } +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..c8ae96896 --- /dev/null +++ b/crates/hypercolor-macos-input/examples/dump_macos_input.rs @@ -0,0 +1,367 @@ +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| match sender.try_send(owned_batch(batch)) { + Ok(()) => hypercolor_macos_input::MacosInputPublicationOutcome::Published, + Err(mpsc::TrySendError::Full(_)) => { + callback_delivery_drops.fetch_add(1, Ordering::Relaxed); + hypercolor_macos_input::MacosInputPublicationOutcome::Rejected + } + Err(mpsc::TrySendError::Disconnected(_)) => { + hypercolor_macos_input::MacosInputPublicationOutcome::Rejected + } + }, + ) + .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={} callback_to_publication_samples={} callback_to_publication_p95_ns={} callback_to_publication_p99_ns={} callback_to_publication_max_ns={}", + 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, + diagnostics.callback_to_publication_sample_count, + diagnostics.callback_to_publication_p95_ns, + diagnostics.callback_to_publication_p99_ns, + diagnostics.callback_to_publication_max_ns, + ); + 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..eca9881eb --- /dev/null +++ b/crates/hypercolor-macos-input/examples/probe_macos_tcc_owner.rs @@ -0,0 +1,489 @@ +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, +} + +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", + } + } +} + +#[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), + _ => Err(format!( + "unknown topology {value:?}; expected app-sidecar, direct-launchd, homebrew, or standalone" + )), + } +} + +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, or standalone.\n\ + --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!(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") + .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")); + } +} 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..2bfea20c6 --- /dev/null +++ b/crates/hypercolor-macos-input/src/lib.rs @@ -0,0 +1,38 @@ +//! 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 process; +mod queue; +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 process::current_process_audit_token_identity; +pub use shared::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputEvent, MacosInputGapReason, MacosInputPublicationOutcome, MacosInputResult, + MacosMediaKey, MacosModifierFlags, MacosPointerButton, MacosScrollPhase, MacosScrollUnit, + MacosVirtualDesktop, MacosWorkerDegradation, MacosWorkerState, +}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::{ + MacosInputSession, current_virtual_desktop, input_monitoring_granted, request_input_monitoring, + secure_event_input_enabled, +}; + +#[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, + secure_event_input_enabled, +}; diff --git a/crates/hypercolor-macos-input/src/macos.rs b/crates/hypercolor-macos-input/src/macos.rs new file mode 100644 index 000000000..48e56ddc2 --- /dev/null +++ b/crates/hypercolor-macos-input/src/macos.rs @@ -0,0 +1,921 @@ +use std::ffi::c_void; +use std::mem::MaybeUninit; +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, CFRunLoopSourceContext, + kCFRunLoopCommonModes, +}; +use objc2_core_graphics::{ + CGDirectDisplayID, CGDisplayBounds, CGError, CGEvent, CGEventField, CGEventTapInformation, + CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, + CGGetActiveDisplayList, CGGetEventTapList, CGGetOnlineDisplayList, + CGPreflightListenEventAccess, CGRequestListenEventAccess, +}; + +use crate::queue::{DEFAULT_QUEUE_CAPACITY, EventQueue}; +use crate::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputEvent, MacosInputGapReason, MacosInputPublicationOutcome, MacosInputResult, + MacosModifierFlags, MacosScrollPhase, 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); +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", + } + } +} + +#[derive(Clone, Copy, Default)] +struct RunLoopHandles { + run_loop: usize, + stop_source: usize, +} + +struct RunLoopControl { + stopping: AtomicBool, + handles: Mutex, +} + +impl RunLoopControl { + fn new() -> Self { + Self { + stopping: AtomicBool::new(false), + handles: Mutex::new(RunLoopHandles::default()), + } + } + + fn install(&self, run_loop: &CFRunLoop, stop_source: &CFRunLoopSource) { + *self + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = RunLoopHandles { + run_loop: std::ptr::from_ref(run_loop).expose_provenance(), + stop_source: std::ptr::from_ref(stop_source).expose_provenance(), + }; + } + + fn clear(&self) { + *self + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = RunLoopHandles::default(); + } + + fn request_stop(&self) { + self.stopping.store(true, Ordering::Release); + let handles = self + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if handles.run_loop == 0 { + return; + } + // SAFETY: the run-loop worker owns the retained objects and clears + // these addresses under the same mutex before releasing them. Core + // Foundation permits stopping and waking a run loop from another + // thread. + let run_loop = + unsafe { &*std::ptr::with_exposed_provenance::(handles.run_loop) }; + if handles.stop_source != 0 { + // Signaling the dedicated stop source closes the startup race: + // a `CFRunLoopStop` that lands after the worker's stopping + // check but before `CFRunLoopRun` begins is a no-op, while a + // signaled source is serviced as soon as the loop enters and + // its perform callback stops the loop from inside. + // SAFETY: same ownership discipline as the run-loop address. + let stop_source = unsafe { + &*std::ptr::with_exposed_provenance::(handles.stop_source) + }; + stop_source.signal(); + } + run_loop.stop(); + run_loop.wake_up(); + } +} + +unsafe extern "C-unwind" fn stop_run_loop_perform(_info: *mut c_void) { + if let Some(run_loop) = CFRunLoop::current() { + run_loop.stop(); + } +} + +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<'_>) -> MacosInputPublicationOutcome + 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 + } + + /// Return the event masks Core Graphics reports for this process's + /// installed listen-only session taps. + pub fn installed_masks(&self) -> MacosInputResult { + let mut count = 0; + // SAFETY: the count-only form accepts a null list and writes one u32. + let result = unsafe { CGGetEventTapList(0, std::ptr::null_mut(), &mut count) }; + if result != CGError::Success { + return Err(MacosInputError::TapInspection(result.0)); + } + let capacity = usize::try_from(count).map_err(|_| MacosInputError::TapInspection(-1))?; + let mut taps = vec![MaybeUninit::::uninit(); capacity]; + let mut written = count; + // SAFETY: `taps` has room for `count` records and Core Graphics writes + // at most that many initialized records, reported through `written`. + let result = unsafe { + CGGetEventTapList( + count, + taps.as_mut_ptr().cast::(), + &mut written, + ) + }; + if result != CGError::Success || written > count { + return Err(MacosInputError::TapInspection(result.0)); + } + let pid = + i32::try_from(std::process::id()).map_err(|_| MacosInputError::TapInspection(-1))?; + let taps = taps + .into_iter() + .take(usize::try_from(written).unwrap_or(capacity)) + .map(|tap| { + // SAFETY: Core Graphics initialized the first `written` + // records on the successful call above. + unsafe { tap.assume_init() } + }) + .collect::>(); + Ok(installed_masks_for_process(self.masks, pid, &taps)) + } + + #[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(); + } + } +} + +fn installed_masks_for_process( + requested: EffectiveEventMasks, + pid: i32, + taps: &[CGEventTapInformation], +) -> EffectiveEventMasks { + let installed = taps + .iter() + .filter(|tap| { + tap.tappingProcess == pid + && tap.tapPoint == CGEventTapLocation::SessionEventTap + && tap.options == CGEventTapOptions::ListenOnly + && tap.enabled + }) + .fold(0, |mask, tap| mask | tap.eventsOfInterest); + EffectiveEventMasks { + keyboard: installed & requested.keyboard, + pointer: installed & requested.pointer, + } +} + +impl Drop for MacosInputSession { + fn drop(&mut self) { + self.stop(); + } +} + +#[must_use] +pub fn input_monitoring_granted() -> bool { + CGPreflightListenEventAccess() +} + +/// Whether any process currently holds the macOS secure-input assertion +/// (Secure Keyboard Entry). While held, an event tap receives no keyboard +/// events even though pointer events keep flowing, so held-key state must +/// be cleared through an ordered gap. +#[must_use] +pub fn secure_event_input_enabled() -> bool { + #[link(name = "Carbon", kind = "framework")] + unsafe extern "C" { + /// `Boolean IsSecureEventInputEnabled(void)` from HIToolbox. + fn IsSecureEventInputEnabled() -> u8; + } + // SAFETY: the function takes no arguments, has no side effects, and + // reads a session-global flag maintained by the window server. + (unsafe { IsSecureEventInputEnabled() }) != 0 +} + +/// 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 displays = query_display_ids(false)?; + if displays.is_empty() { + displays = query_display_ids(true)?; + } + 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 query_display_ids(online: bool) -> MacosInputResult> { + let mut count = 0; + // SAFETY: both Core Graphics count-only forms write only the display count + // because the capacity and display pointer are zero. + let error = unsafe { + if online { + CGGetOnlineDisplayList(0, std::ptr::null_mut(), &raw mut count) + } else { + CGGetActiveDisplayList(0, std::ptr::null_mut(), &raw mut count) + } + }; + if error != CGError::Success { + return Err(MacosInputError::DisplayTopology(error.0)); + } + if count == 0 { + return Ok(Vec::new()); + } + + let mut displays = vec![0; usize::try_from(count).unwrap_or(usize::MAX)]; + let mut written = count; + // SAFETY: both Core Graphics list forms write at most `count` identifiers + // into `displays` and report the initialized length through `written`. + let error = unsafe { + if online { + CGGetOnlineDisplayList(count, displays.as_mut_ptr(), &raw mut written) + } else { + 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())); + Ok(displays) +} + +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; + }; + let mut stop_context = CFRunLoopSourceContext { + version: 0, + info: std::ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: Some(stop_run_loop_perform), + }; + // SAFETY: the context pointer is valid for the duration of the call and + // Core Foundation copies the structure; the perform callback uses no + // context state. + let Some(stop_source) = (unsafe { CFRunLoopSource::new(None, 0, &raw mut stop_context) }) + else { + let _ = ready.send(Err(MacosInputError::WorkerSpawn( + "Core Foundation refused the run-loop stop source".to_owned(), + ))); + return; + }; + // SAFETY: Core Foundation exports this process-lifetime static mode. + let common_modes = unsafe { kCFRunLoopCommonModes }; + run_loop.add_source(Some(&stop_source), common_modes); + control.install(&run_loop, &stop_source); + + 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(); + run_loop.remove_source(Some(&stop_source), common_modes); + 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(); + run_loop.remove_source(Some(&stop_source), common_modes); + + 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); + if !CGEvent::tap_is_enabled(&tap) { + run_loop.remove_source(Some(&source), mode); + return Err(MacosInputError::TapCreation(kind.label())); + } + 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 { + let callback_entry = Instant::now(); + // 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, + callback_entry, + ); + } else if event_type == CGEventType::TapDisabledByUserInput { + handle_tap_disable( + context, + MacosInputGapReason::TapDisabledUserInput, + callback_entry, + ); + } else if let Some(decoded) = decode_native_event(event_type, event_ref, context) { + context.queue.enqueue_at(decoded, callback_entry); + } + event.as_ptr() +} + +fn handle_tap_disable(context: &TapContext, reason: MacosInputGapReason, callback_entry: Instant) { + 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, reason); + context + .queue + .enqueue_at(MacosInputEvent::StateGap { reason }, callback_entry); + // Always re-enable, including on repeated disables. A disabled tap + // fires no callbacks, so refusing here would be permanent capture + // death with no recovery trigger anywhere. The retry cadence is + // bounded by macOS itself: each re-enable requires the window server + // to deliver a fresh disable event before another cycle can happen, + // and repeated disables stay observable through the diagnostics + // counters and the Degraded worker state. + 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); + context.queue.diagnostics().record_tap_reenabled(); +} + +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 { + // The tap thread never spins an autorelease pool of its own, and + // the NSEvent bridge autoreleases; without this scope every media + // key leaks the bridged event and spams the console. + return objc2::rc::autoreleasepool(|_| { + 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(); + 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: q16_16_field(event, CGEventField::ScrollWheelEventFixedPtDeltaAxis2), + fixed_delta_y: q16_16_field(event, CGEventField::ScrollWheelEventFixedPtDeltaAxis1), + unit, + phase, + momentum_phase, + }); + } + None +} + +/// Read a 16.16 fixed-point scroll field as raw Q16.16 bits. +/// +/// The integer accessor rounds fixed-point fields to the nearest whole +/// unit, discarding the fractional 16 bits (one notch reads as 1, not +/// 65536). The double accessor applies the documented 1/65536 scaling, so +/// multiplying back yields the raw representation the fold pipeline +/// expects. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + reason = "the scaled value is clamped to the i64 range before conversion" +)] +fn q16_16_field(event: &CGEvent, field: CGEventField) -> i64 { + const Q16_16_SCALE: f64 = 65536.0; + let scaled = CGEvent::double_value_field(Some(event), field) * Q16_16_SCALE; + if scaled.is_finite() { + scaled.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64 + } else { + 0 + } +} + +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<'_>) -> MacosInputPublicationOutcome, + queue: &EventQueue, + state: &Mutex, + control: &RunLoopControl, +) { + let mut events = Vec::with_capacity(DEFAULT_QUEUE_CAPACITY + 2); + let mut callback_entries = Vec::with_capacity(DEFAULT_QUEUE_CAPACITY); + let mut next_topology_check = Instant::now() + TOPOLOGY_INTERVAL; + let mut secure_input_active = false; + loop { + queue.wait(HEALTH_INTERVAL); + let now = Instant::now(); + if let Some(reason) = queue.diagnostics().take_repeated_tap_disable() { + set_worker_state( + state, + MacosWorkerState::Degraded(MacosWorkerDegradation::TapDisabled(reason)), + ); + } + if config.keyboard && !input_monitoring_granted() { + set_worker_state(state, MacosWorkerState::PermissionRevoked); + queue.request_gap(MacosInputGapReason::PermissionRevoked); + control.request_stop(); + queue.close(); + } + // Secure Keyboard Entry starves the tap of keyboard events while + // pointer events keep flowing; without an ordered gap on the + // rising edge, keys held at that moment stay pressed forever. + if config.keyboard { + let secure_now = secure_event_input_enabled(); + if secure_now != secure_input_active { + secure_input_active = secure_now; + queue.diagnostics().set_secure_input_active(secure_now); + if secure_now { + queue.request_gap(MacosInputGapReason::SessionInterrupted); + } + } + } + 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(MacosWorkerDegradation::DisplayTopology( + error.to_string(), + )), + ), + } + next_topology_check = now + TOPOLOGY_INTERVAL; + } + + events.clear(); + callback_entries.clear(); + let at_ms = (config.clock)(); + queue.drain_into(&mut events, &mut callback_entries); + if !events.is_empty() { + let outcome = sink(MacosInputBatch { + epoch: config.epoch, + at_ms, + events: &events, + virtual_desktop: desktop, + }); + if outcome == MacosInputPublicationOutcome::Published { + queue + .diagnostics() + .record_published(events.len(), &callback_entries); + } + } + 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; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tap(pid: i32, mask: u64, enabled: bool) -> CGEventTapInformation { + CGEventTapInformation { + eventTapID: 1, + tapPoint: CGEventTapLocation::SessionEventTap, + options: CGEventTapOptions::ListenOnly, + eventsOfInterest: mask, + tappingProcess: pid, + processBeingTapped: 0, + enabled, + minUsecLatency: 0.0, + avgUsecLatency: 0.0, + maxUsecLatency: 0.0, + } + } + + #[test] + fn installed_masks_use_only_enabled_current_process_session_taps() { + let requested = EffectiveEventMasks { + keyboard: 0b0011, + pointer: 0b1100, + }; + let taps = [ + tap(42, 0b0001, true), + tap(42, 0b0100, true), + tap(42, 0b0010, false), + tap(7, 0b1000, true), + ]; + + assert_eq!( + installed_masks_for_process(requested, 42, &taps), + EffectiveEventMasks { + keyboard: 0b0001, + pointer: 0b0100, + } + ); + } +} 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/queue.rs b/crates/hypercolor-macos-input/src/queue.rs new file mode 100644 index 000000000..6a3cf235c --- /dev/null +++ b/crates/hypercolor-macos-input/src/queue.rs @@ -0,0 +1,555 @@ +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64, Ordering}; +use std::sync::{Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use crossbeam_queue::ArrayQueue; + +use crate::{MacosInputDiagnostics, MacosInputEvent, MacosInputGapReason}; + +pub(crate) const DEFAULT_QUEUE_CAPACITY: usize = 2048; +const LATENCY_BUCKET_WIDTH_NS: u64 = 100_000; +const LATENCY_BUCKET_COUNT: usize = 4096; + +struct QueuedInputEvent { + event: MacosInputEvent, + callback_entry: Instant, +} + +struct AtomicLatencyHistogram { + buckets: Box<[AtomicU64]>, + generation: AtomicU64, + sample_count: AtomicU64, + total_ns: AtomicU64, + max_ns: AtomicU64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AtomicLatencySnapshot { + sample_count: u64, + total_ns: u64, + max_ns: u64, + p95_ns: u64, + p99_ns: u64, +} + +impl Default for AtomicLatencyHistogram { + fn default() -> Self { + Self { + buckets: (0..=LATENCY_BUCKET_COUNT) + .map(|_| AtomicU64::new(0)) + .collect(), + generation: AtomicU64::new(0), + sample_count: AtomicU64::new(0), + total_ns: AtomicU64::new(0), + max_ns: AtomicU64::new(0), + } + } +} + +impl AtomicLatencyHistogram { + fn record(&self, elapsed: Duration) { + self.record_with_hook(elapsed, || {}); + } + + fn record_with_hook(&self, elapsed: Duration, before_complete: impl FnOnce()) { + let generation = self.begin_write(); + let elapsed_ns = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let bucket = usize::try_from(elapsed_ns / LATENCY_BUCKET_WIDTH_NS) + .unwrap_or(usize::MAX) + .min(LATENCY_BUCKET_COUNT); + self.buckets[bucket].fetch_add(1, Ordering::Relaxed); + let _ = self + .total_ns + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |total| { + Some(total.saturating_add(elapsed_ns)) + }); + self.max_ns.fetch_max(elapsed_ns, Ordering::Relaxed); + before_complete(); + self.sample_count.fetch_add(1, Ordering::Relaxed); + self.generation + .store(generation.wrapping_add(1), Ordering::Release); + } + + fn begin_write(&self) -> u64 { + let mut generation = self.generation.load(Ordering::Relaxed); + loop { + if generation & 1 == 1 { + std::hint::spin_loop(); + generation = self.generation.load(Ordering::Acquire); + continue; + } + let started = generation.wrapping_add(1); + match self.generation.compare_exchange_weak( + generation, + started, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return started, + Err(observed) => generation = observed, + } + } + } + + fn percentile_upper_bound_ns(&self, percentile: u64, sample_count: u64, maximum: u64) -> u64 { + if sample_count == 0 { + return 0; + } + let rank = sample_count.saturating_mul(percentile).saturating_add(99) / 100; + let mut observed = 0_u64; + for (index, count) in self.buckets.iter().enumerate() { + observed = observed.saturating_add(count.load(Ordering::Relaxed)); + if observed >= rank { + if index == LATENCY_BUCKET_COUNT { + return maximum; + } + return u64::try_from(index.saturating_add(1)) + .unwrap_or(u64::MAX) + .saturating_mul(LATENCY_BUCKET_WIDTH_NS) + .min(maximum); + } + } + maximum + } + + fn snapshot(&self) -> AtomicLatencySnapshot { + self.snapshot_with_hooks(|| {}, || {}) + } + + fn snapshot_with_hooks( + &self, + mut retrying: impl FnMut(), + mut after_p95: impl FnMut(), + ) -> AtomicLatencySnapshot { + loop { + let generation = self.generation.load(Ordering::Acquire); + if generation & 1 == 1 { + retrying(); + std::hint::spin_loop(); + continue; + } + let sample_count = self.sample_count.load(Ordering::Relaxed); + let total_ns = self.total_ns.load(Ordering::Relaxed); + let max_ns = self.max_ns.load(Ordering::Relaxed); + let p95_ns = self.percentile_upper_bound_ns(95, sample_count, max_ns); + after_p95(); + let p99_ns = self.percentile_upper_bound_ns(99, sample_count, max_ns); + std::sync::atomic::fence(Ordering::Acquire); + if self.generation.load(Ordering::Relaxed) == generation { + return AtomicLatencySnapshot { + sample_count, + total_ns, + max_ns, + p95_ns, + p99_ns, + }; + } + retrying(); + } + } +} + +#[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, + last_point_delta_y: AtomicI64, + callback_to_publication: AtomicLatencyHistogram, + repeated_tap_disable: AtomicU8, + secure_input_active: AtomicBool, +} + +impl Diagnostics { + fn snapshot(&self, queue_capacity: usize, queue_depth: usize) -> MacosInputDiagnostics { + let callback_to_publication = self.callback_to_publication.snapshot(); + 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), + last_point_delta_y: self.last_point_delta_y.load(Ordering::Relaxed), + callback_to_publication_sample_count: callback_to_publication.sample_count, + callback_to_publication_total_ns: callback_to_publication.total_ns, + callback_to_publication_max_ns: callback_to_publication.max_ns, + callback_to_publication_p95_ns: callback_to_publication.p95_ns, + callback_to_publication_p99_ns: callback_to_publication.p99_ns, + secure_input_active: self.secure_input_active.load(Ordering::Relaxed), + } + } + + /// Publish the health tick's secure-input observation so status + /// snapshots read this cached value instead of calling Carbon from + /// other threads at frame rate. + pub(crate) fn set_secure_input_active(&self, active: bool) { + self.secure_input_active.store(active, Ordering::Relaxed); + } + + fn record_received(&self) { + self.events_received.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn record_published(&self, count: usize, callback_entries: &[Instant]) { + self.events_published + .fetch_add(u64::try_from(count).unwrap_or(u64::MAX), Ordering::Relaxed); + let published_at = Instant::now(); + for callback_entry in callback_entries { + self.callback_to_publication + .record(published_at.saturating_duration_since(*callback_entry)); + } + } + + 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, + MacosInputGapReason::TapDisabledUserInput => 2, + _ => 0, + }; + self.repeated_tap_disable.store(encoded, Ordering::Release); + } + } + + 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); + } + + 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) -> Option { + match self.repeated_tap_disable.swap(0, Ordering::AcqRel) { + 1 => Some(MacosInputGapReason::TapDisabledTimeout), + 2 => Some(MacosInputGapReason::TapDisabledUserInput), + _ => None, + } + } +} + +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(), + } + } + + #[cfg(test)] + pub(crate) fn enqueue(&self, event: MacosInputEvent) { + self.enqueue_at(event, Instant::now()); + } + + pub(crate) fn enqueue_at(&self, event: MacosInputEvent, callback_entry: Instant) { + self.diagnostics.record_received(); + if matches!(event, MacosInputEvent::StateGap { .. }) { + self.diagnostics.record_gap(); + } + if self.overflowed.load(Ordering::Acquire) { + self.diagnostics.record_drop(); + return; + } + if self + .events + .push(QueuedInputEvent { + event, + callback_entry, + }) + .is_err() + { + self.diagnostics.record_drop(); + self.overflowed.store(true, Ordering::Release); + } + self.notify(); + } + + pub(crate) fn request_gap(&self, reason: MacosInputGapReason) { + self.diagnostics.record_gap(); + 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, + callback_entries: &mut Vec, + ) { + while let Some(queued) = self.events.pop() { + output.push(queued.event); + callback_entries.push(queued.callback_entry); + } + if self.overflowed.swap(false, Ordering::AcqRel) { + self.diagnostics.record_gap(); + 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 + } + + 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(()); + } +} + +#[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(); + let mut callback_entries = Vec::new(); + + queue.drain_into(&mut drained, &mut callback_entries); + queue + .diagnostics() + .record_published(drained.len(), &callback_entries); + + 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 + } + ); + 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] + 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(); + let mut callback_entries = Vec::new(); + + queue.drain_into(&mut drained, &mut callback_entries); + + assert_eq!(drained[0], button(true)); + assert_eq!( + drained[1], + MacosInputEvent::StateGap { + reason: MacosInputGapReason::SourceStopped + } + ); + 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(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); + } + + #[test] + fn callback_latency_records_only_after_canonical_publication() { + let queue = EventQueue::new(2); + queue.enqueue(button(true)); + let mut drained = Vec::new(); + let mut callback_entries = Vec::new(); + queue.drain_into(&mut drained, &mut callback_entries); + + let before = queue.diagnostics_snapshot(); + assert_eq!(before.events_published, 0); + assert_eq!(before.callback_to_publication_sample_count, 0); + + queue + .diagnostics() + .record_published(drained.len(), &callback_entries); + let after = queue.diagnostics_snapshot(); + assert_eq!(after.events_published, 1); + assert_eq!(after.callback_to_publication_sample_count, 1); + assert!(after.callback_to_publication_p95_ns > 0); + assert!(after.callback_to_publication_p99_ns > 0); + } + + #[test] + fn callback_latency_percentiles_never_exceed_the_observed_maximum() { + let histogram = AtomicLatencyHistogram::default(); + histogram.record(Duration::from_nanos(1)); + + let snapshot = histogram.snapshot(); + assert_eq!(snapshot.p95_ns, 1); + assert_eq!(snapshot.p99_ns, 1); + assert_eq!(snapshot.max_ns, 1); + } + + #[test] + fn callback_latency_snapshot_retries_when_population_changes() { + let histogram = AtomicLatencyHistogram::default(); + histogram.record(Duration::from_nanos(40)); + let mut injected = false; + + let snapshot = histogram.snapshot_with_hooks( + || {}, + || { + if !injected { + histogram.record(Duration::from_nanos(70)); + injected = true; + } + }, + ); + + assert_eq!( + snapshot, + AtomicLatencySnapshot { + sample_count: 2, + total_ns: 110, + max_ns: 70, + p95_ns: 70, + p99_ns: 70, + } + ); + } +} diff --git a/crates/hypercolor-macos-input/src/shared.rs b/crates/hypercolor-macos-input/src/shared.rs new file mode 100644 index 000000000..50cb9b00b --- /dev/null +++ b/crates/hypercolor-macos-input/src/shared.rs @@ -0,0 +1,307 @@ +//! 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, +} + +/// Whether a native input batch reached the canonical core publication state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosInputPublicationOutcome { + Published, + Rejected, +} + +/// 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, + pub last_point_delta_y: i64, + pub callback_to_publication_sample_count: u64, + pub callback_to_publication_total_ns: u64, + pub callback_to_publication_max_ns: u64, + pub callback_to_publication_p95_ns: u64, + pub callback_to_publication_p99_ns: u64, + /// Whether the health tick last observed the session-global + /// secure-input assertion (Secure Keyboard Entry) as held. + pub secure_input_active: bool, +} + +/// Event masks actually requested for one session. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct EffectiveEventMasks { + pub keyboard: u64, + 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(MacosWorkerDegradation), + PermissionRevoked, + 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("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")] + 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), + #[error("failed to inspect the installed event taps: Core Graphics error {0}")] + TapInspection(i32), + #[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/src/stubs.rs b/crates/hypercolor-macos-input/src/stubs.rs new file mode 100644 index 000000000..b4c4faf9c --- /dev/null +++ b/crates/hypercolor-macos-input/src/stubs.rs @@ -0,0 +1,64 @@ +use crate::{ + EffectiveEventMasks, MacosInputBatch, MacosInputConfig, MacosInputDiagnostics, MacosInputError, + MacosInputPublicationOutcome, 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<'_>) -> MacosInputPublicationOutcome + Send + 'static, + ) -> MacosInputResult { + Err(MacosInputError::UnsupportedPlatform) + } + + #[must_use] + pub const fn effective_masks(&self) -> EffectiveEventMasks { + EffectiveEventMasks { + keyboard: 0, + pointer: 0, + } + } + + pub const fn installed_masks(&self) -> MacosInputResult { + Err(MacosInputError::UnsupportedPlatform) + } + + #[must_use] + pub fn worker_state(&self) -> MacosWorkerState { + MacosWorkerState::Failed("macOS host input is unavailable".to_owned()) + } + + #[must_use] + pub fn diagnostics(&self) -> MacosInputDiagnostics { + MacosInputDiagnostics { + ..MacosInputDiagnostics::default() + } + } + + pub const fn stop(&mut self) {} +} + +#[must_use] +pub const fn input_monitoring_granted() -> bool { + false +} + +#[must_use] +pub const fn secure_event_input_enabled() -> 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 new file mode 100644 index 000000000..df493fe85 --- /dev/null +++ b/crates/hypercolor-macos-input/tests/input_contract_tests.rs @@ -0,0 +1,234 @@ +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, input_monitoring_granted, request_input_monitoring, +}; + +#[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); +} + +#[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), + }, + |_| hypercolor_macos_input::MacosInputPublicationOutcome::Published, + ) + .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); +} 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) + ); +} diff --git a/crates/hypercolor-macos-owner/Cargo.toml b/crates/hypercolor-macos-owner/Cargo.toml new file mode 100644 index 000000000..4a59f8c1f --- /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", "user"] } +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..e8db4eb98 --- /dev/null +++ b/crates/hypercolor-macos-owner/src/lib.rs @@ -0,0 +1,3123 @@ +//! Durable macOS daemon ownership and handover state. + +use std::fmt; +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; +/// Current daemon-session attestation schema version. +pub const MACOS_DAEMON_SESSION_ATTESTATION_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 daemon-session attestation file name within the per-user data directory. +pub const MACOS_DAEMON_SESSION_ATTESTATION_FILE_NAME: &str = "macos-daemon-session.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"; +/// Binary names the app bundle's main executable may carry. Tauri names +/// the `.app` folder after the product but keeps the cargo binary name +/// for the executable, so real bundles ship `hypercolor-app`; the +/// product-named form is accepted for a future renamed bundle. +pub const MACOS_APP_BUNDLE_BINARY_NAMES: [&str; 2] = ["hypercolor-app", MACOS_APP_PRODUCT_NAME]; +/// 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; +const MACOS_SERVER_SESSION_ID_PREFIX: &str = "hc_session_"; +const MACOS_PROTECTED_CONTROL_CREDENTIAL_PREFIX: &str = "hc_pc_"; +const MACOS_SERVER_SESSION_ID_BYTES: usize = 16; +const MACOS_PROTECTED_CONTROL_CREDENTIAL_BYTES: usize = 32; + +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, + } + } + + /// Return the complete durable identity of this owner acquisition. + pub fn incarnation(&self) -> MacosOwnerIncarnation { + MacosOwnerIncarnation { + owner: self.active_owner, + owner_epoch: self.owner_epoch, + identity: self.active_identity.clone(), + } + } +} + +/// Exact durable identity of one owner acquisition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosOwnerIncarnation { + /// Topology that acquired the canonical daemon guard. + pub owner: MacosDaemonOwner, + /// Monotonic acquisition epoch published by that owner. + pub owner_epoch: u64, + /// Full process identity published for the acquisition. + pub identity: MacosOwnerIdentity, +} + +/// Per-process identifier exposed by the daemon discovery endpoint. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct MacosServerSessionId(String); + +impl MacosServerSessionId { + /// Construct a canonical session identifier from 128 bits of entropy. + #[must_use] + pub fn from_bytes(bytes: [u8; MACOS_SERVER_SESSION_ID_BYTES]) -> Self { + Self(format_hex_token(MACOS_SERVER_SESSION_ID_PREFIX, &bytes)) + } + + /// Borrow the canonical session identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MacosServerSessionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("MacosServerSessionId") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for MacosServerSessionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + validate_hex_token( + &value, + MACOS_SERVER_SESSION_ID_PREFIX, + MACOS_SERVER_SESSION_ID_BYTES, + "server_session_id must be a canonical 128-bit token", + ) + .map_err(serde::de::Error::custom)?; + Ok(Self(value)) + } +} + +/// Private 256-bit bearer credential for one daemon process session. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct MacosProtectedControlCredential(String); + +impl MacosProtectedControlCredential { + /// Construct a canonical protected-control credential from 256 bits. + #[must_use] + pub fn from_bytes(bytes: [u8; MACOS_PROTECTED_CONTROL_CREDENTIAL_BYTES]) -> Self { + Self(format_hex_token( + MACOS_PROTECTED_CONTROL_CREDENTIAL_PREFIX, + &bytes, + )) + } + + /// Explicitly expose the bearer value for authenticated local transport. + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MacosProtectedControlCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MacosProtectedControlCredential([REDACTED])") + } +} + +impl<'de> Deserialize<'de> for MacosProtectedControlCredential { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + validate_hex_token( + &value, + MACOS_PROTECTED_CONTROL_CREDENTIAL_PREFIX, + MACOS_PROTECTED_CONTROL_CREDENTIAL_BYTES, + "protected_control_credential must be a canonical 256-bit token", + ) + .map_err(serde::de::Error::custom)?; + Ok(Self(value)) + } +} + +/// Private process-session proof derived from canonical daemon ownership. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosDaemonSessionAttestation { + /// Durable schema version. + pub schema_version: u32, + /// Topology holding the canonical daemon guard. + pub owner: MacosDaemonOwner, + /// Exact owner epoch current when this session was published. + pub owner_epoch: u64, + /// Full process identity current when this session was published. + pub owner_identity: MacosOwnerIdentity, + /// Per-process identifier safe to expose from `GET /server`. + pub server_session_id: MacosServerSessionId, + /// Private bearer credential accepted only from a loopback peer. + pub protected_control_credential: MacosProtectedControlCredential, +} + +impl MacosDaemonSessionAttestation { + fn generate(record: &MacosOwnerRecord) -> Result { + let mut entropy = + [0_u8; MACOS_SERVER_SESSION_ID_BYTES + MACOS_PROTECTED_CONTROL_CREDENTIAL_BYTES]; + File::open("/dev/urandom") + .and_then(|mut source| source.read_exact(&mut entropy)) + .map_err(|source| MacosOwnerStoreError::Read { + artifact: "daemon session entropy", + path: PathBuf::from("/dev/urandom"), + source, + })?; + let (session_bytes, credential_bytes) = entropy.split_at(MACOS_SERVER_SESSION_ID_BYTES); + let session_bytes = session_bytes + .try_into() + .expect("session entropy slice has the exact array length"); + let credential_bytes = credential_bytes + .try_into() + .expect("credential entropy slice has the exact array length"); + Ok(Self { + schema_version: MACOS_DAEMON_SESSION_ATTESTATION_SCHEMA_VERSION, + owner: record.active_owner, + owner_epoch: record.owner_epoch, + owner_identity: record.active_identity.clone(), + server_session_id: MacosServerSessionId::from_bytes(session_bytes), + protected_control_credential: MacosProtectedControlCredential::from_bytes( + credential_bytes, + ), + }) + } + + /// Return the exact owner acquisition that authorized this session. + #[must_use] + pub fn owner_incarnation(&self) -> MacosOwnerIncarnation { + MacosOwnerIncarnation { + owner: self.owner, + owner_epoch: self.owner_epoch, + identity: self.owner_identity.clone(), + } + } +} + +/// 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, + /// Requested owner epoch published during this handover, 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: None, + 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>; + + /// Verify that this executor can stop one exact managed owner acquisition. + fn preflight_stop_authority( + &mut self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError>; + + /// Flush and stop one exact managed owner acquisition. + fn flush_and_stop( + &mut self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError>; + + /// Idempotently start one managed owner. + fn start(&mut self, owner: MacosDaemonOwner) -> Result<(), MacosOwnerExecutionError>; + + /// Wait until the canonical daemon guard can be acquired. + fn wait_for_guard_release( + &mut self, + 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 through a retained, unreaped child handle. +/// +/// # Errors +/// +/// Returns an error when the child state cannot be inspected, its identifier +/// cannot be represented by the platform API, or `SIGTERM` cannot be delivered. +#[cfg(target_os = "macos")] +pub fn request_macos_child_termination( + child: &mut std::process::Child, +) -> Result<(), MacosOwnerExecutionError> { + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + if child + .try_wait() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .is_some() + { + return Ok(()); + } + let pid = i32::try_from(child.id()).map_err(|_| { + MacosOwnerExecutionError::new("retained child identifier exceeds the macOS process range") + })?; + kill(Pid::from_raw(pid), Signal::SIGTERM) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) +} + +/// Request graceful termination of a recorded owner process by pid. +/// +/// The caller must have verified the live process identity against the +/// owner record (executable path and guard contention) before calling: +/// this function delivers `SIGTERM` to whatever currently holds the pid. +/// +/// # Errors +/// +/// Returns an error when the identifier cannot be represented by the +/// platform API or `SIGTERM` cannot be delivered. +#[cfg(target_os = "macos")] +pub fn request_macos_pid_termination(pid: u32) -> Result<(), MacosOwnerExecutionError> { + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + let pid = i32::try_from(pid).map_err(|_| { + MacosOwnerExecutionError::new("recorded owner identifier exceeds the macOS process range") + })?; + kill(Pid::from_raw(pid), Signal::SIGTERM) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string())) +} + +/// Wait until the final single-instance guard can be acquired. +#[cfg(target_os = "macos")] +pub fn wait_for_macos_guard_release( + timeout: Duration, + instance_name: &str, +) -> Result { + use std::time::Instant; + + let started = Instant::now(); + loop { + if try_acquire_macos_daemon_guard(instance_name)?.is_some() { + return Ok(true); + } + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Ok(false); + }; + std::thread::sleep(remaining.min(Duration::from_millis(25))); + } +} + +/// 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, + }, + /// A matching daemon-session attestation could not be removed. + #[error("failed to remove macOS daemon session attestation at {path}: {source}")] + RemoveSessionAttestation { + /// Attestation path. + 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 daemon-session attestation path. + pub fn daemon_session_attestation_path(&self) -> PathBuf { + self.data_dir + .join(MACOS_DAEMON_SESSION_ATTESTATION_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()) + } + + /// Load a private session attestation only when its exact owner is current. + /// + /// Artifact presence is not ownership authority. Callers that need owner + /// authority must independently verify the canonical daemon guard. + pub fn load_daemon_session_attestation( + &self, + ) -> Result, MacosOwnerStoreError> { + let _lock = self.acquire_coordination_lock()?; + let Some(attestation) = + read_daemon_session_attestation(&self.daemon_session_attestation_path())? + else { + return Ok(None); + }; + let current = read_owner_record(&self.owner_record_path())? + .ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if attestation.owner_incarnation() != current.incarnation() { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail: "owner topology, epoch, or identity is not current", + }); + } + Ok(Some(attestation)) + } + + /// Publish a new private process session for the exact guard-winning owner. + #[cfg(target_os = "macos")] + pub fn publish_daemon_session_attestation( + &self, + _guard: &MacosDaemonGuard, + expected_owner: &MacosOwnerIncarnation, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let current = read_owner_record(&self.owner_record_path())? + .ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if current.incarnation() != *expected_owner { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail: "current owner does not match the guard-winning incarnation", + }); + } + let attestation = MacosDaemonSessionAttestation::generate(¤t)?; + validate_daemon_session_attestation(&attestation)?; + write_json_atomic( + &self.data_dir, + &self.daemon_session_attestation_path(), + "daemon session attestation", + &attestation, + )?; + Ok(attestation) + } + + /// Clear only the exact current owner's matching process session. + #[cfg(target_os = "macos")] + pub fn clear_daemon_session_attestation( + &self, + expected_owner: &MacosOwnerIncarnation, + expected_session: &MacosServerSessionId, + ) -> Result { + let _lock = self.acquire_coordination_lock()?; + let current = read_owner_record(&self.owner_record_path())? + .ok_or(MacosOwnerStoreError::MissingOwnerRecord)?; + if current.incarnation() != *expected_owner { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail: "current owner does not match the clearing incarnation", + }); + } + let path = self.daemon_session_attestation_path(); + let Some(attestation) = read_daemon_session_attestation(&path)? else { + return Ok(false); + }; + if attestation.owner_incarnation() != *expected_owner + || attestation.server_session_id != *expected_session + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail: "identity, epoch, or server session does not match", + }); + } + fs::remove_file(&path).map_err(|source| { + MacosOwnerStoreError::RemoveSessionAttestation { + path: path.clone(), + source, + } + })?; + sync_parent_directory(&self.data_dir)?; + Ok(true) + } + + /// Issue one stop request only while the exact owner publication remains current. + /// + /// The callback runs under the same coordination lock used by owner publication, + /// so it must not write through this store or wait for the daemon guard. + /// + /// # Errors + /// + /// Returns an error when the owner record is unavailable or no longer matches, + /// when the coordination lock cannot be acquired, or when the request fails. + pub fn request_stop_if_current( + &self, + expected: &MacosOwnerIncarnation, + request: impl FnOnce() -> Result<(), MacosOwnerExecutionError>, + ) -> Result<(), MacosOwnerExecutionError> { + let _lock = self + .acquire_coordination_lock() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))?; + let current = read_owner_record(&self.owner_record_path()) + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .ok_or_else(|| MacosOwnerExecutionError::new("macOS owner record is unavailable"))?; + if current.incarnation() != *expected { + return Err(MacosOwnerExecutionError::new( + "macOS owner incarnation changed before the stop request", + )); + } + request() + } + + /// 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 bind_requested_epoch( + &self, + transaction_id: &MacosHandoverTransactionId, + requested_owner: MacosDaemonOwner, + requested_epoch: u64, + ) -> 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 Ok(journal); + } + if journal + .contender_epoch + .is_some_and(|epoch| epoch > journal.active_epoch) + { + return if journal.contender_epoch == Some(requested_epoch) { + Ok(journal) + } else { + Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "requested owner epoch changed after it was bound", + }) + }; + } + if requested_owner != journal.requested_owner || requested_epoch <= journal.active_epoch { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "handover journal", + detail: "requested owner incarnation does not match the transaction", + }); + } + journal.journal_revision = journal + .journal_revision + .checked_add(1) + .ok_or(MacosOwnerStoreError::JournalRevisionOverflow)?; + journal.contender_epoch = Some(requested_epoch); + validate_handover_journal(&journal)?; + 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)?; + if requested_owner != prior_record.active_owner + && prior_record.active_owner != MacosDaemonOwner::Standalone + { + preflight_exact_stop_authority( + store, + executor, + prior_record.active_owner, + prior_record.owner_epoch, + )?; + } + 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 }) +} + +fn preflight_forward_stop_authority( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, + journal: &MacosHandoverJournal, +) -> Result { + if journal.requested_owner == journal.prior_owner + || journal.prior_owner == MacosDaemonOwner::Standalone + { + return Ok(ForwardStopPreflight::Ready); + } + let operation = flush_stop_operation(journal.prior_owner)?; + let record = + store + .load_owner_record()? + .ok_or_else(|| MacosOwnerCoordinatorError::Operation { + operation, + source: MacosOwnerExecutionError::new( + "macOS owner record is unavailable during stop-authority preflight", + ), + })?; + if record.active_owner == journal.prior_owner && record.owner_epoch > journal.active_epoch { + return Ok(ForwardStopPreflight::PriorOwnerReplaced); + } + if record.active_owner != journal.prior_owner || record.owner_epoch != journal.active_epoch { + return Err(MacosOwnerCoordinatorError::Operation { + operation, + source: MacosOwnerExecutionError::new( + "macOS owner incarnation changed before stop-authority preflight", + ), + }); + } + executor + .preflight_stop_authority(&record.incarnation()) + .map_err(|source| MacosOwnerCoordinatorError::Operation { operation, source })?; + Ok(ForwardStopPreflight::Ready) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForwardStopPreflight { + Ready, + PriorOwnerReplaced, +} + +fn preflight_exact_stop_authority( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, + owner: MacosDaemonOwner, + owner_epoch: u64, +) -> Result<(), MacosOwnerCoordinatorError> { + let operation = flush_stop_operation(owner)?; + let incarnation = store + .load_owner_record()? + .filter(|record| record.active_owner == owner && record.owner_epoch == owner_epoch) + .map(|record| record.incarnation()) + .ok_or_else(|| MacosOwnerCoordinatorError::Operation { + operation, + source: MacosOwnerExecutionError::new( + "macOS owner incarnation changed before stop-authority preflight", + ), + })?; + executor + .preflight_stop_authority(&incarnation) + .map_err(|source| MacosOwnerCoordinatorError::Operation { operation, 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 rollback_stop_authority_is_unbound(journal: &MacosHandoverJournal) -> bool { + journal + .contender_epoch + .is_none_or(|epoch| epoch <= journal.active_epoch) +} + +fn newer_prior_owner_is_published( + store: &MacosOwnerStore, + journal: &MacosHandoverJournal, +) -> Result { + Ok(store.load_owner_record()?.is_some_and(|record| { + record.active_owner == journal.prior_owner && record.owner_epoch > journal.active_epoch + })) +} + +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 { + if preflight_forward_stop_authority(store, executor, &journal)? + == ForwardStopPreflight::PriorOwnerReplaced + { + journal = begin_rollback(store, &journal)?; + continue; + } + for operation in autostart_operations_for(journal.requested_owner) { + if execute_operation(store, 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 => { + if preflight_forward_stop_authority(store, executor, &journal)? + == ForwardStopPreflight::PriorOwnerReplaced + { + journal = begin_rollback(store, &journal)?; + continue; + } + 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 => { + if preflight_forward_stop_authority(store, executor, &journal)? + == ForwardStopPreflight::PriorOwnerReplaced + { + journal = begin_rollback(store, &journal)?; + continue; + } + let operation = flush_stop_operation(journal.prior_owner)?; + if execute_operation(store, 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 standalone_pid = journal.pending_standalone_pid; + 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(timeout); + if released + .as_ref() + .is_err_and(|_| journal.pending_standalone_pid.is_some()) + { + return Err(MacosOwnerCoordinatorError::Operation { + operation: MacosHandoverOperation::AwaitStandaloneExit { + pid: standalone_pid.expect("checked standalone handover"), + }, + 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: standalone_pid.expect("checked standalone handover"), + }, + }); + } + 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(store, 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(store, executor, &journal, operation, true) { + if journal.prior_owner == MacosDaemonOwner::Standalone { + return Err(error); + } + journal = bind_requested_epoch_from_record(store, &journal)?; + 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, + ); + journal = bind_requested_epoch_from_record(store, &journal)?; + 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 => { + if journal.requested_owner != journal.prior_owner + && !newer_prior_owner_is_published(store, &journal)? + && !rollback_stop_authority_is_unbound(&journal) + { + preflight_exact_stop_authority( + store, + executor, + journal.requested_owner, + journal + .contender_epoch + .expect("checked bound contender epoch"), + )?; + } + for operation in autostart_operations_from(journal.prior_autostart_states) { + execute_operation(store, executor, &journal, operation, false)?; + } + journal = advance( + store, + &journal, + MacosHandoverPhase::RollbackAutostartsRestored, + )?; + } + MacosHandoverPhase::RollbackAutostartsRestored => { + let prior_owner_is_active = newer_prior_owner_is_published(store, &journal)?; + if journal.requested_owner != journal.prior_owner && !prior_owner_is_active { + if rollback_stop_authority_is_unbound(&journal) { + return Ok(recovery_required(&journal)); + } + preflight_exact_stop_authority( + store, + executor, + journal.requested_owner, + journal + .contender_epoch + .expect("checked bound contender epoch"), + )?; + } + journal = advance( + store, + &journal, + if journal.requested_owner == journal.prior_owner || prior_owner_is_active { + MacosHandoverPhase::RollbackCommitPending + } else { + MacosHandoverPhase::RollbackStopRequested + }, + )?; + } + MacosHandoverPhase::RollbackStopRequested => { + if rollback_stop_authority_is_unbound(&journal) { + return Ok(recovery_required(&journal)); + } + preflight_exact_stop_authority( + store, + executor, + journal.requested_owner, + journal + .contender_epoch + .expect("checked bound contender epoch"), + )?; + let operation = flush_stop_operation(journal.requested_owner)?; + execute_operation(store, executor, &journal, operation, false)?; + journal = advance(store, &journal, MacosHandoverPhase::RollbackOwnerStopped)?; + } + MacosHandoverPhase::RollbackOwnerStopped => { + if rollback_stop_authority_is_unbound(&journal) { + return Ok(recovery_required(&journal)); + } + journal = advance( + store, + &journal, + MacosHandoverPhase::RollbackAwaitingGuardRelease, + )?; + } + MacosHandoverPhase::RollbackAwaitingGuardRelease => { + if rollback_stop_authority_is_unbound(&journal) { + return Ok(recovery_required(&journal)); + } + if !executor + .wait_for_guard_release(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(store, 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( + store: &MacosOwnerStore, + 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 {} => flush_and_stop_owner( + store, + executor, + journal, + MacosDaemonOwner::AppSidecar, + forward, + ), + MacosHandoverOperation::StartAppSidecar {} => executor.start(MacosDaemonOwner::AppSidecar), + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled } => { + executor.set_autostart(MacosDaemonOwner::DirectLaunchd, enabled) + } + MacosHandoverOperation::FlushAndStopDirectLaunchd {} => flush_and_stop_owner( + store, + executor, + journal, + MacosDaemonOwner::DirectLaunchd, + forward, + ), + MacosHandoverOperation::StartDirectLaunchd {} => { + executor.start(MacosDaemonOwner::DirectLaunchd) + } + MacosHandoverOperation::SetHomebrewAutostart { enabled } => { + executor.set_autostart(MacosDaemonOwner::Homebrew, enabled) + } + MacosHandoverOperation::FlushAndStopHomebrew {} => flush_and_stop_owner( + store, + executor, + journal, + MacosDaemonOwner::Homebrew, + forward, + ), + MacosHandoverOperation::StartHomebrew {} => executor.start(MacosDaemonOwner::Homebrew), + MacosHandoverOperation::AwaitStandaloneExit { .. } => Ok(()), + } + .map_err(|source| MacosOwnerCoordinatorError::Operation { operation, source }) +} + +fn flush_and_stop_owner( + store: &MacosOwnerStore, + executor: &mut impl MacosOwnerExecutor, + journal: &MacosHandoverJournal, + owner: MacosDaemonOwner, + forward: bool, +) -> Result<(), MacosOwnerExecutionError> { + let incarnation = if forward { + if owner != journal.prior_owner { + return Err(MacosOwnerExecutionError::new( + "forward stop does not target the journal's prior owner", + )); + } + store + .load_owner_record() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .filter(|record| { + record.active_owner == owner && record.owner_epoch == journal.active_epoch + }) + .map(|record| record.incarnation()) + .ok_or_else(|| { + MacosOwnerExecutionError::new( + "handover journal has no matching prior owner incarnation", + ) + })? + } else { + if owner != journal.requested_owner { + return Err(MacosOwnerExecutionError::new( + "rollback stop does not target the journal's requested owner", + )); + } + let Some(requested_epoch) = journal + .contender_epoch + .filter(|epoch| *epoch > journal.active_epoch) + else { + return Ok(()); + }; + store + .load_owner_record() + .map_err(|error| MacosOwnerExecutionError::new(error.to_string()))? + .filter(|record| record.active_owner == owner && record.owner_epoch == requested_epoch) + .map(|record| record.incarnation()) + .ok_or_else(|| { + MacosOwnerExecutionError::new( + "handover journal has no matching requested owner incarnation", + ) + })? + }; + store.request_stop_if_current(&incarnation, || executor.flush_and_stop(&incarnation)) +} + +fn bind_requested_epoch_from_record( + store: &MacosOwnerStore, + journal: &MacosHandoverJournal, +) -> Result { + let requested = store + .load_owner_record()? + .filter(|record| { + record.active_owner == journal.requested_owner + && record.owner_epoch > journal.active_epoch + }) + .map(|record| (record.active_owner, record.owner_epoch)); + requested.map_or_else( + || Ok(journal.clone()), + |(owner, epoch)| store.bind_requested_epoch(&journal.transaction_id, owner, epoch), + ) +} + +fn require_operation( + allowed: &[MacosHandoverOperation], + operation: MacosHandoverOperation, +) -> Result<(), MacosOwnerCoordinatorError> { + if allowed.contains(&operation) { + Ok(()) + } else { + Err(MacosOwnerCoordinatorError::UnauthorizedOperation { operation }) + } +} + +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_daemon_session_attestation( + path: &Path, +) -> Result, MacosOwnerStoreError> { + let Some(bytes) = read_private_optional(path, "daemon session attestation")? else { + return Ok(None); + }; + let attestation = + serde_json::from_slice::(&bytes).map_err(|source| { + MacosOwnerStoreError::Decode { + artifact: "daemon session attestation", + source, + } + })?; + validate_daemon_session_attestation(&attestation)?; + Ok(Some(attestation)) +} + +fn read_private_optional( + path: &Path, + artifact: &'static str, +) -> Result>, MacosOwnerStoreError> { + let path_metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + }); + } + }; + validate_private_file_metadata(&path_metadata, artifact)?; + let file = File::open(path).map_err(|source| MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + })?; + let file_metadata = file + .metadata() + .map_err(|source| MacosOwnerStoreError::Read { + artifact, + path: path.to_path_buf(), + source, + })?; + validate_private_file_metadata(&file_metadata, artifact)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if path_metadata.dev() != file_metadata.dev() || path_metadata.ino() != file_metadata.ino() + { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact, + detail: "file changed while it was opened", + }); + } + } + read_bounded_file(file, path, artifact).map(Some) +} + +fn validate_private_file_metadata( + metadata: &fs::Metadata, + artifact: &'static str, +) -> Result<(), MacosOwnerStoreError> { + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact, + detail: "must be a regular file", + }); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mode() & 0o777 != 0o600 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact, + detail: "mode must be 0600", + }); + } + } + #[cfg(target_os = "macos")] + { + use std::os::unix::fs::MetadataExt; + validate_private_file_uid( + metadata.uid(), + nix::unistd::Uid::current().as_raw(), + artifact, + )?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn validate_private_file_uid( + owner_uid: u32, + current_uid: u32, + artifact: &'static str, +) -> Result<(), MacosOwnerStoreError> { + if owner_uid != current_uid { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact, + detail: "owner UID must match the current user", + }); + } + Ok(()) +} + +fn read_bounded_file( + file: File, + path: &Path, + artifact: &'static str, +) -> Result, MacosOwnerStoreError> { + 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(bytes) +} + +fn read_optional( + path: &Path, + artifact: &'static str, +) -> Result>, MacosOwnerStoreError> { + match File::open(path) { + Ok(file) => read_bounded_file(file, path, artifact).map(Some), + 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_daemon_session_attestation( + attestation: &MacosDaemonSessionAttestation, +) -> Result<(), MacosOwnerStoreError> { + validate_version( + "daemon session attestation", + attestation.schema_version, + MACOS_DAEMON_SESSION_ATTESTATION_SCHEMA_VERSION, + )?; + if attestation.owner_epoch == 0 { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail: "owner_epoch must be positive", + }); + } + validate_owner_identity(&attestation.owner_identity)?; + validate_hex_token( + attestation.server_session_id.as_str(), + MACOS_SERVER_SESSION_ID_PREFIX, + MACOS_SERVER_SESSION_ID_BYTES, + "server_session_id must be a canonical 128-bit token", + )?; + validate_hex_token( + attestation.protected_control_credential.expose_secret(), + MACOS_PROTECTED_CONTROL_CREDENTIAL_PREFIX, + MACOS_PROTECTED_CONTROL_CREDENTIAL_BYTES, + "protected_control_credential must be a canonical 256-bit token", + ) +} + +fn format_hex_token(prefix: &str, bytes: &[u8]) -> String { + const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(prefix.len() + bytes.len() * 2); + value.push_str(prefix); + for byte in bytes { + value.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); + value.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); + } + value +} + +fn validate_hex_token( + value: &str, + prefix: &str, + entropy_bytes: usize, + detail: &'static str, +) -> Result<(), MacosOwnerStoreError> { + let hex = value + .strip_prefix(prefix) + .filter(|hex| hex.len() == entropy_bytes * 2) + .filter(|hex| { + hex.bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }); + if hex.is_none() { + return Err(MacosOwnerStoreError::InvalidArtifact { + artifact: "daemon session attestation", + detail, + }); + } + 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) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|source| MacosOwnerStoreError::CreateTemporary { + path: path.to_path_buf(), + source, + })?; + } + 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(()) +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::validate_private_file_uid; + + #[test] + fn private_session_file_rejects_another_uid() { + let error = validate_private_file_uid(501, 502, "daemon session attestation") + .expect_err("another UID must fail closed"); + + assert!(error.to_string().contains("current user")); + } +} 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..a4a9e6821 --- /dev/null +++ b/crates/hypercolor-macos-owner/tests/coordinator_tests.rs @@ -0,0 +1,1368 @@ +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, + MacosOwnerIncarnation, MacosOwnerRecord, 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, + stopped_incarnations: Vec, + guard_results: VecDeque, + owner_results: VecDeque, + fail_operation: Option, + next_pid: u32, + incoming_recovers_on_start: bool, + app_sidecar_preflight_requires_retained_child: bool, + app_sidecar_stop_preflights: usize, + app_sidecar_stop_attempts: usize, +} + +impl FixtureExecutor { + fn new(store: MacosOwnerStore) -> Self { + Self { + store, + autostarts: [true, false, false], + operations: Vec::new(), + stopped_incarnations: 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, + app_sidecar_preflight_requires_retained_child: false, + app_sidecar_stop_preflights: 0, + app_sidecar_stop_attempts: 0, + } + } + + 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 preflight_stop_authority( + &mut self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + if incarnation.owner == MacosDaemonOwner::AppSidecar { + self.app_sidecar_stop_preflights += 1; + if self.app_sidecar_preflight_requires_retained_child { + return Err(MacosOwnerExecutionError::new( + "app-sidecar termination requires the app supervisor's retained child handle", + )); + } + } + Ok(()) + } + + fn flush_and_stop( + &mut self, + incarnation: &MacosOwnerIncarnation, + ) -> Result<(), MacosOwnerExecutionError> { + if incarnation.owner == MacosDaemonOwner::AppSidecar { + self.app_sidecar_stop_attempts += 1; + } + self.stopped_incarnations.push(incarnation.clone()); + let operation = match incarnation.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, + _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()); + let prior = 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]); + assert_eq!(executor.stopped_incarnations, [prior.incarnation()]); + 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, 11); + let current = store + .load_owner_record() + .expect("owner should load") + .expect("owner should exist"); + assert_eq!(journal.contender_epoch, Some(current.owner_epoch)); + assert_eq!( + current.selected_external_owner, + Some(hypercolor_macos_owner::MacosExternalOwnerMode::DirectLaunchd) + ); +} + +#[test] +fn crash_replay_carries_the_exact_current_incarnation_across_the_executor_seam() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("sidecar", 4_242)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("exact-stop-replay"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(false, true, 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::StopRequested, + ) + .expect("crash phase should persist"); + + let mut executor = FixtureExecutor::new(store.clone()); + recover_daemon_owner(&store, &mut executor) + .expect("replay should succeed") + .expect("journal should recover"); + + assert_eq!(executor.stopped_incarnations, [prior.incarnation()]); +} + +#[test] +fn crash_replay_never_stops_a_newer_same_topology_incarnation() { + for (phase_index, phase) in [ + MacosHandoverPhase::Prepared, + MacosHandoverPhase::AutostartsConfigured, + MacosHandoverPhase::StopRequested, + ] + .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-old", 4_242)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("same-topology-replacement-{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_from(&journal.transaction_id, MacosHandoverPhase::Prepared, phase) + .expect("crash phase should persist"); + } + let replacement = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity("sidecar-replacement", 4_242), + ) + .expect("same-topology replacement should publish"); + + let mut executor = FixtureExecutor::new(store.clone()); + executor.guard_results = VecDeque::from([false]); + let outcome = recover_daemon_owner(&store, &mut executor) + .expect("newer prior publication should complete rollback") + .expect("journal should recover"); + + assert!(matches!( + outcome, + MacosOwnerCoordinatorOutcome::RolledBack { + prior_owner: MacosDaemonOwner::AppSidecar, + .. + } + )); + assert_eq!(executor.app_sidecar_stop_preflights, 0); + assert!(executor.stopped_incarnations.is_empty()); + assert_eq!( + executor.operations, + [ + MacosHandoverOperation::SetAppSidecarAutostart { enabled: true }, + MacosHandoverOperation::SetDirectLaunchdAutostart { enabled: false }, + MacosHandoverOperation::SetHomebrewAutostart { enabled: false }, + ] + ); + assert_eq!(executor.guard_results, VecDeque::from([false])); + assert!(replacement.owner_epoch > prior.owner_epoch); + } +} + +#[test] +fn legacy_unbound_rollback_stop_phases_fail_closed_without_mutation() { + let phases = [ + MacosHandoverPhase::RollbackStopRequested, + MacosHandoverPhase::RollbackOwnerStopped, + MacosHandoverPhase::RollbackAwaitingGuardRelease, + ]; + + for (phase_index, phase) in phases.into_iter().enumerate() { + for (epoch_index, contender_epoch) in [None, Some(())].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("legacy-prior", 101)) + .expect("initial owner should publish"); + let mut journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("legacy-rollback-{phase_index}-{epoch_index}")), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + journal.contender_epoch = contender_epoch.map(|()| prior.owner_epoch); + let journal = store.begin_handover(journal).expect("journal should begin"); + let persisted = store + .advance_handover_from(&journal.transaction_id, MacosHandoverPhase::Prepared, phase) + .expect("legacy rollback phase should persist"); + + let mut executor = FixtureExecutor::new(store.clone()); + executor.guard_results = VecDeque::from([false]); + for _ in 0..2 { + assert_eq!( + recover_daemon_owner(&store, &mut executor) + .expect("legacy recovery should fail closed"), + Some(MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase, + }) + ); + let current = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should remain pending"); + assert_eq!(current.phase, phase); + assert_eq!(current.journal_revision, persisted.journal_revision); + } + assert!(executor.operations.is_empty()); + assert!(executor.stopped_incarnations.is_empty()); + assert_eq!(executor.guard_results, VecDeque::from([false])); + } + } +} + +#[test] +fn cli_without_retained_sidecar_child_fails_before_handover_mutation() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::AppSidecar, identity("cli-sidecar", 101)) + .expect("initial owner should publish"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.app_sidecar_preflight_requires_retained_child = true; + executor.guard_results = VecDeque::from([false]); + + let error = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("cli-unretained-sidecar"), + ) + .expect_err("CLI without a retained child must fail preflight"); + + assert!(error.to_string().contains("retained child handle")); + assert_eq!(executor.app_sidecar_stop_preflights, 1); + assert_eq!(executor.app_sidecar_stop_attempts, 0); + assert_eq!(executor.autostarts, [true, false, false]); + assert!(executor.operations.is_empty()); + assert!(executor.stopped_incarnations.is_empty()); + assert_eq!(executor.guard_results, VecDeque::from([false])); + assert!( + store + .load_handover_journal() + .expect("journal lookup should succeed") + .is_none() + ); + assert_eq!( + store + .load_owner_record() + .expect("owner record should load") + .expect("prior owner should remain published") + .incarnation(), + prior.incarnation() + ); +} + +#[test] +fn cli_without_retained_sidecar_child_cannot_mutate_replayed_forward_phases() { + for (phase_index, phase) in [ + MacosHandoverPhase::Prepared, + MacosHandoverPhase::AutostartsConfigured, + MacosHandoverPhase::StopRequested, + ] + .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("cli-replay", 101)) + .expect("initial owner should publish"); + let journal = MacosHandoverJournal::for_owner_choice( + transaction(&format!("cli-replay-{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"); + let persisted = if phase == MacosHandoverPhase::Prepared { + journal + } else { + store + .advance_handover_from(&journal.transaction_id, MacosHandoverPhase::Prepared, phase) + .expect("replay phase should persist") + }; + let mut executor = FixtureExecutor::new(store.clone()); + executor.app_sidecar_preflight_requires_retained_child = true; + executor.guard_results = VecDeque::from([false]); + + let error = choose_daemon_owner( + &store, + &mut executor, + MacosDaemonOwner::DirectLaunchd, + transaction("blocked-replay-choice"), + ) + .expect_err("replay without retained stop authority must fail preflight"); + + assert!(error.to_string().contains("retained child handle")); + assert_eq!(executor.app_sidecar_stop_preflights, 1); + assert_eq!(executor.app_sidecar_stop_attempts, 0); + assert_eq!(executor.autostarts, [true, false, false]); + assert!(executor.operations.is_empty()); + assert!(executor.stopped_incarnations.is_empty()); + assert_eq!(executor.guard_results, VecDeque::from([false])); + let current = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should remain pending"); + assert_eq!(current.phase, phase); + assert_eq!(current.journal_revision, persisted.journal_revision); + } +} + +#[test] +fn cli_without_retained_sidecar_child_cannot_mutate_bound_rollback_replay() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity("rollback-prior", 101), + ) + .expect("initial owner should publish"); + let requested = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity("rollback-sidecar", 202), + ) + .expect("requested owner should publish"); + let mut journal = MacosHandoverJournal::for_owner_choice( + transaction("cli-bound-rollback"), + MacosDaemonOwner::AppSidecar, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(false, true, false), + ) + .expect("journal should build"); + journal.contender_epoch = Some(requested.owner_epoch); + let journal = store.begin_handover(journal).expect("journal should begin"); + let persisted = store + .advance_handover_from( + &journal.transaction_id, + MacosHandoverPhase::Prepared, + MacosHandoverPhase::RollbackPending, + ) + .expect("rollback phase should persist"); + let mut executor = FixtureExecutor::new(store.clone()); + executor.autostarts = [true, false, false]; + executor.app_sidecar_preflight_requires_retained_child = true; + executor.guard_results = VecDeque::from([false]); + + let error = recover_daemon_owner(&store, &mut executor) + .expect_err("rollback without retained stop authority must fail preflight"); + + assert!(error.to_string().contains("retained child handle")); + assert_eq!(executor.app_sidecar_stop_preflights, 1); + assert_eq!(executor.app_sidecar_stop_attempts, 0); + assert_eq!(executor.autostarts, [true, false, false]); + assert!(executor.operations.is_empty()); + assert!(executor.stopped_incarnations.is_empty()); + assert_eq!(executor.guard_results, VecDeque::from([false])); + let current = store + .load_handover_journal() + .expect("journal should load") + .expect("journal should remain pending"); + assert_eq!(current.phase, MacosHandoverPhase::RollbackPending); + assert_eq!(current.journal_revision, persisted.journal_revision); +} + +#[test] +fn stop_request_keeps_new_owner_publication_outside_the_validated_window() { + use std::sync::mpsc; + + let directory = tempfile::tempdir().expect("temporary directory should build"); + let store = MacosOwnerStore::new(directory.path()); + let prior = store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("direct-old", 101)) + .expect("initial owner should publish"); + let publisher_store = store.clone(); + let (start_tx, start_rx) = mpsc::sync_channel(0); + let (attempt_tx, attempt_rx) = mpsc::sync_channel(0); + let (published_tx, published_rx) = mpsc::sync_channel(0); + let publisher = std::thread::spawn(move || { + start_rx.recv().expect("publisher should be released"); + attempt_tx.send(()).expect("attempt should be visible"); + let replacement = publisher_store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("direct-new", 202)) + .expect("replacement should publish after the stop request"); + published_tx + .send(replacement) + .expect("replacement should be observable"); + }); + + store + .request_stop_if_current(&prior.incarnation(), || { + start_tx.send(()).expect("publisher should start"); + attempt_rx + .recv() + .expect("publisher should attempt publication"); + assert!( + published_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "publication must remain blocked while the stop request is active" + ); + Ok(()) + }) + .expect("exact stop request should run"); + + let replacement = published_rx + .recv_timeout(Duration::from_secs(1)) + .expect("publication should complete after the stop request"); + publisher.join().expect("publisher should finish"); + assert!(replacement.owner_epoch > prior.owner_epoch); +} + +#[test] +fn rollback_replay_never_stops_a_newer_requested_owner_incarnation() { + 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 requested = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity("requested", 4_242), + ) + .expect("requested owner should publish"); + let journal = MacosHandoverJournal::new( + transaction("rollback-requested-replacement"), + MacosDaemonOwner::DirectLaunchd, + MacosDaemonOwner::AppSidecar, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + vec![MacosHandoverOperation::FlushAndStopDirectLaunchd {}], + prior.owner_epoch, + Some(requested.owner_epoch), + None, + ); + let journal = store.begin_handover(journal).expect("journal should begin"); + store + .advance_handover_from( + &journal.transaction_id, + MacosHandoverPhase::Prepared, + MacosHandoverPhase::RollbackStopRequested, + ) + .expect("rollback crash phase should persist"); + let replacement = store + .publish_owner( + MacosDaemonOwner::DirectLaunchd, + identity("requested-replacement", requested.active_identity.pid), + ) + .expect("same-topology replacement should publish"); + + let mut executor = FixtureExecutor::new(store.clone()); + assert!(recover_daemon_owner(&store, &mut executor).is_err()); + assert!(executor.stopped_incarnations.is_empty()); + assert!(replacement.owner_epoch > requested.owner_epoch); +} + +#[test] +fn journal_v1_serialized_key_set_remains_unchanged() { + let prior = MacosOwnerRecord::new(MacosDaemonOwner::AppSidecar, identity("sidecar", 101), None); + let journal = MacosHandoverJournal::for_owner_choice( + transaction("v1-key-compatibility"), + MacosDaemonOwner::DirectLaunchd, + &prior, + hypercolor_macos_owner::MacosAutostartStates::new(true, false, false), + ) + .expect("journal should build"); + let value = serde_json::to_value(journal).expect("journal should encode"); + let mut keys = value + .as_object() + .expect("journal should be an object") + .keys() + .map(String::as_str) + .collect::>(); + keys.sort_unstable(); + + assert_eq!( + keys, + [ + "active_epoch", + "allowed_forward_operations", + "allowed_rollback_operations", + "contender_epoch", + "journal_revision", + "pending_standalone_pid", + "phase", + "prior_autostart_states", + "prior_owner", + "requested_owner", + "schema_version", + "transaction_id", + ] + ); +} + +#[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_restores_prior_autostarts_and_requires_exact_recovery() { + 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_eq!( + outcome, + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: MacosHandoverPhase::RollbackAutostartsRestored, + } + ); + 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::RollbackAutostartsRestored + ); + assert_eq!(journal.journal_revision, 9); +} + +#[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_eq!( + outcome, + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: MacosHandoverPhase::RollbackAutostartsRestored, + } + ); + 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 requested_epoch = if matches!( + phase, + MacosHandoverPhase::RequestedOwnerStarted + | MacosHandoverPhase::CommitPending + | MacosHandoverPhase::RollbackPending + | MacosHandoverPhase::RollbackAutostartsRestored + | MacosHandoverPhase::RollbackStopRequested + | MacosHandoverPhase::RollbackOwnerStopped + | MacosHandoverPhase::RollbackAwaitingGuardRelease + | MacosHandoverPhase::RollbackGuardReleased + | MacosHandoverPhase::RollbackStartRequested + ) { + Some( + store + .publish_owner(MacosDaemonOwner::DirectLaunchd, identity("direct", 202)) + .expect("requested owner fixture should publish") + .owner_epoch, + ) + } else { + None + }; + let mut 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"); + if phase != MacosHandoverPhase::RequestedOwnerStarted { + journal.contender_epoch = requested_epoch; + } + 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"); + } + + 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_failure_and_guard_timeout_do_not_trust_a_stale_owner_record() { + for (label, failure, guard_results, remaining_guard_results) in [ + ( + "stop-failure", + Some(MacosHandoverOperation::FlushAndStopAppSidecar {}), + VecDeque::from([true]), + VecDeque::from([true]), + ), + ( + "guard-timeout", + None, + VecDeque::from([false, true]), + VecDeque::from([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("ambiguous rollback should fail closed"); + + assert_eq!( + outcome, + MacosOwnerCoordinatorOutcome::RecoveryRequired { + requested_owner: MacosDaemonOwner::DirectLaunchd, + prior_owner: MacosDaemonOwner::AppSidecar, + phase: MacosHandoverPhase::RollbackAutostartsRestored, + } + ); + assert_eq!( + store + .load_handover_journal() + .expect("journal should load") + .expect("journal should exist") + .phase, + MacosHandoverPhase::RollbackAutostartsRestored + ); + assert_eq!(executor.guard_results, remaining_guard_results); + assert!( + !executor + .operations + .iter() + .any(|operation| matches!(operation, MacosHandoverOperation::StartAppSidecar {})) + ); + } +} + +#[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_observes_only_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::wait_for_macos_guard_release( + Duration::from_secs(1), + &waiter_name, + )) + .expect("guard result should send"); + }); + + assert!(matches!( + receiver.recv_timeout(Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + drop(winner); + assert!( + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("waiter should observe guard release") + .expect("waiter should inspect the guard") + ); + 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-macos-owner/tests/session_attestation_tests.rs b/crates/hypercolor-macos-owner/tests/session_attestation_tests.rs new file mode 100644 index 000000000..c355aadea --- /dev/null +++ b/crates/hypercolor-macos-owner/tests/session_attestation_tests.rs @@ -0,0 +1,312 @@ +#![cfg(target_os = "macos")] + +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use hypercolor_macos_owner::{ + MACOS_DAEMON_SESSION_ATTESTATION_SCHEMA_VERSION, MAX_MACOS_OWNER_ARTIFACT_BYTES, + MacosDaemonOwner, MacosOwnerIdentity, MacosOwnerStore, MacosProtectedControlCredential, + MacosServerSessionId, try_acquire_macos_daemon_guard, +}; +use serde_json::{Value, json}; + +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 publish_fixture( + directory: &tempfile::TempDir, + label: &str, +) -> ( + MacosOwnerStore, + hypercolor_macos_owner::MacosDaemonGuard, + hypercolor_macos_owner::MacosOwnerRecord, + hypercolor_macos_owner::MacosDaemonSessionAttestation, +) { + let store = MacosOwnerStore::new(directory.path().join("state")); + let guard_path = directory.path().join("daemon.lock"); + let guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("guard lookup should succeed") + .expect("fixture should acquire the canonical guard"); + let record = store + .publish_guard_winner( + &guard, + MacosDaemonOwner::AppSidecar, + identity(label, std::process::id()), + ) + .expect("guard winner should publish"); + let attestation = store + .publish_daemon_session_attestation(&guard, &record.incarnation()) + .expect("exact guard winner should publish a session"); + (store, guard, record, attestation) +} + +#[test] +fn session_attestation_is_private_exact_and_not_an_owner_lease() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let (store, guard, record, attestation) = publish_fixture(&directory, "first"); + let path = store.daemon_session_attestation_path(); + + assert_eq!( + fs::metadata(&path) + .expect("attestation metadata should load") + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!(attestation.owner_incarnation(), record.incarnation()); + assert_eq!( + store + .load_daemon_session_attestation() + .expect("attestation should load"), + Some(attestation.clone()) + ); + assert!( + try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("contending guard lookup should succeed") + .is_none(), + "session publication must not create or replace the canonical owner lease" + ); + + let secret = attestation + .protected_control_credential + .expose_secret() + .to_owned(); + let debug = format!("{attestation:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(&secret)); + + assert!( + store + .clear_daemon_session_attestation( + &record.incarnation(), + &attestation.server_session_id, + ) + .expect("matching session should clear") + ); + assert!(!path.exists()); + drop(guard); +} + +#[test] +fn load_rejects_wrong_mode_topology_identity_and_epoch() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let (store, _guard, _record, _attestation) = publish_fixture(&directory, "validation"); + let path = store.daemon_session_attestation_path(); + let valid = fs::read(&path).expect("valid attestation should read"); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)) + .expect("fixture mode should change"); + assert!( + store + .load_daemon_session_attestation() + .expect_err("public mode must fail closed") + .to_string() + .contains("mode must be 0600") + ); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("fixture mode should restore"); + let mut wrong_topology: Value = + serde_json::from_slice(&valid).expect("valid attestation should decode as JSON"); + wrong_topology["owner"] = json!("standalone"); + fs::write( + &path, + serde_json::to_vec_pretty(&wrong_topology).expect("fixture should encode"), + ) + .expect("wrong topology fixture should write"); + assert!( + store + .load_daemon_session_attestation() + .expect_err("wrong topology must fail closed") + .to_string() + .contains("not current") + ); + + let mut wrong_identity: Value = + serde_json::from_slice(&valid).expect("valid attestation should decode as JSON"); + wrong_identity["owner_identity"]["pid"] = json!(std::process::id().saturating_add(1)); + fs::write( + &path, + serde_json::to_vec_pretty(&wrong_identity).expect("fixture should encode"), + ) + .expect("wrong identity fixture should write"); + assert!( + store + .load_daemon_session_attestation() + .expect_err("wrong identity must fail closed") + .to_string() + .contains("not current") + ); + + let mut wrong_epoch: Value = + serde_json::from_slice(&valid).expect("valid attestation should decode as JSON"); + wrong_epoch["owner_epoch"] = json!(99); + fs::write( + &path, + serde_json::to_vec_pretty(&wrong_epoch).expect("fixture should encode"), + ) + .expect("wrong epoch fixture should write"); + assert!( + store + .load_daemon_session_attestation() + .expect_err("wrong epoch must fail closed") + .to_string() + .contains("not current") + ); +} + +#[test] +fn publication_requires_the_exact_current_incarnation() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let (store, guard, record, _attestation) = publish_fixture(&directory, "publication"); + let mut stale = record.incarnation(); + stale.owner_epoch = stale.owner_epoch.saturating_add(1); + + assert!( + store + .publish_daemon_session_attestation(&guard, &stale) + .expect_err("noncurrent incarnation must not publish") + .to_string() + .contains("guard-winning incarnation") + ); +} + +#[test] +fn load_rejects_an_oversized_session_artifact() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let (store, _guard, _record, _attestation) = publish_fixture(&directory, "bounded"); + fs::write( + store.daemon_session_attestation_path(), + vec![b'x'; MAX_MACOS_OWNER_ARTIFACT_BYTES + 1], + ) + .expect("oversized fixture should write"); + + assert!( + store + .load_daemon_session_attestation() + .expect_err("oversized session must fail before decoding") + .to_string() + .contains("exceeds the 262144-byte limit") + ); +} + +#[test] +fn clear_requires_the_exact_owner_epoch_identity_and_session() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let (store, _guard, record, attestation) = publish_fixture(&directory, "clear"); + let wrong_session = MacosServerSessionId::from_bytes([0x55; 16]); + assert!( + store + .clear_daemon_session_attestation(&record.incarnation(), &wrong_session) + .expect_err("wrong session must not clear") + .to_string() + .contains("does not match") + ); + assert!(store.daemon_session_attestation_path().exists()); + + let replacement = store + .publish_owner( + MacosDaemonOwner::AppSidecar, + identity("replacement", std::process::id()), + ) + .expect("new owner epoch should publish"); + assert!( + store + .clear_daemon_session_attestation( + &record.incarnation(), + &attestation.server_session_id, + ) + .expect_err("stale owner must not clear") + .to_string() + .contains("clearing incarnation") + ); + assert!(replacement.owner_epoch > record.owner_epoch); + assert!(store.daemon_session_attestation_path().exists()); +} + +#[test] +fn next_guard_winner_replaces_a_stale_crash_session() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let guard_path = directory.path().join("daemon.lock"); + let (store, first_guard, first_record, first) = publish_fixture(&directory, "crashed"); + drop(first_guard); + + let second_guard = try_acquire_macos_daemon_guard(&guard_path.to_string_lossy()) + .expect("second guard lookup should succeed") + .expect("next process should acquire the released guard"); + let second_record = store + .publish_guard_winner( + &second_guard, + MacosDaemonOwner::DirectLaunchd, + identity("replacement", std::process::id()), + ) + .expect("next guard winner should publish"); + let second = store + .publish_daemon_session_attestation(&second_guard, &second_record.incarnation()) + .expect("next guard winner should replace the stale session"); + + assert!(second_record.owner_epoch > first_record.owner_epoch); + assert_ne!(second.server_session_id, first.server_session_id); + assert_ne!( + second.protected_control_credential, + first.protected_control_credential + ); + assert_eq!( + store + .load_daemon_session_attestation() + .expect("replacement should load"), + Some(second) + ); +} + +#[test] +fn schema_v1_shape_is_separate_and_credential_has_256_random_bits() { + let directory = tempfile::tempdir().expect("temporary directory should build"); + let (_store, _guard, _record, attestation) = publish_fixture(&directory, "shape"); + let value = serde_json::to_value(&attestation).expect("attestation should encode"); + let mut keys = value + .as_object() + .expect("attestation should be an object") + .keys() + .map(String::as_str) + .collect::>(); + keys.sort_unstable(); + + assert_eq!( + keys, + [ + "owner", + "owner_epoch", + "owner_identity", + "protected_control_credential", + "schema_version", + "server_session_id", + ] + ); + assert_eq!( + attestation.schema_version, + MACOS_DAEMON_SESSION_ATTESTATION_SCHEMA_VERSION + ); + assert_eq!( + attestation + .protected_control_credential + .expose_secret() + .strip_prefix("hc_pc_") + .expect("credential should have its type prefix") + .len(), + 64 + ); + assert_ne!( + attestation.protected_control_credential, + MacosProtectedControlCredential::from_bytes([0; 32]) + ); +} 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/src/config.rs b/crates/hypercolor-types/src/config.rs index 6824829f4..9d085d34f 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; @@ -124,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 } @@ -677,6 +693,17 @@ impl Default for AudioConfig { // ─── Screen Capture ────────────────────────────────────────────────────────── +/// Native acquisition cadence for screen capture. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CaptureCadenceMode { + /// Acquire at `capture_fps`. + #[default] + Fixed, + /// Allow the native backend to acquire at the display refresh cadence. + NativeRefresh, +} + /// Screen capture settings for ambient lighting effects. /// /// The capture source is chosen interactively through the desktop portal @@ -692,6 +719,9 @@ pub struct CaptureConfig { #[serde(default = "defaults::capture_fps")] pub capture_fps: u32, + #[serde(default)] + pub cadence: CaptureCadenceMode, + /// Sector grid columns for ambilight zone sampling. #[serde(default = "defaults::capture_grid_cols")] pub grid_cols: u32, @@ -742,6 +772,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, @@ -754,6 +804,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 +822,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 } @@ -815,6 +871,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 { @@ -860,6 +936,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); @@ -906,6 +1012,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,15 +1042,33 @@ 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 { enabled: defaults::capture_enabled(), source: defaults::capture_source(), capture_fps: defaults::capture_fps(), + cadence: CaptureCadenceMode::default(), grid_cols: defaults::capture_grid_cols(), grid_rows: defaults::capture_grid_rows(), publication_memory_bytes: None, @@ -954,6 +1079,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/src/event.rs b/crates/hypercolor-types/src/event.rs index 8ba99f883..e95b1bb53 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, .. } @@ -369,6 +404,61 @@ 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, +} + +/// 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 { + pub active: MacosDaemonOwnerEvent, + pub contender: MacosDaemonOwnerEvent, + 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 { @@ -904,6 +994,14 @@ pub enum HypercolorEvent { reason: String, }, + /// The authoritative macOS daemon owner or contender changed. + MacosDaemonOwnershipChanged { + active_owner: MacosDaemonOwnerEvent, + owner_epoch: u64, + conflict: Option, + recovery_required: Option, + }, + /// Global brightness changed. BrightnessChanged { old: u8, new_value: u8 }, @@ -1096,6 +1194,7 @@ impl HypercolorEvent { | Self::ShutdownRequested { .. } | Self::DaemonStarted { .. } | Self::DaemonShutdown { .. } + | Self::MacosDaemonOwnershipChanged { .. } | Self::BrightnessChanged { .. } | Self::Paused | Self::Resumed 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-types/tests/config_tests.rs b/crates/hypercolor-types/tests/config_tests.rs index 445662d19..f0f0e9aca 100644 --- a/crates/hypercolor-types/tests/config_tests.rs +++ b/crates/hypercolor-types/tests/config_tests.rs @@ -1,12 +1,13 @@ //! Tests for configuration types — defaults, serde roundtrips, partial deserialization. use hypercolor_types::config::{ - AudioConfig, CaptureConfig, CaptureConfigValidationError, CapturePlatform, DaemonConfig, - DbusConfig, DiscoveryConfig, DisplayConfig, EffectEngineConfig, EffectErrorFallbackPolicy, - FeatureFlags, GoveeConfig, HypercolorConfig, InputConfig, InteractionRoutePolicy, LogLevel, - McpConfig, MediaConfig, NetworkAccessMode, NetworkClientScope, NetworkConfig, - RenderAccelerationMode, RenderingConfig, ServoGpuImportConfig, ServoGpuImportMode, - ShutdownBehavior, TuiConfig, WebConfig, default_driver_configs, + AudioConfig, CaptureCadenceMode, CaptureConfig, CaptureConfigValidationError, CapturePlatform, + DaemonConfig, DbusConfig, DiscoveryConfig, DisplayConfig, EffectEngineConfig, + EffectErrorFallbackPolicy, FeatureFlags, GoveeConfig, HypercolorConfig, InputConfig, + InteractionRoutePolicy, LogLevel, McpConfig, MediaConfig, NetworkAccessMode, + NetworkClientScope, NetworkConfig, RenderAccelerationMode, RenderingConfig, + ServoGpuImportConfig, ServoGpuImportMode, ShutdownBehavior, TuiConfig, WebConfig, + default_driver_configs, }; use hypercolor_types::session::{OffOutputBehavior, SessionConfig}; @@ -101,6 +102,7 @@ fn capture_defaults_match_spec() { let c = CaptureConfig::default(); assert_eq!(c.source, "auto"); assert_eq!(c.capture_fps, 30); + assert_eq!(c.cadence, CaptureCadenceMode::Fixed); assert_eq!(c.grid_cols, 8); assert_eq!(c.grid_rows, 6); assert_eq!(c.publication_memory_bytes, None); @@ -113,9 +115,64 @@ 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_native_refresh_is_explicit_and_roundtrips_without_a_zero_sentinel() { + let config: CaptureConfig = toml::from_str("capture_fps = 30\ncadence = \"native_refresh\"\n") + .expect("native refresh capture config parses"); + assert_eq!(config.capture_fps, 30); + assert_eq!(config.cadence, CaptureCadenceMode::NativeRefresh); + config + .validate_for_platform(CapturePlatform::MacosScreenCaptureKit) + .expect("native refresh retains a valid analysis cadence"); + + let serialized = toml::to_string(&config).expect("capture config serializes"); + let roundtrip: CaptureConfig = + toml::from_str(&serialized).expect("serialized capture config parses"); + assert_eq!(roundtrip.cadence, CaptureCadenceMode::NativeRefresh); + assert_eq!(roundtrip.capture_fps, 30); +} + +#[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")] + 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 +187,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; @@ -201,6 +259,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; @@ -248,6 +395,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 { diff --git a/crates/hypercolor-types/tests/event_tests.rs b/crates/hypercolor-types/tests/event_tests.rs index 000bfb85f..006892afd 100644 --- a/crates/hypercolor-types/tests/event_tests.rs +++ b/crates/hypercolor-types/tests/event_tests.rs @@ -8,6 +8,8 @@ use hypercolor_types::event::{ AssetChangeKind, ChangeTrigger, ContextType, DisconnectReason, EffectDegradationState, EffectRef, EffectStopReason, EventCategory, EventControlValue, EventPriority, FrameData, FrameTiming, HypercolorEvent, InputButtonState, InputEvent, LayerHealth, LayerStackChangeKind, + MacosDaemonHandoverPhaseEvent, MacosDaemonOwnerConflictEvent, MacosDaemonOwnerEvent, + MacosDaemonOwnerRecoveryRequiredEvent, PointerScrollPhase, PointerScrollUnit, SceneChangeReason, Severity, TimedInputEvent, TransitionRef, ZoneChangeKind, ZoneColors, ZoneRef, }; @@ -306,6 +308,12 @@ fn system_events_have_system_category() { HypercolorEvent::DaemonShutdown { reason: "user".into(), }, + HypercolorEvent::MacosDaemonOwnershipChanged { + active_owner: MacosDaemonOwnerEvent::AppSidecar, + owner_epoch: 7, + conflict: None, + recovery_required: None, + }, HypercolorEvent::BrightnessChanged { old: 100, new_value: 50, @@ -329,6 +337,40 @@ 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, + }), + 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"); + 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!( + json["data"]["recovery_required"]["phase"], + "rollback_start_requested" + ); + assert_eq!( + serde_json::from_value::(json) + .expect("deserialize ownership event") + .category(), + EventCategory::System + ); +} + #[test] fn automation_events_have_automation_category() { let events = vec![ @@ -1250,6 +1292,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}"#; diff --git a/crates/hypercolor-ui/src/api/assets.rs b/crates/hypercolor-ui/src/api/assets.rs index 863fb8e5d..6e18c332e 100644 --- a/crates/hypercolor-ui/src/api/assets.rs +++ b/crates/hypercolor-ui/src/api/assets.rs @@ -1,6 +1,6 @@ //! User media asset API client. -use gloo_net::http::Request; +use gloo_net::http::Method; use serde::{Deserialize, Serialize}; use web_sys::{File, FormData}; @@ -73,7 +73,8 @@ pub async fn upload_asset(file: File) -> Result { .append_with_blob_and_filename("file", &file, &file.name()) .map_err(|error| format!("{error:?}"))?; - let response = client::with_auth(Request::post("/api/v1/assets")) + let request = client::request(Method::POST, "/api/v1/assets").map_err(String::from)?; + let response = request .body(form_data) .map_err(|error| error.to_string())? .send() diff --git a/crates/hypercolor-ui/src/api/client.rs b/crates/hypercolor-ui/src/api/client.rs index 32d499b95..5528189c8 100644 --- a/crates/hypercolor-ui/src/api/client.rs +++ b/crates/hypercolor-ui/src/api/client.rs @@ -9,9 +9,9 @@ //! `Result` can convert via `?` (see `From for String`) //! or `map_err(Into::into)`. -use std::fmt; +use std::{cell::RefCell, fmt}; -use gloo_net::http::{Method, Request, RequestBuilder, Response}; +use gloo_net::http::{Method, RequestBuilder, Response}; use serde::{Serialize, de::DeserializeOwned}; use super::ApiEnvelope; @@ -19,6 +19,33 @@ use super::ApiEnvelope; #[cfg(target_arch = "wasm32")] const API_KEY_STORAGE_KEY: &str = "hypercolor.api_key"; +thread_local! { + static DAEMON_TRANSPORT: RefCell = RefCell::new(DaemonTransport::default()); +} + +#[derive(Clone, Default, PartialEq, Eq)] +struct DaemonTransport { + native_app: bool, + base_url: Option, + protected_control_credential: Option, +} + +impl DaemonTransport { + fn resolve_url(&self, url: &str) -> Option { + if !url.starts_with('/') { + return Some(url.to_owned()); + } + self.base_url + .as_ref() + .map(|base| format!("{}{url}", base.trim_end_matches('/'))) + .or_else(|| (!self.native_app).then(|| url.to_owned())) + } + + fn authorization_token(&self, stored_api_key: Option) -> Option { + self.protected_control_credential.clone().or(stored_api_key) + } +} + // ── Error type ────────────────────────────────────────────────────────────── /// Typed error surface for HTTP operations. @@ -139,9 +166,67 @@ pub fn save_api_key(api_key: &str) { save_api_key_impl(api_key); } -pub(crate) fn with_auth(request: RequestBuilder) -> RequestBuilder { - if let Some(api_key) = stored_api_key() { - request.header("Authorization", &format!("Bearer {api_key}")) +/// Configure the daemon base URL held only for this browser process. +pub fn begin_native_daemon_verification() { + DAEMON_TRANSPORT.with_borrow_mut(|transport| { + transport.native_app = true; + transport.base_url = None; + transport.protected_control_credential = None; + }); +} + +/// Install an exact verified daemon connection without persistent storage. +pub fn install_verified_daemon_connection(base_url: &str, credential: Option<&str>) { + let base_url = base_url.trim().trim_end_matches('/'); + let credential = credential + .map(str::trim) + .filter(|credential| !credential.is_empty()); + DAEMON_TRANSPORT.with_borrow_mut(|transport| { + transport.native_app = true; + transport.base_url = (!base_url.is_empty()).then(|| base_url.to_owned()); + transport.protected_control_credential = credential.map(str::to_owned); + }); +} + +/// Remove both parts of the verified native daemon connection. +pub fn clear_verified_daemon_connection() { + DAEMON_TRANSPORT.with_borrow_mut(|transport| { + transport.base_url = None; + transport.protected_control_credential = None; + }); +} + +#[cfg(test)] +pub(crate) fn reset_daemon_transport_for_test() { + DAEMON_TRANSPORT.with_borrow_mut(|transport| *transport = DaemonTransport::default()); +} + +/// Resolve a daemon-relative URL against the in-memory native base route. +#[must_use] +pub fn daemon_url(url: &str) -> Option { + DAEMON_TRANSPORT.with_borrow(|transport| transport.resolve_url(url)) +} + +/// Select the in-memory protected credential before any stored public key. +#[must_use] +pub fn authorization_token() -> Option { + DAEMON_TRANSPORT.with_borrow(|transport| transport.authorization_token(stored_api_key())) +} + +pub(crate) fn request(method: Method, url: &str) -> Result { + if !url.starts_with('/') { + return Err(ApiError::Network( + "authenticated daemon API URLs must be relative".to_owned(), + )); + } + let url = daemon_url(url) + .ok_or_else(|| ApiError::Network("verified daemon connection is unavailable".to_owned()))?; + Ok(with_auth(RequestBuilder::new(&url).method(method))) +} + +fn with_auth(request: RequestBuilder) -> RequestBuilder { + if let Some(token) = authorization_token() { + request.header("Authorization", &format!("Bearer {token}")) } else { request } @@ -199,7 +284,7 @@ async fn send_request( where Req: Serialize + ?Sized, { - let mut builder = with_auth(RequestBuilder::new(url).method(method)); + let mut builder = request(method, url)?; if let Some(version) = if_match { builder = builder.header("If-Match", &version.to_string()); } @@ -316,7 +401,7 @@ pub async fn fetch_json_optional(url: &str) -> Result, ApiError> where T: DeserializeOwned, { - let resp = with_auth(Request::get(url)) + let resp = request(Method::GET, url)? .send() .await .map_err(|e| ApiError::Network(e.to_string()))?; @@ -411,7 +496,62 @@ pub async fn delete_empty(url: &str) -> Result<(), ApiError> { #[cfg(test)] mod tests { - use super::{ApiError, MutationOutcome, extract_error_message, stale_current_version}; + use super::{ + ApiError, DaemonTransport, MutationOutcome, authorization_token, + begin_native_daemon_verification, clear_verified_daemon_connection, daemon_url, + extract_error_message, install_verified_daemon_connection, reset_daemon_transport_for_test, + stale_current_version, + }; + + #[test] + fn native_transport_routes_relative_urls_and_preserves_absolute_urls() { + let transport = DaemonTransport { + native_app: true, + base_url: Some("http://127.0.0.1:9420".to_owned()), + protected_control_credential: None, + }; + assert_eq!( + transport.resolve_url("/api/v1/devices"), + Some("http://127.0.0.1:9420/api/v1/devices".to_owned()) + ); + assert_eq!( + transport.resolve_url("https://example.test/image.png"), + Some("https://example.test/image.png".to_owned()) + ); + } + + #[test] + fn verified_credential_precedes_public_key_and_clears_without_persistence() { + let transport = DaemonTransport { + native_app: true, + base_url: None, + protected_control_credential: Some("protected".to_owned()), + }; + assert_eq!( + transport.authorization_token(Some("public".to_owned())), + Some("protected".to_owned()) + ); + + begin_native_daemon_verification(); + assert_eq!(daemon_url("/api/v1/server"), None); + install_verified_daemon_connection("http://127.0.0.1:9420", Some("protected")); + assert!( + super::request( + gloo_net::http::Method::GET, + "https://attacker.example/steal" + ) + .is_err() + ); + assert_eq!(authorization_token().as_deref(), Some("protected")); + assert_eq!( + daemon_url("/api/v1/server"), + Some("http://127.0.0.1:9420/api/v1/server".to_owned()) + ); + clear_verified_daemon_connection(); + assert_eq!(authorization_token(), None); + assert_eq!(daemon_url("/api/v1/server"), None); + reset_daemon_transport_for_test(); + } #[test] fn stale_current_version_parses_daemon_412_body() { diff --git a/crates/hypercolor-ui/src/api/config.rs b/crates/hypercolor-ui/src/api/config.rs index ec9c0697d..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> { @@ -85,6 +75,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.") diff --git a/crates/hypercolor-ui/src/api/displays.rs b/crates/hypercolor-ui/src/api/displays.rs index a20f67a36..b98acdc11 100644 --- a/crates/hypercolor-ui/src/api/displays.rs +++ b/crates/hypercolor-ui/src/api/displays.rs @@ -186,8 +186,9 @@ pub async fn update_display_face_composition( /// URL of the latest composited preview JPEG for a display. #[must_use] pub fn display_preview_url(display_id: &str, cache_buster: Option) -> String { - cache_buster.map_or_else( + client::daemon_url(&cache_buster.map_or_else( || format!("/api/v1/displays/{display_id}/preview.jpg"), |cb| format!("/api/v1/displays/{display_id}/preview.jpg?ts={cb}"), - ) + )) + .unwrap_or_default() } diff --git a/crates/hypercolor-ui/src/api/effects.rs b/crates/hypercolor-ui/src/api/effects.rs index b6447de88..6757f45e4 100644 --- a/crates/hypercolor-ui/src/api/effects.rs +++ b/crates/hypercolor-ui/src/api/effects.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use std::collections::HashMap; -use gloo_net::http::Request; +use gloo_net::http::Method; use hypercolor_types::effect::{ControlDefinition, ControlValue}; use web_sys::{File, FormData}; @@ -38,6 +38,8 @@ pub struct ActiveEffectResponse { pub active_preset_modified: bool, #[serde(default)] pub render_group_id: Option, + #[serde(default)] + pub cover_image_url: Option, /// Server-side controls version (matches the `ETag` header). /// `Some` while an effect is running, `None` on the idle response. /// Clients that want optimistic concurrency echo this back via @@ -51,7 +53,7 @@ pub struct ActiveEffectResponse { /// Fetch all registered effects. pub async fn fetch_effects() -> Result, String> { let list: EffectListResponse = client::fetch_json("/api/v1/effects").await?; - Ok(list.items) + Ok(list.items.into_iter().map(route_effect_summary).collect()) } /// Fetch effects filtered to a single category. @@ -67,6 +69,7 @@ pub async fn fetch_effects_by_category(category: &str) -> Result Result, Strin active_preset_modified: effect.active_preset_modified, render_group_id: effect.render_group_id, controls_version: effect.controls_version, + cover_image_url: route_cover_image_url(effect.cover_image_url), }) })) } /// Fetch detailed metadata for one effect. pub async fn fetch_effect_detail(id: &str) -> Result { - client::fetch_json(&format!("/api/v1/effects/{}", path_segment(id))) - .await - .map_err(Into::into) + let mut detail: EffectDetailResponse = + client::fetch_json(&format!("/api/v1/effects/{}", path_segment(id))) + .await + .map_err(String::from)?; + detail.cover_image_url = route_cover_image_url(detail.cover_image_url); + Ok(detail) +} + +fn route_effect_summary(mut effect: EffectSummary) -> EffectSummary { + effect.cover_image_url = route_cover_image_url(effect.cover_image_url); + effect +} + +fn route_cover_image_url(cover_image_url: Option) -> Option { + cover_image_url.and_then(|url| client::daemon_url(&url)) } /// Fetch the bundled and saved preset stack for one effect. @@ -246,7 +262,8 @@ pub async fn upload_effect(file: File) -> Result Result, } /// Host keyboard/mouse capture health from the daemon status payload. @@ -69,6 +73,109 @@ 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, +} + +/// 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, + 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. +#[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, +} + +/// 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")] +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: 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)] @@ -79,6 +186,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, @@ -90,6 +198,7 @@ pub struct InputSourceStatus { pub issue: Option, pub lifecycle_issue: Option, pub freshness_issue: Option, + pub platform: Option, pub retired: bool, } @@ -144,3 +253,236 @@ 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, + MacosDaemonOwnerRecoveryRequiredStatus, MacosDaemonOwnershipStatus, MacosSelectionStatus, + MacosTahoeSelectionStatus, MacosTahoeStatus, + }; + + #[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 + }, + "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"); + + 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), + }) + ); + 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"); + assert_eq!(partial, MacosDaemonOwnershipStatus::default()); + } + + #[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!({ + "active_consumer_count": 3, + "platform": { + "type": "macos_screen", + "state": "interrupted", + "tcc": "denied", + "owner": "standalone", + "selection": { + "type": "session_scoped", + "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, + "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"); + assert_eq!(status.active_consumer_count, 3); + + let Some(InputSourcePlatformStatus::MacosScreen { + state, + tcc, + owner, + selection, + tahoe, + 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!( + 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 { + 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.active_consumer_count, 0); + 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)); + } +} diff --git a/crates/hypercolor-ui/src/app.rs b/crates/hypercolor-ui/src/app.rs index fda452a8e..6d72463bb 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. @@ -478,7 +480,11 @@ pub fn app_view(ext: UiExtensions) -> impl IntoView { { set_api_key_required.set(true); } - Err(_) => {} + Err(error) => { + leptos::logging::warn!( + "Config fetch failed (retries on the next socket open): {error}" + ); + } } }); }); @@ -495,7 +501,16 @@ pub fn app_view(ext: UiExtensions) -> impl IntoView { refresh: refresh_config, audio_enabled, }); - refresh_config.run(()); + // A one-shot fetch at wasm init loses the race when the daemon is + // still binding (app boot) or mid-restart, and nothing would retry. + // Keying the fetch to the socket generation refires it on every + // WebSocket open, so config heals on the same reconnect that + // refreshes the hint-driven resources. + let config_connection_generation = ws.connection_generation; + Effect::new(move |_| { + let _generation = config_connection_generation.get(); + refresh_config.run(()); + }); let ws_ctx = WsContext { canvas_frame: ws.canvas_frame, @@ -528,6 +543,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/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/components/media_grid.rs b/crates/hypercolor-ui/src/components/media_grid.rs index 804508212..49ac3ca7e 100644 --- a/crates/hypercolor-ui/src/components/media_grid.rs +++ b/crates/hypercolor-ui/src/components/media_grid.rs @@ -61,7 +61,9 @@ fn AssetCard( let icon = kind_icon(kind); let label = kind_label(kind); let has_thumb = kind_has_thumbnail(kind); - let thumbnail_url = format!("/api/v1/assets/{}/thumbnail", asset.id); + let thumbnail_url = + crate::api::client::daemon_url(&format!("/api/v1/assets/{}/thumbnail", asset.id)) + .unwrap_or_default(); let meta_line = format!( "{} · {}", format_bytes(asset.byte_len), diff --git a/crates/hypercolor-ui/src/components/media_preview.rs b/crates/hypercolor-ui/src/components/media_preview.rs index c3ec79d10..f0c48f6de 100644 --- a/crates/hypercolor-ui/src/components/media_preview.rs +++ b/crates/hypercolor-ui/src/components/media_preview.rs @@ -37,7 +37,8 @@ pub fn MediaPreview( on_video_loaded: Option>, ) -> impl IntoView { let kind = asset_kind(&asset); - let blob_url = format!("/api/v1/assets/{}/blob", asset.id); + let blob_url = crate::api::client::daemon_url(&format!("/api/v1/assets/{}/blob", asset.id)) + .unwrap_or_default(); match kind { "video" => view! { diff --git a/crates/hypercolor-ui/src/components/settings_sections.rs b/crates/hypercolor-ui/src/components/settings_sections.rs index b88f63618..160d0d051 100644 --- a/crates/hypercolor-ui/src/components/settings_sections.rs +++ b/crates/hypercolor-ui/src/components/settings_sections.rs @@ -5,12 +5,18 @@ 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::*; +use crate::input_access::{input_status_epoch, screen_status_line}; use crate::render_presets::{ CANVAS_PRESETS, MAX_CUSTOM_CANVAS_HEIGHT, MAX_CUSTOM_CANVAS_WIDTH, canvas_preset_key, }; +use crate::{ + api::{InputSourcePlatformStatus, InputStatus, SystemStatus}, + app::WsContext, +}; mod about; mod audio; @@ -19,6 +25,8 @@ mod discovery; mod input; mod session; +use input::MacosSystemSettingsButton; + pub use about::AboutSection; pub use audio::AudioSection; pub use developer::DeveloperSection; @@ -121,6 +129,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 = @@ -143,6 +152,37 @@ 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()); + }); + 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 @@ -174,16 +214,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(); }); }; @@ -197,6 +255,45 @@ pub fn CaptureSection( value=enabled on_change=on_change /> + +
+
+
"Screen Recording"
+
+ "Open Screen Recording in System Settings, enable Hypercolor, then return here." +
+
+
+ + +
+
+
+ {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} +
+ })} + + {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)) + }}
"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"
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 _ = owner; + let message = "Capture service restarted.".to_owned(); + 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( + "Open Hypercolor.app to finish this restart.".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"
+
+ "Permission granted. Hypercolor needs a quick restart of its capture service to start using it." +
+ +
+ "Open Hypercolor.app to restart it." +
+
+ {move || result_message.get().map(|message| view! { +
{message}
+ })} +
+ +
+ } +} + +#[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: 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..a40747627 100644 --- a/crates/hypercolor-ui/src/components/settings_sections/input.rs +++ b/crates/hypercolor-ui/src/components/settings_sections/input.rs @@ -1,17 +1,71 @@ 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, 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 { + 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( @@ -48,14 +102,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,20 +171,59 @@ pub fn InputSection( /> -
-
- "Source health" + +
+
+
"Input Monitoring"
+
+ "Open Input Monitoring in System Settings, enable Hypercolor, then return here." +
+
+
+ + +
- {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).into_any(), - }} -
+ + {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} +
+ })} + + {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) - .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() - }} +
+ + {text}
} } -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(); +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") + ) + }) +} - 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}
- })} -
- } +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 route_value(route: InteractionRoutePolicy) -> String { @@ -256,10 +300,170 @@ fn route_value(route: InteractionRoutePolicy) -> String { .to_owned() } -fn humanize(value: &str) -> String { - let mut words = value.replace('_', " "); - if let Some(first) = words.get_mut(0..1) { - first.make_ascii_uppercase(); +#[cfg(test)] +mod tests { + use crate::api::{ + InputSourcePlatformStatus, InputSourceStatus, InputStatus, MacosDaemonOwnershipStatus, + SystemStatus, + }; + + use super::{ + macos_keyboard_needs_authorization, macos_keyboard_restart_coordinates, + macos_system_settings_remedy, + }; + + 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: 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: None, + tahoe_selection: None, + owner_conflict: None, + }); + assert!(!super::super::macos_screen_needs_authorization(&status)); + } + + #[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 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); } - words } diff --git a/crates/hypercolor-ui/src/components/settings_sections/session.rs b/crates/hypercolor-ui/src/components/settings_sections/session.rs index 12f966533..61ff3c916 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( + "Open this page in Hypercolor.app to make this change.".to_owned(), + )), + Err(error) => set_result_message.set(Some(format!("The switch 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( + "Open this page in Hypercolor.app to start it.".to_owned(), + )), + Err(error) => set_offline_message.set(Some(format!("It could not start: {error}"))), + } + set_starting_offline.set(false); + ownership.refetch(); + offline.refetch(); + }); + }); + + view! { + {move || match ownership.get() { + Some(Ok(Some(status))) + if status.conflict.is_some() || status.recovery_required.is_some() => + { + view! { + + } + .into_any() + } + _ => ().into_any(), + }} + {move || match offline.get() { + Some(Ok(Some(status))) => view! { + + }.into_any(), + Some(Err(error)) if native_available => view! { + +
+ {format!("Could not read the engine status: {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! { + +
+
+ +
+
"Hypercolor's lighting engine isn't running"
+
+ {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 conflict = status.conflict.clone(); + let choices = macos_owner_choices(&status); + let has_choices = !choices.is_empty(); + let recovery_pending = status.recovery_required.is_some(); + + view! { + +
+ {conflict.map(|conflict| view! { +
+
"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, + ), + )} +
+
+ })} + +
+ "A switch between Hypercolor installs was interrupted. Hypercolor is recovering; check back in a moment." +
+
+ +
+ {choices.clone().into_iter().map(|choice| { + let label = owner_choice_label(choice); + view! { + + } + }).collect_view()} +
+ +
+ "Open Hypercolor.app to make this choice." +
+
+
+ {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 the terminal daemon", + } +} + +fn macos_owner_outcome_message(outcome: &MacosOwnerCoordinatorOutcome) -> String { + match outcome { + 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), + ), + MacosOwnerCoordinatorOutcome::RecoveryRequired { .. } => { + "The switch was interrupted. Hypercolor is recovering; check back in a moment." + .to_owned() + } + MacosOwnerCoordinatorOutcome::Unknown => { + "This version of Hypercolor.app could not read the result. Refresh to see the current state." + .to_owned() + } + } +} + +fn owner_remedy_label(remedy: &MacosOwnerRemedy) -> String { + match remedy { + MacosOwnerRemedy::StopStandaloneOwner { pid } => { + 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 => "Update Hypercolor.app to finish this step.".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".to_owned(), + "launchd_service" | "direct_launchd" => "launchd service".to_owned(), + "homebrew_service" | "homebrew" => "Homebrew service".to_owned(), + "standalone" => "a terminal-launched 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(); @@ -324,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)}
} diff --git a/crates/hypercolor-ui/src/input_access.rs b/crates/hypercolor-ui/src/input_access.rs index 295840df1..92509655b 100644 --- a/crates/hypercolor-ui/src/input_access.rs +++ b/crates/hypercolor-ui/src/input_access.rs @@ -141,8 +141,82 @@ fn source_is_degraded(source: &InputSourceStatus) -> bool { || (source.demanded && source.freshness == "stale")) } +// Only host interaction sources speak for the input pipeline. Media, +// network, and audio sources live in their own domains, and an +// unsupported-on-this-platform media source must never degrade input +// health or push its remediation into the input section. fn source_is_relevant(source: &InputSourceStatus) -> bool { - source.backend != "browser" && (source.configured || source.demanded) + source.kind == "interaction" + && source.backend != "browser" + && (source.configured || source.demanded) +} + +/// Visual tone for the single settings status sentence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusLineTone { + Active, + Ready, + Warn, +} + +/// The one sentence the Input section shows, or `None` when the section's +/// own controls already tell the story (consent off, or the permission row +/// is on screen). +#[must_use] +pub fn input_status_line(input: &InputStatus) -> Option<(StatusLineTone, String)> { + if !input.enabled { + return None; + } + match input_pipeline_state(input) { + InputPipelineState::ConsentOff => None, + InputPipelineState::Live => Some(( + StatusLineTone::Active, + "Capturing input for the active effect.".to_owned(), + )), + InputPipelineState::Ready => Some(( + StatusLineTone::Ready, + "Ready. Capture starts when an effect uses input.".to_owned(), + )), + InputPipelineState::Degraded => { + let sentence = input_status_remediation(input).map_or_else( + || "Input capture isn't working right now.".to_owned(), + |remedy| format!("Input capture isn't working right now. {remedy}"), + ); + Some((StatusLineTone::Warn, sentence)) + } + InputPipelineState::Unavailable => Some(( + StatusLineTone::Warn, + "Host input isn't available on this system.".to_owned(), + )), + } +} + +/// The one sentence the Screen Capture section shows, or `None` when the +/// toggle or the permission row already tells the story. +#[must_use] +pub fn screen_status_line(input: &InputStatus) -> Option<(StatusLineTone, String)> { + let screen = input + .sources + .iter() + .find(|source| !source.retired && source.kind == "screen")?; + + if screen.state == "live" { + return Some((StatusLineTone::Active, "Capturing your screen.".to_owned())); + } + let issue = primary_input_source_issue(screen); + if issue.is_some() || matches!(screen.state.as_str(), "failed" | "degraded" | "unavailable") { + let sentence = issue + .and_then(|issue| issue.remediation.clone()) + .map_or_else( + || "Screen capture isn't working right now.".to_owned(), + |remedy| format!("Screen capture isn't working right now. {remedy}"), + ); + return Some((StatusLineTone::Warn, sentence)); + } + Some(( + StatusLineTone::Ready, + "Ready. Starts when a screen effect runs.".to_owned(), + )) } /// Which remediation the banner should offer, if any. diff --git a/crates/hypercolor-ui/src/lib.rs b/crates/hypercolor-ui/src/lib.rs index b47f43cb8..ebcdda0de 100644 --- a/crates/hypercolor-ui/src/lib.rs +++ b/crates/hypercolor-ui/src/lib.rs @@ -98,6 +98,7 @@ fn print_banner() { pub fn run_with_extensions(ext: UiExtensions) { _ = console_log::init_with_level(log::Level::Debug); console_error_panic_hook::set_once(); + tauri_bridge::initialize_daemon_transport(); print_banner(); mount_to_body(move || app::app_view(ext)); } diff --git a/crates/hypercolor-ui/src/pages/media.rs b/crates/hypercolor-ui/src/pages/media.rs index 89dbf5eb9..01c4aa71b 100644 --- a/crates/hypercolor-ui/src/pages/media.rs +++ b/crates/hypercolor-ui/src/pages/media.rs @@ -511,7 +511,11 @@ fn MediaDetail( let accent = kind_accent(kind); let icon = kind_icon(kind); let label = kind_label(kind); - let blob_url = format!("/api/v1/assets/{}/blob", asset.id); + let blob_url = crate::api::client::daemon_url(&format!( + "/api/v1/assets/{}/blob", + asset.id + )) + .unwrap_or_default(); let header_name = asset.name.clone(); let download_name = asset.name.clone(); let type_text = asset.mime_type.clone(); 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..b175d7c24 100644 --- a/crates/hypercolor-ui/src/tauri_bridge.rs +++ b/crates/hypercolor-ui/src/tauri_bridge.rs @@ -1,14 +1,189 @@ //! Optional bridge to native Tauri commands when the UI is hosted in hypercolor-app. use serde::Deserialize; +#[cfg(any(target_arch = "wasm32", test))] +use std::{cell::Cell, future::Future}; #[cfg(target_arch = "wasm32")] -use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen::{JsCast, JsValue, closure::Closure}; #[cfg(target_arch = "wasm32")] 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"; + +#[cfg(any(target_arch = "wasm32", test))] +const VERIFIED_DAEMON_CONNECTION_COMMAND: &str = "get_verified_daemon_connection"; + +#[cfg(target_arch = "wasm32")] +const VERIFIED_DAEMON_CONNECTION_EVENT: &str = "verified-daemon-connection-changed"; + +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct VerifiedDaemonConnection { + base_url: String, + #[serde(default)] + server_session_id: Option, + #[serde(default)] + protected_control_credential: Option, +} + +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct VerifiedDaemonConnectionSnapshot { + revision: u64, + connection: Option, +} + +#[cfg(any(target_arch = "wasm32", test))] +thread_local! { + static VERIFIED_DAEMON_REVISION: Cell = const { Cell::new(0) }; +} + +#[cfg(target_arch = "wasm32")] +#[derive(Deserialize)] +struct VerifiedDaemonConnectionEvent { + payload: VerifiedDaemonConnectionSnapshot, +} + +#[cfg(any(target_arch = "wasm32", test))] +fn apply_verified_daemon_connection(snapshot: VerifiedDaemonConnectionSnapshot) -> bool { + let accepted = VERIFIED_DAEMON_REVISION.with(|revision| { + if snapshot.revision <= revision.get() { + false + } else { + revision.set(snapshot.revision); + true + } + }); + if !accepted { + return false; + } + if let Some(connection) = snapshot.connection { + crate::api::client::install_verified_daemon_connection( + &connection.base_url, + connection.protected_control_credential.as_deref(), + ); + } else { + crate::api::client::clear_verified_daemon_connection(); + } + notify_verified_daemon_connection_change(); + true +} + +#[cfg(any(target_arch = "wasm32", test))] +async fn snapshot_after_listener_registration( + registration: Registration, + snapshot: Snapshot, +) -> Option +where + Registration: Future, + Snapshot: FnOnce() -> SnapshotFuture, + SnapshotFuture: Future>, +{ + if !registration.await { + return None; + } + snapshot().await +} + +#[cfg(target_arch = "wasm32")] +fn notify_verified_daemon_connection_change() { + let Some(window) = browser_window() else { + return; + }; + if let Ok(event) = web_sys::Event::new("hypercolor-verified-daemon-connection-changed") { + let _ = window.dispatch_event(&event); + } +} + +#[cfg(test)] +fn notify_verified_daemon_connection_change() {} + +/// Initialize the bundled app's process-memory daemon transport. +pub fn initialize_daemon_transport() { + #[cfg(target_arch = "wasm32")] + { + if tauri_invoke().is_none() { + return; + } + crate::api::client::begin_native_daemon_verification(); + + wasm_bindgen_futures::spawn_local(async { + let connection = snapshot_after_listener_registration( + subscribe_verified_daemon_connection_events(), + || async { + let invoke = tauri_invoke()?; + invoke_command(&invoke, VERIFIED_DAEMON_CONNECTION_COMMAND, None) + .await + .ok() + .and_then(|value| serde_json_from_js_value(value).ok()) + }, + ) + .await; + if let Some(snapshot) = connection { + apply_verified_daemon_connection(snapshot); + } + }); + } +} + +#[cfg(target_arch = "wasm32")] +async fn subscribe_verified_daemon_connection_events() -> bool { + let Some(window) = browser_window() else { + return false; + }; + let Some(listen) = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("__TAURI__")) + .ok() + .and_then(|tauri| js_sys::Reflect::get(&tauri, &JsValue::from_str("event")).ok()) + .and_then(|event| js_sys::Reflect::get(&event, &JsValue::from_str("listen")).ok()) + .and_then(|listen| listen.dyn_into::().ok()) + else { + return false; + }; + let callback = Closure::::new(|value| { + if let Ok(event) = serde_json_from_js_value::(value) { + apply_verified_daemon_connection(event.payload); + } + }); + let Ok(registration) = listen.call2( + &JsValue::NULL, + &JsValue::from_str(VERIFIED_DAEMON_CONNECTION_EVENT), + callback.as_ref(), + ) else { + return false; + }; + let Ok(registration) = registration.dyn_into::() else { + return false; + }; + if JsFuture::from(registration).await.is_err() { + return false; + } + callback.forget(); + true +} + /// Status for a native Windows service. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -92,6 +267,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 +430,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) @@ -327,6 +709,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, @@ -379,6 +780,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)) @@ -410,11 +851,145 @@ fn js_error_string(value: JsValue) -> String { #[cfg(test)] mod tests { + use std::{ + cell::{Cell, RefCell}, + future::Future, + task::{Context, Poll, Waker}, + }; + use super::{ - PawnIoModuleStatus, PawnIoSupportStatus, ServiceSupportStatus, bundled_payload_ready, - smbus_support_ready, windows_daemon_service_conflict, + MACOS_SYSTEM_SETTINGS_COMMAND, MacosOwnerCoordinatorOutcome, MacosOwnerRemedy, + MacosSystemSettingsPane, PawnIoModuleStatus, PawnIoSupportStatus, ServiceSupportStatus, + VERIFIED_DAEMON_CONNECTION_COMMAND, VERIFIED_DAEMON_REVISION, VerifiedDaemonConnection, + VerifiedDaemonConnectionSnapshot, apply_verified_daemon_connection, bundled_payload_ready, + smbus_support_ready, snapshot_after_listener_registration, windows_daemon_service_conflict, }; + fn run_ready(future: F) -> F::Output { + let mut future = std::pin::pin!(future); + let mut context = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => value, + Poll::Pending => panic!("fixture future should resolve without suspension"), + } + } + + #[test] + fn snapshot_request_waits_for_listener_registration_and_skips_rejection() { + let steps = RefCell::new(Vec::new()); + let snapshot = run_ready(snapshot_after_listener_registration( + async { + steps.borrow_mut().push("listener-ready"); + true + }, + || async { + assert_eq!(steps.borrow().as_slice(), &["listener-ready"]); + steps.borrow_mut().push("snapshot-requested"); + Some(42_u8) + }, + )); + assert_eq!(snapshot, Some(42)); + assert_eq!( + steps.into_inner(), + vec!["listener-ready", "snapshot-requested"] + ); + + let snapshot_requested = Cell::new(false); + let rejected = run_ready(snapshot_after_listener_registration( + async { false }, + || async { + snapshot_requested.set(true); + Some(42_u8) + }, + )); + assert_eq!(rejected, None); + assert!(!snapshot_requested.get()); + } + + #[test] + fn verified_connection_command_installs_and_rotates_only_process_memory() { + assert_eq!( + VERIFIED_DAEMON_CONNECTION_COMMAND, + "get_verified_daemon_connection" + ); + VERIFIED_DAEMON_REVISION.with(|revision| revision.set(0)); + crate::api::client::begin_native_daemon_verification(); + let connection: VerifiedDaemonConnection = serde_json::from_value(serde_json::json!({ + "baseUrl": "http://127.0.0.1:9420", + "serverSessionId": "hcs1_11111111111111111111111111111111", + "protectedControlCredential": format!("hcc1_{}", "22".repeat(32)), + })) + .expect("verified native connection should decode"); + assert!(apply_verified_daemon_connection( + VerifiedDaemonConnectionSnapshot { + revision: 2, + connection: Some(connection), + } + )); + assert_eq!( + crate::api::client::daemon_url("/api/v1/devices"), + Some("http://127.0.0.1:9420/api/v1/devices".to_owned()) + ); + assert!(crate::api::client::authorization_token().is_some()); + + assert!(!apply_verified_daemon_connection( + VerifiedDaemonConnectionSnapshot { + revision: 1, + connection: None, + } + )); + assert!(crate::api::client::daemon_url("/api/v1/devices").is_some()); + + assert!(apply_verified_daemon_connection( + VerifiedDaemonConnectionSnapshot { + revision: 3, + connection: None, + } + )); + assert!(crate::api::client::authorization_token().is_none()); + assert!(crate::api::client::daemon_url("/api/v1/devices").is_none()); + crate::api::client::reset_daemon_transport_for_test(); + } + + #[test] + fn health_verified_native_connection_routes_without_protected_credential() { + VERIFIED_DAEMON_REVISION.with(|revision| revision.set(0)); + crate::api::client::begin_native_daemon_verification(); + assert!(crate::api::client::daemon_url("/api/v1/status").is_none()); + + let connection: VerifiedDaemonConnection = serde_json::from_value(serde_json::json!({ + "baseUrl": "https://daemon.lan:19420", + "serverSessionId": null, + "protectedControlCredential": null, + })) + .expect("health-proven native connection should decode"); + assert!(apply_verified_daemon_connection( + VerifiedDaemonConnectionSnapshot { + revision: 1, + connection: Some(connection), + } + )); + assert_eq!( + crate::api::client::daemon_url("/api/v1/status"), + Some("https://daemon.lan:19420/api/v1/status".to_owned()) + ); + assert!(crate::api::client::authorization_token().is_none()); + crate::api::client::reset_daemon_transport_for_test(); + } + + #[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(); @@ -458,6 +1033,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/thumbnails.rs b/crates/hypercolor-ui/src/thumbnails.rs index 79db9289f..9167e7f35 100644 --- a/crates/hypercolor-ui/src/thumbnails.rs +++ b/crates/hypercolor-ui/src/thumbnails.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; -use gloo_net::http::{Method, RequestBuilder}; +use gloo_net::http::Method; use hypercolor_leptos_ext::canvas::{context_2d, create_canvas, image_data_rgba, set_canvas_size}; use hypercolor_leptos_ext::prelude::{console_warn_with_value, now_ms, spawn_timeout}; use leptos::prelude::*; @@ -231,17 +231,24 @@ fn encode_frame_to_webp(frame: &CanvasFrame) -> Result { /// Keyed by effect id so the daemon owns cover resolution — it decides between /// a curated override and the cover the effect ships inline. pub fn effect_cover_url(effect_id: &str) -> String { - format!("/api/v1/effects/{effect_id}/cover") + crate::api::client::daemon_url(&format!("/api/v1/effects/{effect_id}/cover")) + .unwrap_or_default() } /// Kick off a HEAD probe for an effect's cover and update `probe_cache` with /// the result. Idempotent — callers should check for an existing entry before /// spawning. fn spawn_curated_probe(effect_id: String, probe_cache: StoredValue>) { - let url = effect_cover_url(&effect_id); + let route = format!("/api/v1/effects/{effect_id}/cover"); wasm_bindgen_futures::spawn_local(async move { - let request = match RequestBuilder::new(&url).method(Method::HEAD).build() { - Ok(req) => req, + let Ok(request) = crate::api::client::request(Method::HEAD, &route) else { + probe_cache.update_value(|cache| { + cache.insert(effect_id, CuratedProbe::Absent); + }); + return; + }; + let request = match request.build() { + Ok(request) => request, Err(_) => { probe_cache.update_value(|cache| { cache.insert(effect_id, CuratedProbe::Absent); diff --git a/crates/hypercolor-ui/src/ws/connection.rs b/crates/hypercolor-ui/src/ws/connection.rs index 385050db9..15ac523d2 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, @@ -51,6 +51,7 @@ use crate::api::client; const BACKPRESSURE_RECOVERY_MS: f64 = 2_000.0; const TAURI_WINDOW_VISIBILITY_EVENT: &str = "hypercolor-window-visibility"; +const VERIFIED_DAEMON_CONNECTION_EVENT: &str = "hypercolor-verified-daemon-connection-changed"; const TAURI_WINDOW_VISIBLE_GLOBAL: &str = "__HYPERCOLOR_TAURI_WINDOW_VISIBLE"; fn preview_now_ms() -> u64 { @@ -142,6 +143,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 +213,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) = @@ -252,16 +258,14 @@ impl WsManager { StoredValue::new_local(None); let tauri_visibility_change_callback: StoredValue, LocalStorage> = StoredValue::new_local(None); + let daemon_connection_change_callback: StoredValue, LocalStorage> = + StoredValue::new_local(None); let reconnect_timeout: StoredValue, LocalStorage> = StoredValue::new_local(None); // Reconnection attempt counter for exponential backoff. let reconnect_attempts = StoredValue::new(0_u32); - // Build WebSocket URL relative to page origin - let ws_url = build_ws_url(); - let ws_url = StoredValue::new(ws_url); - // ── connect() ────────────────────────────────────────────────────── // Callable multiple times: creates a fresh WebSocket and wires the // same signal writers. Called once at startup and again on close/error @@ -292,7 +296,10 @@ impl WsManager { set_preview_fps.set(0.0); set_sensors.set(None); - let url = ws_url.get_value(); + let Some(url) = build_ws_url() else { + set_connection_state.set(ConnectionState::Disconnected); + return; + }; let ws = match arraybuffer_websocket(&url, HYPERCOLOR_WS_PROTOCOL) { Ok(ws) => ws, Err(_) => { @@ -503,6 +510,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, @@ -760,6 +768,22 @@ impl WsManager { }, ); tauri_visibility_change_callback.set_value(Some(on_tauri_visibility_change)); + + daemon_connection_change_callback.update_value(|handle| { + if let Some(mut handle) = handle.take() { + handle.cancel(); + } + }); + let on_daemon_connection_change = on( + window.unchecked_ref(), + VERIFIED_DAEMON_CONNECTION_EVENT, + move |_| { + if let Some(connect_fn) = connect.get_value() { + connect_fn(); + } + }, + ); + daemon_connection_change_callback.set_value(Some(on_daemon_connection_change)); } // Initial connection @@ -837,6 +861,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, @@ -916,7 +941,9 @@ fn dispose_existing_socket( /// Dev builds (Trunk dev server, any port) connect directly to the daemon /// (:9420) since Trunk's proxy doesn't handle WebSocket upgrades. Release /// builds are served by the daemon itself, so same-origin works. -fn build_ws_url() -> String { +fn build_ws_url() -> Option { + let routed = client::daemon_url("/api/v1/ws")?; + let native_base = native_websocket_url(&routed); let location = current_page_location(); let ws_protocol = location.websocket_protocol(); @@ -931,12 +958,30 @@ fn build_ws_url() -> String { location.host() }; - let base = format!("{ws_protocol}//{host}/api/v1/ws"); - client::stored_api_key().map_or(base.clone(), |key| { - format!("{base}?token={}", percent_encode(&key)) + let base = native_base.unwrap_or_else(|| format!("{ws_protocol}//{host}/api/v1/ws")); + Some(authenticated_websocket_url( + base, + client::authorization_token().as_deref(), + )) +} + +fn authenticated_websocket_url(base: String, token: Option<&str>) -> String { + token.map_or(base.clone(), |token| { + format!("{base}?token={}", percent_encode(token)) }) } +fn native_websocket_url(routed: &str) -> Option { + routed + .strip_prefix("https://") + .map(|rest| format!("wss://{rest}")) + .or_else(|| { + routed + .strip_prefix("http://") + .map(|rest| format!("ws://{rest}")) + }) +} + fn percent_encode(input: &str) -> String { let mut encoded = String::with_capacity(input.len()); for byte in input.bytes() { @@ -967,3 +1012,47 @@ fn tauri_window_is_visible() -> bool { .and_then(|value| value.as_bool()) .unwrap_or(true) } + +#[cfg(test)] +mod transport_tests { + #[test] + fn native_daemon_routes_convert_http_schemes_for_websocket() { + assert_eq!( + super::native_websocket_url("http://127.0.0.1:9420/api/v1/ws").as_deref(), + Some("ws://127.0.0.1:9420/api/v1/ws") + ); + assert_eq!( + super::native_websocket_url("https://daemon.test/api/v1/ws").as_deref(), + Some("wss://daemon.test/api/v1/ws") + ); + assert!(super::native_websocket_url("/api/v1/ws").is_none()); + } + + #[test] + fn every_connection_uses_the_current_verified_websocket_token() { + let base = "ws://127.0.0.1:9420/api/v1/ws".to_owned(); + let first = super::authenticated_websocket_url(base.clone(), Some("session-one")); + let rotated = super::authenticated_websocket_url(base, Some("session-two")); + assert_eq!(first, "ws://127.0.0.1:9420/api/v1/ws?token=session-one"); + assert_eq!(rotated, "ws://127.0.0.1:9420/api/v1/ws?token=session-two"); + assert_ne!(first, rotated); + } + + #[test] + fn health_verified_native_base_enables_websocket_without_session_token() { + crate::api::client::reset_daemon_transport_for_test(); + crate::api::client::begin_native_daemon_verification(); + assert!(crate::api::client::daemon_url("/api/v1/ws").is_none()); + + crate::api::client::install_verified_daemon_connection("https://daemon.lan:19420", None); + let routed = crate::api::client::daemon_url("/api/v1/ws") + .expect("health-proven native route should exist"); + let websocket = + super::native_websocket_url(&routed).expect("HTTPS daemon route should convert to WSS"); + assert_eq!( + super::authenticated_websocket_url(websocket, None), + "wss://daemon.lan:19420/api/v1/ws" + ); + crate::api::client::reset_daemon_transport_for_test(); + } +} 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/messages.rs b/crates/hypercolor-ui/src/ws/messages.rs index 14f460186..94446dd6b 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 ──────────────────────────────────────────────────────── @@ -75,6 +75,7 @@ pub const LAYER_HEALTH_EVENTS: &[&str] = &["layer_health_changed"]; pub struct PerformanceMetrics { pub fps: MetricsFps, pub frame_time: MetricsFrameTime, + pub input_latency: MetricsSessionLatency, pub stages: MetricsStages, pub pacing: MetricsPacing, pub effect_health: MetricsEffectHealth, @@ -88,6 +89,16 @@ pub struct PerformanceMetrics { pub websocket: MetricsWebsocket, } +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(default)] +pub struct MetricsSessionLatency { + pub sample_count: u64, + pub avg_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub max_ms: f64, +} + #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(default)] pub struct MetricsFps { @@ -384,6 +395,9 @@ pub struct MetricsCopies { pub publication_full_frame_count: u32, pub publication_full_frame_kb: f64, pub publication_reason: Option, + pub session_full_frame_count: u64, + pub session_full_frame_frames: u64, + pub session_full_frame_bytes: u64, } #[derive(Debug, Clone, Default, Deserialize, PartialEq)] @@ -476,6 +490,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, @@ -487,6 +502,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 +837,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 +1005,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 +1034,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, @@ -1245,3 +1275,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/crates/hypercolor-ui/src/ws/mod.rs b/crates/hypercolor-ui/src/ws/mod.rs index 565716cc0..88001c53e 100644 --- a/crates/hypercolor-ui/src/ws/mod.rs +++ b/crates/hypercolor-ui/src/ws/mod.rs @@ -9,11 +9,13 @@ 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, 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..7417b84c2 100644 --- a/crates/hypercolor-ui/tests/input_access_tests.rs +++ b/crates/hypercolor-ui/tests/input_access_tests.rs @@ -1,9 +1,12 @@ use hypercolor_ui::api::{InputSourceIssueStatus, InputSourceStatus, InputStatus, SystemStatus}; use hypercolor_ui::input_access::{ - InputAccessRemedy, InputPipelineState, input_access_remedy, input_pipeline_state, - input_status_epoch, input_status_remediation, primary_input_source_issue, + InputAccessRemedy, InputPipelineState, StatusLineTone, input_access_remedy, + input_pipeline_state, input_status_epoch, input_status_line, input_status_remediation, + primary_input_source_issue, screen_status_line, +}; +use hypercolor_ui::ws::messages::{ + extract_input_source_status_event_hint, extract_macos_daemon_ownership_event_hint, }; -use hypercolor_ui::ws::messages::extract_input_source_status_event_hint; fn input(enabled: bool, opened: usize, denied: usize) -> InputStatus { InputStatus { @@ -151,6 +154,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!({ @@ -243,6 +276,7 @@ fn worker_failure_and_stale_demand_are_degraded_until_recovery() { let mut status = input(true, 1, 0); status.sources = vec![InputSourceStatus { source_id: "host-interaction".to_owned(), + kind: "interaction".to_owned(), configured: true, consented: true, demanded: true, @@ -364,3 +398,82 @@ fn reconnect_and_route_changes_advance_the_status_epoch() { let rerouted = input_status_epoch(2, None, Some(&config)).expect("rerouted epoch"); assert_ne!(reconnect, rerouted); } + +#[test] +fn non_interaction_sources_never_degrade_the_input_pipeline() { + let mut status = input(true, 1, 0); + status.sources = vec![InputSourceStatus { + source_id: "media".to_owned(), + kind: "media".to_owned(), + configured: true, + consented: true, + demanded: true, + state: "unavailable".to_owned(), + issue: Some(InputSourceIssueStatus { + code: "unsupported_platform".to_owned(), + message: "native media input is unavailable on this platform".to_owned(), + remediation: Some("run Hypercolor on Linux or Windows for media input".to_owned()), + retryable: false, + }), + ..InputSourceStatus::default() + }]; + + assert_eq!(input_pipeline_state(&status), InputPipelineState::Live); + assert_eq!(input_status_remediation(&status), None); +} + +#[test] +fn input_status_line_speaks_one_plain_sentence_per_state() { + assert_eq!(input_status_line(&input(false, 0, 0)), None); + + let (tone, text) = input_status_line(&input(true, 1, 0)).expect("live line"); + assert_eq!(tone, StatusLineTone::Active); + assert_eq!(text, "Capturing input for the active effect."); + + let (tone, text) = input_status_line(&input(true, 0, 0)).expect("ready line"); + assert_eq!(tone, StatusLineTone::Ready); + assert_eq!(text, "Ready. Capture starts when an effect uses input."); + + let mut unavailable = input(true, 0, 0); + unavailable.host_capture_registered = false; + let (tone, _) = input_status_line(&unavailable).expect("unavailable line"); + assert_eq!(tone, StatusLineTone::Warn); +} + +#[test] +fn screen_status_line_reports_the_screen_source_without_identifiers() { + let mut status = input(true, 0, 0); + assert_eq!(screen_status_line(&status), None); + + status.sources = vec![InputSourceStatus { + source_id: "macos_screen".to_owned(), + kind: "screen".to_owned(), + configured: true, + state: "live".to_owned(), + ..InputSourceStatus::default() + }]; + let (tone, text) = screen_status_line(&status).expect("live line"); + assert_eq!(tone, StatusLineTone::Active); + assert_eq!(text, "Capturing your screen."); + assert!(!text.contains("macos_screen")); + + status.sources[0].state = "failed".to_owned(); + status.sources[0].lifecycle_issue = Some(InputSourceIssueStatus { + code: "worker_exited".to_owned(), + message: "capture worker exited".to_owned(), + remediation: Some("Toggle screen capture off and on.".to_owned()), + retryable: true, + }); + let (tone, text) = screen_status_line(&status).expect("warn line"); + assert_eq!(tone, StatusLineTone::Warn); + assert_eq!( + text, + "Screen capture isn't working right now. Toggle screen capture off and on." + ); + + status.sources[0].state = "idle".to_owned(); + status.sources[0].lifecycle_issue = None; + let (tone, text) = screen_status_line(&status).expect("ready line"); + assert_eq!(tone, StatusLineTone::Ready); + assert_eq!(text, "Ready. Starts when a screen effect runs."); +} 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] diff --git a/crates/hypercolor-ui/tests/ws_messages_tests.rs b/crates/hypercolor-ui/tests/ws_messages_tests.rs index f4bee8537..44fdd0e58 100644 --- a/crates/hypercolor-ui/tests/ws_messages_tests.rs +++ b/crates/hypercolor-ui/tests/ws_messages_tests.rs @@ -105,6 +105,13 @@ fn performance_metrics_deserializes_renderer_diagnostics() { "dropped": 1 }, "frame_time": { "avg_ms": 8.1, "p95_ms": 12.4, "p99_ms": 15.9, "max_ms": 18.2 }, + "input_latency": { + "sample_count": 600, + "avg_ms": 0.31, + "p95_ms": 0.72, + "p99_ms": 0.91, + "max_ms": 1.08 + }, "stages": { "producer_effect_rendering_ms": 2.1, "producer_preview_compose_ms": 3.4, @@ -179,7 +186,10 @@ fn performance_metrics_deserializes_renderer_diagnostics() { "full_frame_count": 2, "full_frame_kb": 2400.0, "producer_reason": "readback", - "publication_reason": "canvas" + "publication_reason": "canvas", + "session_full_frame_count": 7, + "session_full_frame_frames": 4, + "session_full_frame_bytes": 9830400 }, "memory": { "daemon_rss_mb": 100.0, "canvas_buffer_kb": 1200 }, "devices": { "connected": 2, "total_leds": 300, "output_errors": 0 }, @@ -190,6 +200,8 @@ fn performance_metrics_deserializes_renderer_diagnostics() { assert_eq!(metrics.fps.ceiling, 60); assert_eq!(metrics.fps.capacity, 60.0); assert_eq!(metrics.fps.delivered_or_legacy(), 58.4); + assert_eq!(metrics.input_latency.sample_count, 600); + assert_eq!(metrics.input_latency.p99_ms, 0.91); assert_eq!(metrics.stages.producer_scene_compose_ms, 3.4); assert_eq!(metrics.effect_health.servo_render_gpu_frames_total, 120); assert_eq!( @@ -217,6 +229,9 @@ fn performance_metrics_deserializes_renderer_diagnostics() { assert_eq!(metrics.render_surfaces.scene_pool_saturation_reallocs, 7); assert_eq!(metrics.display_output.write_failures_total, 3); assert_eq!(metrics.copies.producer_reason.as_deref(), Some("readback")); + assert_eq!(metrics.copies.session_full_frame_count, 7); + assert_eq!(metrics.copies.session_full_frame_frames, 4); + assert_eq!(metrics.copies.session_full_frame_bytes, 9_830_400); } #[test] 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 ───────────────────────────────────────────────────────────────── 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/api/auth-and-security.md b/docs/content/api/auth-and-security.md index a5a9c8110..0b6af2a58 100644 --- a/docs/content/api/auth-and-security.md +++ b/docs/content/api/auth-and-security.md @@ -1,26 +1,29 @@ +++ title = "Auth & security" -description = "Dual-key API auth, the loopback exemption, CORS, network allowlists, and rate limiting for the Hypercolor daemon on :9420." +description = "Dual-key API auth, protected local controls, CORS, network allowlists, and rate limiting for the Hypercolor daemon on :9420." weight = 60 template = "page.html" +++ -The daemon ships **open on loopback and closed to the network**. Local clients -on `127.0.0.1` (CLI, TUI, web UI, an MCP client on the same box) work with no -credentials, while every off-host request is gated by the layers on this page: -API-key authentication, a per-client allowlist, CORS, and rate limiting. All of -it is enforced by a single Axum middleware (`enforce_security`) that wraps the -whole `/api/v1` surface. +The daemon ships **open for ordinary control on loopback and closed to the +network**. Local clients on `127.0.0.1` (CLI, TUI, web UI, an MCP client on the +same box) can manage lighting with no credentials. Privacy-bearing capture and +input operations always require an authenticated control credential, including +on loopback. Every off-host request is also gated by API-key authentication, a +per-client allowlist, CORS, and rate limiting. One Axum middleware +(`enforce_security`) establishes both the ordinary API tier and the separate +protected-control authority for the whole `/api/v1` surface. If you only ever drive Hypercolor from the same machine, you can stop reading after the loopback section. Everything else matters the moment you bind the daemon to a LAN address or put it behind a reverse proxy. {% callout(type="info") %} -Authentication is **opt-in**. With no API-key environment variables set, the -daemon enforces no keys at all: loopback is trusted and remote clients are -governed only by the network allowlist (default: local-only). Setting a key -flips on the full Bearer-token gate. +Ordinary API authentication is **opt-in**. With no API-key environment +variables set, loopback lighting control remains credentialless and remote +clients are governed by the network allowlist (default: local-only). Protected +capture and input surfaces remain unavailable until a control credential or +trusted in-process capability is present. {% end %} ## The model at a glance @@ -38,16 +41,20 @@ graph TD C -- no --> D{Loopback client?} D -- yes --> E{Cross-site mutating request?} E -- yes --> R2[403 forbidden - CSRF] - E -- no --> P + E -- no --> J{Protected capture or input?} D -- no --> F{Auth enabled?} - F -- no --> P + F -- no --> J F -- yes --> G{Valid Bearer token?} G -- no --> R3[401 unauthorized] G -- yes --> H{Tier satisfies method?} H -- no --> R4[403 forbidden] H -- yes --> I{Under rate limit?} I -- no --> R5[429 rate_limited] - I -- yes --> P + I -- yes --> J + J -- no --> P + J -- yes --> K{Authenticated control authority?} + K -- no --> R6[403 forbidden] + K -- yes --> P {% end %} ## Dual-key authentication @@ -123,12 +130,30 @@ A missing or unparseable token on a non-loopback request returns ## The loopback exemption Requests whose client IP is loopback (`127.0.0.0/8`, `::1`) skip the API-key -requirement entirely. This is why the CLI, TUI, web UI, and a local MCP client -all work with no key on a default install. The daemon derives the client IP -from the peer socket; when the peer is itself loopback (a reverse proxy on the -same host) it honors `X-Forwarded-For` / `X-Real-IP` so the real remote IP is -used for auth and allowlisting. Forwarded headers from a **non-loopback** peer -are ignored, so you cannot spoof your way to a loopback exemption. +requirement for ordinary lighting control. This is why the CLI, TUI, web UI, +and a local MCP client all work with no key on a default install. The daemon +derives the client IP from the peer socket; when the peer is itself loopback (a +reverse proxy on the same host) it honors `X-Forwarded-For` / `X-Real-IP` so the +real remote IP is used for auth and allowlisting. Forwarded headers from a +**non-loopback** peer are ignored, so you cannot spoof your way to a loopback +exemption. + +Loopback locality is not a user identity. These surfaces require protected +control authority even when the TCP peer is loopback: + +- `POST /api/v1/input/authorize` +- `POST /api/v1/capture/authorize` +- `POST /api/v1/capture/source/pick` +- `GET /api/v1/capture/monitors` +- WebSocket subscriptions to `screen_canvas`, `screen_zones`, or `input_events` + +The ordinary status endpoint remains available, but capture selection IDs are +redacted unless the request has protected control authority. + +An authenticated control-tier key grants that authority. A read key, a missing +key, an `Origin` header, Fetch Metadata, and the peer IP do not. Trusted +in-process transports receive the same authority only after their own +authentication boundary has succeeded. ### CSRF protection on loopback @@ -292,9 +317,11 @@ ws://studio.local:9420/api/v1/ws?token=hc_ak_super_secret Query-string tokens are accepted **only** on the `GET` WebSocket upgrade. Plain HTTP endpoints reject `?token=` and demand the `Authorization` header, so a -token never leaks into an ordinary request URL or access log. On loopback the -socket needs no token at all. For the channel and frame protocol once -connected, see [WebSocket protocol](@/api/websocket.md). +token never leaks into an ordinary request URL or access log. A loopback socket +needs no token for ordinary channels. The three sensitive channels require a +control token in the upgrade URL or a trusted in-process connection. For the +channel and frame protocol once connected, see +[WebSocket protocol](@/api/websocket.md). ## Hardening checklist diff --git a/docs/content/api/openapi.md b/docs/content/api/openapi.md index 0edc3ab47..f223157ac 100644 --- a/docs/content/api/openapi.md +++ b/docs/content/api/openapi.md @@ -78,29 +78,26 @@ graph TD F --> H["hypercolor-openapi binary"] {% end %} -A handful of endpoints (system, drivers, devices, effects) carry full utoipa -`#[utoipa::path]` annotations with request and response schemas. Most other -routes are registered through the static `ROUTES` catalog, so the document -lists the path-and-method surface even where a per-operation body schema is -not yet annotated. The document covers the cataloged surface, and the parity -test asserts one direction only: every cataloged route must appear in the -document. A router path missing from the catalog is not caught, and eight -`/api/v1` paths currently live in the router without a catalog entry: -`/assets`, `/assets/{id}`, `/assets/{id}/blob`, `/assets/{id}/thumbnail`, -`/capture/source/pick`, `/capture/monitors`, `/diagnose/memory`, and -`/effects/screenshots`. The [REST reference](@/api/rest.md) documents those -routes; the OpenAPI document does not list them until they are cataloged. +A handful of endpoints (system, drivers, devices, effects, and protected +capture actions) carry full utoipa `#[utoipa::path]` annotations with request +and response schemas. Most other routes are registered through the static +`ROUTES` catalog, so the document lists the path-and-method surface even where +a per-operation body schema is not yet annotated. The parity tests check both +directions: every cataloged route must appear in the document, and every static +router operation must have a catalog entry. A new REST route therefore cannot +silently disappear from OpenAPI. The schema components are drawn from `hypercolor-types`, the shared contract crate. ## hypercolor-types is the contract source Request and response bodies for the core domains live in one place: -`hypercolor-types::api`, with submodules `common`, `devices`, `effects`, `scenes`, -and `zones`. The daemon serializes these exact types and both UIs deserialize them, -so a wire change is a compile error rather than a runtime surprise. When the -OpenAPI document references a schema like `EffectSummary` or `CreateZoneRequest`, -it is referencing those shared definitions. +`hypercolor-types::api`, with submodules `capture`, `common`, `devices`, `effects`, +`scenes`, and `zones`. The daemon serializes these exact types and both UIs +deserialize them, so a wire change is a compile error rather than a runtime +surprise. When the OpenAPI document references a schema like `CaptureMonitor`, +`EffectSummary`, or `CreateZoneRequest`, it is referencing those shared +definitions. {% callout(type="info") %} Diagnostic telemetry (system status internals and metrics payloads) deliberately @@ -119,11 +116,9 @@ The document describes the REST surface only. A few things are out of scope: [Binary frame format](@/api/websocket-binary-frames.md). - **MCP** at `/mcp` is a separate Streamable HTTP surface with its own tool, resource, and prompt schemas. See the [Agents & MCP](@/agents/_index.md) section. -- **Per-operation request bodies** are fully annotated for the core endpoints and - catalogued (path + method + standard responses) for the rest of the cataloged - surface; the eight uncataloged paths above are absent entirely. The - [REST reference](@/api/rest.md) is the human-readable companion enumerated from - the same router. +- **Per-operation request bodies** are fully annotated for the core endpoints + and catalogued (path + method + standard responses) for the rest of the REST + surface. The [REST reference](@/api/rest.md) is the human-readable companion. The security scheme advertised in the document is HTTP `Bearer` (`bearer_auth`, bearer format "API key"), matching the daemon's `Authorization: Bearer ` diff --git a/docs/content/api/rest.md b/docs/content/api/rest.md index 940f488e4..456e6eca2 100644 --- a/docs/content/api/rest.md +++ b/docs/content/api/rest.md @@ -1111,9 +1111,34 @@ of your output, not a microphone, if you want lights to follow what's playing. ## Screen capture +The four protected capture operations below only accept local requests. A +remote client receives `403 Forbidden` even when it presents a valid control +key. The locality decision uses the socket peer and only trusts forwarded +addresses from a loopback proxy. + +The system status response keeps the selected session source identifier for a +local request. Any remote response replaces application and window selection +identifiers with `session_scoped`; stable display UUIDs remain available for +diagnostics. + +{% api_endpoint(method="POST", path="/api/v1/input/authorize") %} +Request Input Monitoring authorization from the process that owns host keyboard +capture. The response reports whether access is currently authorized and names +the process topology that owns the grant. +{% end %} + +{% api_endpoint(method="POST", path="/api/v1/capture/authorize") %} +Request Screen Recording authorization from the process that owns screen +capture. The response reports whether access is currently authorized and names +the process topology that owns the grant. +{% end %} + {% api_endpoint(method="POST", path="/api/v1/capture/source/pick") %} -Open the platform picker so the user can choose a screen or window capture -source for screen-reactive effects. +Open the platform picker so the user can choose a display, window, or application +for screen-reactive effects. An accepted display persists by its stable display +UUID. Window and application choices persist as `session_scoped`, so Hypercolor +remembers the privacy boundary without writing the selected window ID or bundle +ID to configuration. Cancelling the picker leaves the current source unchanged. {% end %} {% api_endpoint(method="GET", path="/api/v1/capture/monitors") %} diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 4805da429..1657526b0 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,24 @@ 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. +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} @@ -116,7 +127,11 @@ 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 +system screen picker require the app UI. --- 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. --- diff --git a/docs/content/guide/installation.md b/docs/content/guide/installation.md index d40dbc8a7..33f562dd9 100644 --- a/docs/content/guide/installation.md +++ b/docs/content/guide/installation.md @@ -130,6 +130,32 @@ 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. 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: + +```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 +197,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 diff --git a/docs/design/05-api-design.md b/docs/design/05-api-design.md index debc9262a..270ebfbb3 100644 --- a/docs/design/05-api-design.md +++ b/docs/design/05-api-design.md @@ -2198,26 +2198,40 @@ For audit/debug purposes, the daemon can optionally log events to a ring buffer ### 9.1 Threat Model -Hypercolor controls lights. It cannot brick hardware, exfiltrate data, or compromise system security. The primary concerns are: +Hypercolor controls lights and can optionally observe screen and host-input data. The primary concerns are: | Threat | Severity | Mitigation | | -------------------------------- | -------- | ----------------------------- | | Unauthorized effect changes | Low | Annoying, not dangerous | | Excessive API calls (DoS) | Medium | Rate limiting | | Reading device info | Low | No sensitive data exposed | +| Reading captured screen or input | High | Protected control credential | +| Triggering TCC prompts or picker | Medium | Protected control credential | | Firmware manipulation | High | Not exposed via API at all | | Daemon crash via malformed input | Medium | Input validation, fuzzing | | Network-exposed daemon hijacked | Medium | API keys for non-local access | -### 9.2 Local Access (No Auth) +### 9.2 Local Access -When bound to `127.0.0.1` (the default), no authentication is required. The reasoning: +When bound to `127.0.0.1` (the default), ordinary lighting control requires no +authentication. Loopback is network locality, not user identity, so privacy +surfaces use a separate protected-control authority. -- The daemon runs as the user's own process -- Only processes on the same machine can connect -- D-Bus session bus is already authenticated per the D-Bus spec +The credentialless loopback tier is a compatibility policy for low-impact +lighting control. It does not prove that a client shares the daemon's user ID. +A native process under another local account can reach the same host loopback +namespace. -This matches the security model of OpenRGB (TCP 6742, no auth), WLED (HTTP, no auth on local network), and other RGB tools (HTTP API, local only by default). +The Input Monitoring and Screen Recording authorization routes, the capture +source picker, monitor enumeration, and the `screen_canvas`, `screen_zones`, +and `input_events` WebSocket channels require an authenticated control +credential even on loopback. Read keys and unauthenticated local clients cannot +reach them. Trusted in-process transports receive protected control only after +their transport-specific authentication succeeds. + +The ordinary tier remains compatible with OpenRGB, WLED, and other local RGB +tools. The protected tier is stricter because screen and input data cross a +privacy boundary those lighting-only models do not cover. ### 9.3 Network Access (API Key) 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/design/46-cross-platform-packaging.md b/docs/design/46-cross-platform-packaging.md index 3ef5afe4e..e2841d918 100644 --- a/docs/design/46-cross-platform-packaging.md +++ b/docs/design/46-cross-platform-packaging.md @@ -119,7 +119,7 @@ New work, scoped to the v1 unified-app vision. | First-run flow | 🆕 | PawnIO detection (Windows), permission walkthrough (macOS), SCM-service detection (Windows) | | Tauri NSIS bundler config | 🆕 | `bundle.windows.nsis` block in `tauri.conf.json` | | Tauri DMG bundler config | 🆕 | `bundle.macOS` block + Homebrew Cask formula | -| Notarization workflow | 🆕 | GitHub Actions step using `xcrun notarytool` | +| Notarization workflow | 🆕 | Proprietary release step using `xcrun notarytool` | | AppImage build (deferred to v1.1) | 🆕 | Bundle WebKit2GTK 4.1 for old-distro reach | **Retired**: `crates/hypercolor-tray/` — its menu logic moves into `hypercolor-app`'s tray @@ -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): @@ -1004,34 +1016,22 @@ itself, and the uninstaller. | Notarization | Mandatory for distribution outside MAS. Free, automated via `xcrun notarytool` | | Stapling | `xcrun stapler staple` attaches notarization ticket for offline verification | -**GitHub Actions workflow:** +**Release boundary:** -```yaml -- name: Import signing certs - uses: apple-actions/import-codesign-certs@v3 - with: - p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12 }} - p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} - -- name: Build and sign - run: | - cargo tauri build --target aarch64-apple-darwin - env: - APPLE_SIGNING_IDENTITY: "Developer ID Application: Stefanie Jane (TEAMID)" - -- name: Notarize - run: | - xcrun notarytool submit \ - target/aarch64-apple-darwin/release/bundle/dmg/Hypercolor_0.1.0_aarch64.dmg \ - --apple-id "${{ secrets.APPLE_ID }}" \ - --team-id "${{ secrets.APPLE_TEAM_ID }}" \ - --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ - --wait - -- name: Staple - run: | - xcrun stapler staple target/aarch64-apple-darwin/release/bundle/dmg/Hypercolor_0.1.0_aarch64.dmg -``` +The public repository builds unsigned per-architecture `.app` fixtures with +`--no-sign`. Those fixtures have short retention, use an `oss-ci-*` artifact +namespace, and never enter a GitHub release. Public workflows contain no Apple +release credentials or Homebrew promotion token. + +The proprietary release pipeline builds each candidate once, invokes +`scripts/sign-macos-artifacts.sh`, notarizes and staples the result, runs the +physical TCC acceptance matrix, and promotes the exact accepted bits. The +pipeline supplies the App Store Connect API key as a private `0400` or `0600` +file. PKCS#12 and ephemeral-keychain passwords reach Security.framework through +a bounded stdin frame and never appear in process arguments. + +Local Apple ID notarization uses a preconfigured `notarytool` keychain profile. +The signing actor never accepts a raw Apple ID password. ### 11.3 Linux — No Signing (v1) @@ -1146,8 +1146,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 +1212,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/design/71-macos-capture-input-pr158-review.md b/docs/design/71-macos-capture-input-pr158-review.md new file mode 100644 index 000000000..21eb658ff --- /dev/null +++ b/docs/design/71-macos-capture-input-pr158-review.md @@ -0,0 +1,219 @@ +# PR #158 Deep Review: macOS Native Screen Capture and Host Input + +**Branch:** `nova/macos-capture-input` (HEAD `b881c795`, rebased onto `origin/main` `8821e02b`) +**Reviewed:** 2026-08-15 +**Method:** 8-lens `hyper-pr-review`, level 3 (deep). Read-only. Working-tree and untracked files included in scope. +**Scope:** 322 files, +87,354 / -2,966 vs `origin/main`, plus ~2,600 lines of uncommitted working-tree changes and 2,260 lines of untracked files (H2.3 `native/transactions.rs` + `native/lifecycle.rs`, H1.5 `macos_launcher_authority.rs`). + +**Verdict: NEEDS_CHANGES.** Multiple confirmed blockers. + +--- + +## Executive summary + +The capture and input engines are well-built. The transaction claim-once model, the generation-keyed IOSurface ownership caches, the CGEventTap disable/re-enable handling, and the seqlock latency histogram all hold up under adversarial reading. The old `device_query` polling bridge is genuinely gone. The sequence-0 first-frame rejection from prior history is fixed at HEAD. + +The daemon-ownership superstructure is the problem, on two axes at once. It is the direct cause of the black screen, and it is dramatically over-engineered: roughly 16,800 lines across 12 distinct concepts (owner record, session attestation, handover journal, flock guard, incarnations, launcher authority, read-probe-reread verification, owner watch, TCC canary), when the project's own spec 77 invariant 5 says the flock is supposed to be the only ownership authority. The single highest-value change in the PR is to collapse that layer: put `{owner, incarnation, session_id, credential}` inside the flock-held file itself and make handover a launchd bootout/bootstrap. + +So: the compositor and the capture/input pipelines are right. The process-ownership layer is both the bug and the biggest simplification opportunity. + +--- + +## The two live symptoms + +### Black screen: an orphaned daemon + +Not a UI bug. The app launched at 22:42 on Aug 14 spawned a sidecar daemon (pid 31138) that outlived the app and was still holding `:9420` at review time, reparented to launchd (ppid 1). On relaunch the supervisor spawned five fresh daemon children; each child's health probe was answered in ~1ms by the stale daemon (a fresh child cannot bind and serve that fast), then ownership verification failed with `healthy app sidecar has no matching authoritative owner publication`, the supervisor gave up after 5 restarts in 300s, and the trusted UI never received its daemon routes, so it rendered black. + +Receipt: `~/Library/Application Support/hypercolor/logs/hypercolor-app.log.2026-08-15`, the 20:37 block. + +### Keyboard dead while trackpad works: external secure-input holder + +External to Hypercolor. The terminal app cmux (pid 1218) holds the macOS secure-input assertion (`kCGSSessionSecureInputPID`, confirmed via ioreg). With Secure Keyboard Entry asserted, a `CGEventTap` receives zero keyboard events while pointer events still flow, which is exactly the observed symptom. Restarting the terminal (or disabling its Secure Keyboard Entry) releases it. + +### Immediate unblock (reversible, not yet executed) + +``` +kill 31138 # frees :9420; next app launch spawns a clean daemon +# then restart the terminal app to release the keyboard +``` + +--- + +## Blocking findings + +### B1. Orphaned sidecar defeats owner verification (the black screen) + +`crates/hypercolor-app/src/supervisor/mod.rs` + +The sidecar's lifetime is not coupled to app death. The unix child is detached via `process_group(0)` (line 2423) with a no-op platform guard, so the only kill path is `ManagedDaemon::Drop`, which never runs because tray-quit calls `app.exit(0)` and `std::process::exit` skips destructors. When a stale daemon then answers `:9420`, the watchdog treats it as spawn-retry-then-give-up instead of invoking the recovery/remedy machinery that already exists (`recover_daemon_owner`, `MacosOwnerRemedy`). Every recovery path also dead-ends because AppSidecar stop authority is a retained `Child` handle the new process does not hold. + +Confirmed by the logs plus live process state (pid 31138, ppid 1). The dirty `app_sidecar_recovery_needs_rearm` predicate in `ownership.rs` covers only `(AppSidecar, RequestedOwnerStarted)`, which is an in-flight owner-switch handover, not the orphaned-crash case where the journal is absent or terminal. + +Fix has two parts: couple sidecar lifetime to the app (parent-death watch via kqueue `EVFILT_PROC`, or reap on `RunEvent::Exit`), and route "guard contended by stale owner" into takeover rather than the crash-loop. + +### B2. Launcher authority breaks the dev loop and every non-bundle install + +`crates/hypercolor-daemon/src/macos_launcher_authority.rs` (untracked, uncommitted) + +Two independent startup failures, both confirmed by tracing: + +- `just daemon` / `cargo run` makes the daemon's parent `cargo`, which is not in the shell allowlist (`terminal_parent_is_valid`, line 368). So `standalone` evidence is false, no owner matches, and `exact_owner()` bails "launcher authority is missing" before startup. Same under `sudo` (parent `sudo`). +- `paths_are_equal` at line 269 runs `canonicalize()?` on `/Hypercolor` before the `layout_valid` gate is consulted (line 161), so any layout without a sibling `Hypercolor` binary (standalone, homebrew, launchd) hard-errors at startup. The `.expect("validated app sidecar path has a parent")` comment shows the gate was meant to run first. + +### B3. Capture transaction epoch-adoption bug (likely the live screen-capture issues) + +`crates/hypercolor-macos-capture/src/native.rs` + uncommitted `native/transactions.rs` + +When a source pick or interrupted-recovery restage adopts an in-flight pending request, the code remaps the stage epoch (`pending.epoch = epoch`, line 2309) but the transaction cell's generation is immutable (`transactions.rs:289`, no setter). Every generation-filtered operation (`arm_candidate_deadline` line 1782, cancel line 2854) then misses. The freshly staged candidate is insta-cancelled, `state.candidate_completion = None` clears the cell without claiming it, and the core waiter in `set_screen_capture_demand`/`reconfigure_screen_capture` either times out spuriously ~5s later via the old epoch's still-armed deadline, or hangs forever in the interleaving where prepare fails after the replacement (the completer has no Drop-cancel). + +Effect: capture does not recover after a display sleep or picker change with a request in flight. The existing test passes because the fixture path bypasses `arm_candidate_deadline` and activation deliberately has no generation filter, so the fixture path works while the production path breaks. Uncommitted H2.3 work, so it is fixable before it lands. + +Falsifier (non-macOS unit test): reserve a request candidate at gen 42, reserve a selection candidate at gen 43, then assert `arm_candidate_deadline(43, StreamStart, ...) == Ok(true)`. The bug predicts `Ok(false)`. + +Class-kill: rekey the cell generation on adoption, or drop the generation filter in favor of a cell-identity check, plus a `Drop` on the completer that publishes `Cancelled` so no path can strand a waiter. + +### B4. Two capture endpoints have no protected-control gate + +`crates/hypercolor-daemon/src/api/config.rs`, `api/diagnose.rs` + +Both handlers were statically confirmed to take no auth context. + +- `POST /config/set` (line 82) will start screen capture, retarget the display, flip the mic on, or enable keyboard capture from any local unprivileged process with no credential and no TCC prompt: `capture.enabled`, `capture.source`, `audio.device`, `input.keyboard` all route through `apply_capture_config_transaction`. This bypasses the gated picker (`/capture/source/pick`) that guards the same `capture.source` mutation. +- `POST /diagnose {"checks":["macos_screen_parity"]}` (line 214) actuates a real screenshot-reference capture ungated. It returns aggregate delta metrics, not raw pixels, so it is not a pixel-exfiltration path, but it actuates the protected capture pipeline with no credential. + +This violates the branch's own `docs/content/api/auth-and-security.md`, which states privacy-bearing capture "always require[s] an authenticated control credential, including on loopback." The loopback CSRF check blocks browsers but not native local processes. + +Falsifier (each settles in seconds against a running daemon): +``` +curl -s -X POST 127.0.0.1:9420/api/v1/config/set \ + -H 'content-type: application/json' -d '{"key":"capture.enabled","value":"true"}' +``` +A 403 kills the finding; a 200 + live apply confirms it. + +### B5. No secure-input / lock / fast-user-switch detection; held keys stick forever + +`crates/hypercolor-macos-input/src/macos.rs` + +The 250ms health tick (`drain_batches`, line ~709) polls only TCC, never `IsSecureEventInputEnabled`. The `SessionInterrupted` gap reason exists (`shared.rs:91`) but is dead code, constructed nowhere. The only paths that clear `pressed_keys` are a real KeyUp or a `StateGap` (`core/src/input/macos.rs:1105`). + +Effect: when a terminal grabs secure input (today's scenario), `pressed_keys` stays populated indefinitely, keyboard-reactive effects stay lit, and the session still reports Live. Spec 76 §15 rule 8 requires secure input, lock, logout, and fast-user-switch to clear held state; only the TCC leg is implemented. + +Fix: poll `IsSecureEventInputEnabled` next to the existing TCC poll in the health tick; on a rising edge emit `request_gap(SessionInterrupted)` (the dead variant is the designed carrier) and surface a secure-input field on `MacosInputPlatformStatus`. PID-only diagnostics keep §15 rule 4 (no app names in logs). + +--- + +## Non-blocking findings + +### N1. Scroll is broken two independent ways + +- All platforms (confirmed): the JS adapter (`crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js:177`) dropped its `/120`, making `engine.mouse.wheel` 120x larger, and the in-repo `keystrike` effect consumer (`sdk/src/effects/keystrike/main.ts:135`) was not updated, so one notch swings hue by 4.8 instead of 0.04. +- macOS only (plausible): `macos.rs:666` reads the 16.16 fixed-point `ScrollWheelEventFixedPtDeltaAxis1` with `integer_value_field`, which returns the value rounded to the nearest integer rather than the raw bits, making native scroll ~65536x too small. Those fields are documented 16.16 fixed-point and the double accessor is the one that applies scaling. On-device falsifier: run `examples/dump_macos_input.rs`, scroll one notch; `±65536` kills it, `±1` confirms. Fix is `double_value_field × Q16_16_SCALE`. + +### N2. Production CPU fallback is live while specs 76 and 77 declare capture GPU-only + +`crates/hypercolor-core/src/input/screen/macos.rs:1725`. Spec 77 invariant 1 ("never materializes a full frame on CPU") is a security-boundary claim that is currently false. Either land the removal (H3.5) or rewrite the invariants as target-state with the fallback named as the temporary mitigation. + +### N3. Repeated tap-disable permanently kills capture with no recovery trigger + +`macos.rs:538`. Two timeout-disables within the 10s window leave the tap disabled, and a disabled tap fires no callbacks, so nothing re-enables it. No `Degraded`-to-restart path exists anywhere. Sleep/wake or a debugger pause can trigger this. + +### N4. CFRunLoopStop race can hang stop() forever + +`macos.rs:425`. If `stop()` lands after the worker's `stopping` check but before `CFRunLoopRun` marks the loop running, `CFRunLoopStop` is a no-op, the loop runs indefinitely, and `MacosInputSession::stop` blocks forever in `join()`. Fix: install a dedicated `CFRunLoopSource` whose handler calls `CFRunLoopStop` from inside the loop. + +### N5. Webview has no navigation guard while holding the session credential + +`crates/hypercolor-app/src/main.rs:80` builds the window with only `on_new_window`; there is no `on_navigation`, and the capability is scoped only by window label (`capabilities/default.json`). Given an XSS sink in the bundled SPA, a remote page inherits `window.__TAURI__`, reads the 256-bit `protected_control_credential`, and drives protected capture over loopback. Add an `on_navigation` that rejects non-`tauri` origins. + +### N6. macOS releases and homebrew job deleted from public CI, undisclosed in PR body + +Tagged releases from `ci.yml` will ship zero macOS artifacts, and the `update-homebrew` job deletion also kills Linux formula updates as collateral. This is deliberate (public CI cannot sign), but `docs/development/RELEASING.md:44` still claims the tap gets updated and lists `HOMEBREW_TAP_TOKEN` as required, and the PR body does not mention the removal. + +--- + +## Follow-ups + +- Non-constant-time credential and API-key compare (`api/security.rs:196`, `:769`). +- Control API key grants capture from non-loopback; a privacy-bearing capability arguably should require loopback even with a control key. +- CapsLock physical release emits a phantom `Repeated` event and inflates `impossible_key_edges` each cycle (`core/src/input/macos.rs:1225`). +- `NSEvent::eventWithCGEvent` runs on a thread with no autorelease pool (`macos.rs:581`); slow leak plus console spam. Wrap the type-14 branch in `autoreleasepool`. +- `native.rs` at 9,360 lines defers its own decomposition (spec 77 H6.1); the two new submodules prove the extraction pattern. +- 1Hz re-verify loop (`supervisor/mod.rs:66`) where the repo already ships a notify-watcher (`startup/macos_owner_watch.rs`). +- Private-selector secure-input workaround (`app/src/window.rs:126`) fails silent if WebKit renames `_resetSecureInputState`; add a once-per-process warn, and note it is a Mac App Store rejection vector if MAS is ever on the table. +- The `canvas` watch channel is deliberately not control-gated; confirm no screen-source producer composites into it. + +--- + +## Structural assessment + +The ownership machinery is 12 concepts and ~16,800 diff lines (macos-owner lib 3,096 + tests 1,680, app ownership 1,303, supervisor 1,491, launcher authority 585, owner watch 780 + tests 793, canary 3,520 + tests 1,985, CLI 645, scripts 1,520). The branch defines 204 distinct public `Macos*` types. + +The tell: spec 77 invariant 5 states the flock is the only ownership authority, yet the branch ships four on-disk artifacts and two locks whose mutual consistency requires the read-probe-reread verification machinery. Simpler alternative: the winner writes `{owner, incarnation, server_session_id, credential}` into the locked guard file and fsyncs; reading while probing contention becomes atomic by construction, collapsing the attestation file, the cross-file consistency code, and the drift re-read into one locked read. Handover becomes launchd bootout/bootstrap (idempotent, each launcher already self-verifies via authority evidence). + +Two more structural items worth folding into the same pass: the 6,296-line TCC canary release-harness lives inside the daemon crate (a standalone `hypercolor-macos-canary` bin crate keeps the daemon at zero canary surface), and large inline test bodies plus ~35 `#[cfg(test)]` seams woven into production types in `native.rs` and `screen/macos.rs` grip internals and break on refactor. + +--- + +## CI status + +All 9 failures currently shown on the PR ran against the stale pre-rebase remote head and are already fixed on this lineage (verified: cross-target `cargo check` for linux-gnu and windows-msvc both finish clean; the E0004 non-exhaustive match and dead-code deny were fixed by the portable-compilation commit; the Intel nasm gap is fixed by an added `brew install nasm` step; the SDK Biome import-sort was re-sorted). + +One new red test on the rebased branch: `every_static_router_operation_is_cataloged` (added by the branch itself, commit 859df1ad) fails because the effect-preset routes (`/effects/{id}/presets`, `/effects/{id}/presets/{preset_id}/apply`, on `origin/main` since before v0.3.2) were never added to the hand-maintained OpenAPI catalog in `api/openapi.rs`. Pre-existing gap, surfaced by the branch's stricter test. The failing test does not exist on `main` (confirmed: a `test-one` run on main matches 0 tests). Fix is two catalog entries; it will keep `Rust Test / Daemon` red until patched. + +--- + +## What is solid (verified good, do not touch) + +- Transaction core: claim-once, settlement Drop converting abandoned `Ok` to `Cancelled`, deadline rearm revisions, timeout-vs-complete races, stop quarantine released by late witness. Strong targeted tests. +- IOSurface ownership: both cache layers key on session + resource generation + surface id + shape + allocation, refresh the retained owner on hit, evict only the cache's own retention while in-flight frames hold independent Arcs; the surface lease re-verifies id/extent/format/allocation. +- Sequence-0 first-frame bug: fixed. Core shifts `frame.sequence.checked_add(1)` before the nonzero gates; epochs start at 1. +- Latency histogram: proper seqlock (odd/even generation, single writer, reader re-validates behind an acquire fence) with a mid-snapshot-write test. +- Tap disable/re-enable: timeout and user-input disables both enqueue an ordered `StateGap`, bump per-reason counters, re-enable once, degrade on repeat. +- Owner-store file I/O: TOCTOU-resistant (`symlink_metadata`, rejects symlinks/non-regular files, enforces 0600 + owner-uid, re-validates the opened fd via dev/ino); atomic writes via `O_EXCL` temp + fsync + rename + parent-dir fsync. +- Credential generation: 256-bit from `/dev/urandom`, `[REDACTED]` Debug, never serialized over HTTP (only `server_session_id` is exposed, at the exempt `/api/v1/server`). +- Launcher-authority env var is a claim only; real authority is derived from process inspection plus code-signing evidence, and env-vs-CLI disagreement is rejected. Spoofing is fail-closed; the residual is DoS-by-inherited-env. +- Loopback enforcement, WS capture-channel gating, MCP redaction, WS token log redaction, and the trusted-local bridge (in-process, not network-reachable) all check out. +- Cross-platform cfg-gating hygiene is clean at every spot checked; no performance baselines reduced. + +--- + +## Negative space and process transparency + +This was a level-3 deep pass: 8 read-only lens agents on a frozen artifact (capture core, host input, ownership/app-shell, daemon API contracts, security, CI/intent-drift, cross-platform regression, structure/sprawl), with load-bearing anchors independently grep-verified and the two live symptoms reproduced against logs and live process state. Blocking findings B1, B2, B4, and the scroll and openapi items were confirmed by code trace or static read; B3 and B5 carry named falsifiers (a non-macOS unit test and an on-device `dump_macos_input` run respectively); N1's macOS leg is plausible pending the one-notch on-device check. + +No builds were run inside the lenses (read-only). The orchestrator ran `just check`, `just lint`, and `just test-crate` on the five macOS-touched crates (all green except the openapi catalog test), plus cross-target compiles via the CI lens. Not reviewed line-by-line: the 2,071-line GPU bench example, the 571-line signing C, and the excluded-from-workspace `hypercolor-ui` internals beyond the daemon-connection consumption path. The macOS Intel release fix is verified by workflow diff only, since no Intel runner run exists on this lineage. + +Rebase: the branch is rebased onto `origin/main` (was 124 ahead / 3 behind, now 124/0); local `main` is rebased too, with the unpushed docs commit clean on top. Autostash preserved the entire dirty tree. + +--- + +## Addendum: fix pass landed (2026-08-15) + +Every blocking and non-blocking finding above is fixed on the branch as of +`c1d78b8e` (ten commits, `ebb1dfb4..c1d78b8e`), verified by four adversarial +Opus verification agents plus full gates (workspace clippy at deny-warnings, +crate suites, SDK typecheck/lint/tests). Three corrections the verification +round made to this review's own claims: + +- N1's all-platform leg reversed: the adapter's missing `/120` is the + contract, not the bug. Spec 76 §wheel defines `mouse.wheel` as 1/120-notch + units, the SDK bridge test pins `-240` per two notches, and commit + `ec5dfc54` removed the division deliberately. The real defect was the + keystrike consumer's stale per-notch factor, fixed there. The macOS + fixed-point leg was confirmed and fixed as written (double accessor, + Q16.16 scale-back). +- The "all green except openapi" test claim was too strong: cargo's + per-binary fail-fast meant the render-thread suite never ran during the + review. It fails 3-5/46 under default intra-binary parallelism on + high-core machines (passes 46/46 single-threaded; `origin/main` flakes + 1/46 the same way). Test-harness fragility, tracked in sibyl, not a + product defect proven. +- The review's B1 fix sketch missed that the terminal conflict-exit branch + is the *primary* orphan signal; the landed fix reclaims from all three + watchdog failure paths. + +Two review-adjacent surfaces were found and deliberately deferred (tracked +in sibyl): the consent model for capture-reactive effect applies, and MCP +tool authentication. The structural ownership collapse and platform-code +disentanglement remain the follow-up architecture phase. diff --git a/docs/development/DEV_SETUP.md b/docs/development/DEV_SETUP.md new file mode 100644 index 000000000..3aceef36d --- /dev/null +++ b/docs/development/DEV_SETUP.md @@ -0,0 +1,150 @@ +# Development Environment Setup + +Per-platform setup for building and running Hypercolor from source. The +common workflow (`just verify`, per-area gates, PR expectations) lives in +[CONTRIBUTING.md](../../CONTRIBUTING.md); this document covers what each +operating system needs before those commands work. + +## All platforms + +- **Rust** via [rustup](https://rustup.rs). `rust-toolchain.toml` pins the + toolchain (currently 1.95.0); rustup installs it automatically on first + build. Edition 2024, minimum supported Rust 1.94. +- **[just](https://github.com/casey/just)**, the command runner every + workflow goes through. +- **[Bun](https://bun.sh)** for the TypeScript effect SDK and bundled + effects (`just sdk-install` once, then `just effects-build`). +- **Trunk** and the `wasm32-unknown-unknown` target for the web UI + (`just ui-dev` prints what it needs if something is missing). + +Every `just` build routes through `scripts/cargo-cache-build.sh`, which +wires up sccache or ccache automatically when installed. Neither is +required, but Servo builds are dramatically faster with a warm cache; see +[SERVO_BUILD_CACHING.md](SERVO_BUILD_CACHING.md). + +## Linux + +Install the system libraries the daemon, app shell, and Servo renderer +link against. On Debian/Ubuntu: + +```bash +sudo apt install \ + libudev-dev libdbus-1-dev libasound2-dev libpulse0 \ + libpipewire-0.3-dev \ + ccache clang cmake jq nasm pkg-config lld \ + libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xfixes0-dev \ + libxdo-dev libfontconfig1-dev libegl1 libssl-dev \ + libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev +``` + +The last line (GTK, WebKitGTK, appindicator, rsvg) is only needed for the +desktop app shell and tray; daemon-only work can skip it. `clang` and +`lld` are load-bearing: the build wrapper selects them for linking. + +USB and HID device access needs the udev rules: + +```bash +sudo just udev-install +``` + +Screen capture uses the Wayland XDG portal and prompts per session; no +extra setup. + +## macOS + +- **Xcode Command Line Tools** (`xcode-select --install`). Full Xcode is + not required. +- Minimum deployment target is macOS 15.2 (Sequoia). + +### Code signing for local bundles + +`just app-bundle` signs the app with the identity from +`APPLE_SIGNING_IDENTITY`, falling back to a local certificate named +**Hypercolor Dev**, and finally to ad-hoc signing. + +The fallback matters because of how macOS permissions work. TCC keys +Screen Recording and Input Monitoring grants to the app's code-signing +identity. An ad-hoc signature has a per-build identity (its designated +requirement is the build's cdhash), so every rebuild is a brand-new app +to macOS: System Settings keeps showing the old build's toggle as +enabled while the new build reads `not_determined` and has to be granted +again. A certificate-anchored signature is stable across builds, so +grants stick. + +One-time setup: + +1. Open **Keychain Access**, then from the menu bar choose + **Keychain Access → Certificate Assistant → Create a Certificate**. +2. Name: `Hypercolor Dev`. Identity type: Self-Signed Root. + Certificate type: **Code Signing**. Create. +3. Trust it for code signing (macOS asks for your password): + + ```bash + security find-certificate -c "Hypercolor Dev" -p > /tmp/hypercolor-dev.pem + security add-trusted-cert -p codeSign \ + -k ~/Library/Keychains/login.keychain-db /tmp/hypercolor-dev.pem + ``` + +4. Verify: `security find-identity -v -p codesigning` lists + `"Hypercolor Dev"` as a valid identity. + +The first signed build pops a keychain dialog asking whether `codesign` +may use the key; choose **Always Allow**. After granting Screen +Recording or Input Monitoring to a bundle signed this way, the grants +survive rebuilds. Stale rows from earlier ad-hoc builds can be removed +in System Settings with the minus button. + +Two more pieces happen automatically during `just app-bundle`, both +mirroring the release lane. The entitlements include +`disable-library-validation` because Servo links Homebrew dylibs signed +by other teams, which hardened-runtime library validation would refuse +once the bundle is certificate-signed. And +`scripts/macos-dev-postsign.sh` re-signs the daemon with the +`tech.hyperbliss.hypercolor.sidecar` identifier after the Tauri build, +because the daemon ownership handshake verifies that identity chain +between the app and its sidecar; Tauri alone would sign it with a +filename-derived identifier the handshake rejects. + +Release DMGs use a real Developer ID plus notarization through +`just mac-installer`; see [RELEASING.md](RELEASING.md). The dev +certificate is for local iteration only and never ships. + +### Permission model + +The daemon asks for Microphone, Screen Recording, or Input Monitoring +only when the matching feature is enabled. After a grant, macOS +requires a process restart before capture APIs see it; the Settings +page offers that restart when it applies. + +## Windows + +- **Visual Studio Build Tools** with the C++ workload (MSVC linker), or a + full Visual Studio install. +- **WebView2 runtime** for the app shell. Preinstalled on Windows 11; + the packaged installer bootstraps it on Windows 10. +- All `just` recipes have Windows variants and run through PowerShell. + +Optional hardware support: + +- Motherboard and DRAM RGB (SMBus) goes through the PawnIO kernel + driver and a broker service. `scripts/install-windows-hardware-support.ps1` + installs both; the individual scripts it wraps live next to it in + `scripts/`. +- Running the daemon as a Windows service uses + `scripts/install-windows-service.ps1`. Keyboard and mouse capture + requires an interactive session, so prefer the foreground app while + developing input features. + +## Smoke test + +Any platform, once set up: + +```bash +just verify # fmt + lint + test +just daemon # daemon on :9420 +just ui-dev # web UI on :9430, proxying to the daemon +``` + +macOS and Windows app-shell work uses `just app` for the iteration +build and `just app-bundle` for a native bundle. diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index 832e43c54..ebe954d97 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: @@ -37,11 +38,44 @@ What the Release workflow does, in order: with `GITHUB_TOKEN` never fire `on: push` workflows; the tag-lane jobs in ci.yml accept `workflow_dispatch` for exactly this reason. -The CI tag lane then builds all platform artifacts, creates the GitHub -Release with the committed notes, publishes `hypercolor` + +The CI tag lane then builds the Linux and Windows artifacts, creates the +GitHub Release with the committed notes, publishes `hypercolor` + `create-hypercolor` to npm (with provenance; prereleases go to the `next` dist-tag), publishes the Python client to PyPI (stable only), and updates -the Homebrew tap and AUR metadata (stable only). +the AUR metadata (stable only). + +Public CI ships no macOS artifacts and does not update the Homebrew tap: +macOS binaries require Developer ID signing that repository runners cannot +perform, so signed macOS artifacts are produced and attached through the +signed acceptance checkpoint below, and tap updates are manual until a +signing-capable release lane exists. + +## 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 @@ -50,7 +84,7 @@ the Homebrew tap and AUR metadata (stable only). | `ANTHROPIC_API_KEY` | repo secret | git-iris release notes + changelog (required) | | npm trusted publishers | npmjs.com package settings | `publish-npm` uses OIDC (no token, automatic provenance); register repo `hyperb1iss/hypercolor`, workflow `ci.yml` on **both** `hypercolor` and `create-hypercolor` | | PyPI trusted publisher | pypi.org project settings | `publish-pypi` uses OIDC; register repo `hyperb1iss/hypercolor`, workflow `ci.yml` | -| `HOMEBREW_TAP_TOKEN` | repo secret | tap pushes (already configured) | +| `HOMEBREW_TAP_TOKEN` | repo secret | currently unused; retained for the future signing-capable tap lane | | `GIT_IRIS_MODEL` | repo variable, optional | override git-iris's default Anthropic model | ## Version alignment diff --git a/docs/specs/14-screen-capture.md b/docs/specs/14-screen-capture.md index 48a08c906..430b0618c 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. --- @@ -57,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. @@ -86,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) @@ -124,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 | @@ -148,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. @@ -283,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 | --- @@ -401,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, @@ -932,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) @@ -1419,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 | @@ -1430,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 @@ -1591,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 @@ -1604,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 @@ -1638,9 +1559,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 @@ -1650,6 +1572,8 @@ a demand-driven request flow rather than a default-on config. 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 @@ -1663,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 @@ -1681,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. 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/67-macos-installer.md b/docs/specs/67-macos-installer.md index 011c1ec02..25e919a17 100644 --- a/docs/specs/67-macos-installer.md +++ b/docs/specs/67-macos-installer.md @@ -1,10 +1,11 @@ -# 67 — macOS Installer: Wired State and Signing Flip-The-Switch +# 67: macOS Installer Packaging and Release Boundary -> Captures the macOS bundle pipeline as it stands today, the discrete pieces of -> hardware-key infrastructure required to ship a Developer ID notarized DMG, and -> the exact patches to apply once the Apple credentials are provisioned. +> Captures the macOS bundle pipeline, the public OSS validation surface, and the +> private release boundary for Developer ID signing and notarization. -**Status:** Active — local + CI scaffolding wired, signing/notarization deferred until creds exist +**Status:** Implemented. Public CI validates unsigned app packaging without +release credentials. Proprietary builds sign, notarize, accept, and promote +macOS release artifacts. **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`) @@ -19,18 +20,21 @@ ### 1.1 Bundle assembly -Local and CI builds both produce per-arch DMG + `.app` artifacts via Tauri 2's bundler. +Local and CI builds produce per-architecture `.app` artifacts through Tauri 2's +bundler. Public CI retains them only as short-lived unsigned packaging fixtures. +The proprietary release pipeline produces the signed and notarized DMGs. | Surface | File | Status | |---|---|---| | 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 | +| Per-arch CI build matrix (`macos-arm64`, `macos-x64`) | `.github/workflows/ci.yml` § `build-native-app` | Live; uploads short-lived unsigned `.app` fixtures only | +| Manifest-driven Developer ID signing and notarization actor | `scripts/sign-macos-artifacts.sh` | Live; invoked only by local or proprietary builds | | 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 | +| Signed DMG and Homebrew Cask promotion | Proprietary release pipeline | Private; never receives credentials from OSS CI | ### 1.2 Local build script @@ -48,12 +52,13 @@ Prerequisites it asserts: `cargo`, `rustc`, `bun`, `trunk`, `xcrun`, `cargo-taur ### 1.3 Icon ladder -`scripts/generate-mac-icons.sh` rasterizes `packaging/icons/hypercolor.svg` -through Quick Look (WebKit-based, ships with macOS) at 1024px, downscales the -full Apple iconset (16/32/128/256/512 at @1x and @2x) via `sips`, and assembles -`icon.icns` with `iconutil`. The text wordmark is stripped from the source SVG -before rasterizing because it is illegible below 128px and macOS HIG recommends -against text inside dock icons; the Finder/Dock label already names the app. +`scripts/generate-mac-icons.sh` delegates to the canonical brand pipeline +(`assets/brand/build.py app-icon`), which renders the app icon set from the +checked-in brand masters with a ~5% safe-margin inset and writes the six +Tauri assets plus `icon.icns` into `crates/hypercolor-app/icons/`. The icon +carries no wordmark because text is illegible below 128px and macOS HIG +recommends against text inside dock icons; the Finder/Dock label already +names the app. Generated files committed under `crates/hypercolor-app/icons/`: @@ -71,13 +76,19 @@ committed so contributors without Quick Look tooling can still build. --- -## 2. What Signing + Notarization Need +## 2. Signing and Notarization Boundary Distribution outside the Mac App Store still requires an Apple-issued Developer ID certificate and notarization service. Per [`46-cross-platform-packaging.md` §11.2](../design/46-cross-platform-packaging.md#112-macos--apple-developer-id--notarization-required) -this is a $99/yr Apple Developer Program membership plus an -`apple-actions/import-codesign-certs@v3` step in CI. +this requires an Apple Developer Program membership, a Developer ID identity, +an App Store Connect API key, and the repository's manifest-driven signing +actor. + +Apple release credentials belong exclusively to local private keychains and +the proprietary release system. They are never configured as secrets in the +public OSS repository. Public workflows never sign, notarize, or publish a +macOS artifact. ### 2.1 One-time setup @@ -85,108 +96,55 @@ this is a $99/yr Apple Developer Program membership plus an 2. In Keychain Access, request and download the **Developer ID Application** certificate (do **not** use "Mac App Distribution"; that's MAS-specific). 3. Export the cert + private key as a `.p12` with a password. Note the password. -4. Generate an app-specific password for the Apple ID at - → Sign-In and Security → - App-Specific Passwords. (Alternatively, provision an App Store Connect API - key at — preferred for CI.) +4. Provision an App Store Connect API key at + . Store it only in the + proprietary release system. 5. Note the Team ID from . -### 2.2 GitHub secrets to add +Local Apple ID notarization uses a stored `notarytool` profile. Run +`xcrun notarytool store-credentials hypercolor-notary`, then enter the Apple ID, +Team ID, and app-specific password at the secure prompts. Never pass the +password to `notarytool submit`. -In the repo settings (`Settings → Secrets and variables → Actions`): +### 2.2 Proprietary release inputs -| Secret | Value | -|---|---| -| `APPLE_DEVELOPER_ID_P12` | Base64 of the exported `.p12` (`base64 -i cert.p12 | pbcopy`) | -| `APPLE_DEVELOPER_ID_P12_PASSWORD` | The password used during `.p12` export | -| `APPLE_SIGNING_IDENTITY` | The full identity string, e.g. `Developer ID Application: Stefanie Jane (TEAMID)` | -| `APPLE_ID` | The Apple ID email used for the Developer Program | -| `APPLE_TEAM_ID` | The 10-character team identifier | -| `APPLE_APP_SPECIFIC_PASSWORD` | The app-specific password from step 4 | - -If using App Store Connect API keys instead, swap the last three for -`APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_API_KEY_PATH`. - -### 2.3 CI patch (drop-in for `.github/workflows/ci.yml`) - -In the `build-native-app` job, gate signing/notarization to tag builds with -the matrix entries that have `cask_arch != ""`. Add these steps before the -existing **Build Tauri native bundle** step: - -```yaml -- name: Import Apple Developer ID certificate - if: matrix.cask_arch != '' && startsWith(github.ref, 'refs/tags/') - uses: apple-actions/import-codesign-certs@v3 - with: - p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12 }} - p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} -``` +The proprietary pipeline provides the signing identity, team identifier, +certificate material, and a private App Store Connect API-key file to +`scripts/sign-macos-artifacts.sh`. The API key must be a non-symlink regular +file with mode `0400` or `0600`. -Update the **Build Tauri native bundle** step to drop `--no-sign` on signed -runs and to surface the signing identity: - -```yaml -- name: Build Tauri native bundle - working-directory: crates/hypercolor-app - shell: pwsh - env: - TAURI_BUNDLES: ${{ matrix.bundles }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - $configArgs = @() - if (Test-Path "tauri.bundle.conf.json") { - $configArgs += @("--config", "tauri.bundle.conf.json") - } - if ($env:RUNNER_OS -eq "Windows" -and (Test-Path "tauri.windows.bundle.conf.json")) { - $configArgs += @("--config", "tauri.windows.bundle.conf.json") - } - $signArgs = @() - if ($env:RUNNER_OS -ne "macOS" -or [string]::IsNullOrEmpty($env:APPLE_SIGNING_IDENTITY)) { - $signArgs += @("--no-sign") - } - cargo tauri build --ci @signArgs --bundles $env:TAURI_BUNDLES @configArgs -``` +The signing actor imports PKCS#12 and ephemeral-keychain passwords through a +bounded stdin frame into Security.framework. Neither password enters a process +argument. Raw Apple ID passwords are rejected. Interactive local signing may +use a stored `notarytool` keychain profile. -Add a notarization step after **Normalize macOS DMG artifact name**, before -**Upload native app bundle**: - -```yaml -- name: Notarize and staple macOS DMG - if: matrix.cask_arch != '' && startsWith(github.ref, 'refs/tags/') - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - run: | - set -euo pipefail - dmg="$(find target/release/bundle/dmg crates/hypercolor-app/target/release/bundle/dmg \ - -maxdepth 1 -type f -name '*.dmg' 2>/dev/null | head -1)" - [ -n "${dmg}" ] || { echo 'no DMG found for notarization' >&2; exit 1; } - xcrun notarytool submit "${dmg}" \ - --apple-id "${APPLE_ID}" \ - --team-id "${APPLE_TEAM_ID}" \ - --password "${APPLE_APP_SPECIFIC_PASSWORD}" \ - --wait --timeout 30m - xcrun stapler staple "${dmg}" - xcrun stapler validate "${dmg}" -``` +### 2.3 Public OSS validation -Untagged builds and PRs keep building unsigned DMGs as today. +The public `build-native-app` matrix builds macOS app bundles with `--no-sign`, +checks the deployment target, and uploads the result under an `oss-ci-*` name +with seven-day retention. The release job downloads only `hypercolor-*` +artifacts, and its allowlist contains no macOS artifact type. Public CI also +runs the signing transport regression test without using real credentials. --- -## 3. Local Signed Build +## 3. Proprietary or Local Signed Build Once the cert is in your local keychain (System keychain → "My Certificates"): ```bash export APPLE_SIGNING_IDENTITY="Developer ID Application: Stefanie Jane (TEAMID)" -export APPLE_ID="" export APPLE_TEAM_ID="TEAMID" -export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" +export APPLE_API_KEY_ID="KEYID" +export APPLE_API_ISSUER="issuer-uuid" +export APPLE_API_KEY_PATH="/private/path/AuthKey_KEYID.p8" just mac-installer --notarize ``` +To use the stored local profile instead, set +`APPLE_NOTARY_KEYCHAIN_PROFILE=hypercolor-notary` and omit the three API-key +variables. + The script auto-detects which env vars are present and only invokes notary when asked. Unsigned local builds remain a one-liner for dev iteration: @@ -215,7 +173,7 @@ codesign --verify --deep --strict --verbose=2 "/Volumes/Hypercolor/Hypercolor.ap A successful run prints `the validate action worked!`, `accepted`, and `valid on disk` respectively. If `spctl` reports `rejected (rejected source=no usable signature)` or notary returns `Invalid`, fetch the log with -`xcrun notarytool log --apple-id ... --team-id ... --password ...` +`xcrun notarytool log --keychain-profile hypercolor-notary` and read the JSON for the offending file (typically a sidecar binary that needs hardened-runtime entitlements applied via `codesign --deep`). @@ -223,12 +181,12 @@ hardened-runtime entitlements applied via `codesign --deep`). ## 5. Cask Publication -The `update-homebrew` CI job already templates `packaging/homebrew/hypercolor-app.rb` -with the per-arch DMG SHA256s and commits the result to `hyperb1iss/homebrew-tap` -under `Casks/hypercolor-app.rb`. No additional work is required for cask -distribution once the DMG is notarized — `brew install --cask` reads the URLs -from the cask formula, downloads the notarized DMG, and Homebrew's mount-and-copy -flow inherits the staple from the DMG. +The proprietary release pipeline templates +`packaging/homebrew/hypercolor-app.rb` with the accepted per-architecture DMG +digests and promotes the cask only after receipt validation. Public OSS CI does +not hold the tap token and cannot update the cask. `brew install --cask` reads +the promoted URLs, downloads the notarized DMG, and preserves the stapled +ticket through Homebrew's mount-and-copy flow. --- @@ -255,11 +213,11 @@ These items are explicitly out of scope for the v1 launch per | Trigger | Command | |---|---| | Regenerate icons after editing SVG | `just mac-icons` | -| Build unsigned DMG for local testing | `just mac-installer --profile preview` | +| Build unsigned `.app` for local testing | `just mac-installer --profile preview` | | Build signed + notarized DMG locally | `just mac-installer --notarize` (env vars required) | | Check prerequisites only | `just mac-installer --check-only` | | Validate a notarized DMG | `xcrun stapler validate ` | -| Read notary failure log | `xcrun notarytool log --apple-id ... --team-id ... --password ...` | +| Read notary failure log | `xcrun notarytool log --keychain-profile hypercolor-notary` | --- 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 d3142dea6..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 @@ -890,16 +891,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 +1023,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 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..fecfc976f --- /dev/null +++ b/docs/specs/76-macos-screen-capture-and-host-input.md @@ -0,0 +1,2672 @@ +# 76 - macOS Screen Capture and Host Input + +**Status:** Implementation-ready, revision 27; GPU-only amendment approved +**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 +**Hardening companion:** spec 77 +**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 zero-full-frame-copy IOSurface and Metal production path with a + fixture-only CPU correctness oracle. +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. +17. Production macOS screen capture is GPU-only. Missing or failed Metal + capability invalidates stale output, rebuilds native execution, and fails + closed without selecting a CPU capture, conversion, publication, reduction, + or compositor path. + +## 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 production-safe operations are metadata inspection and +handoff to the macOS GPU interop crate. Fixture-gated tests may map retained +storage for parity oracles, but production types expose no CPU mapping path. + +`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 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 +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. Fixture-only CPU correctness oracle + +The CPU implementation is a bring-up and parity oracle compiled only for tests +with the macOS capture fixture feature. It is not an `InputSource`, publication +executor, runtime fallback, diagnostic recovery mode, or production feature. +Production macOS types cannot request, construct, or select it. + +The oracle locks a retained fixture `CVPixelBuffer` read-only, validates every +plane, stride, extent, and length, and converts into fixture-owned output. It +never publishes into the render thread or participates in source lifecycle. +Fixture generations still fence asynchronous test work so stale conversions +cannot make a parity assertion pass against the wrong source. + +Supported oracle 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 feature described by this spec is not complete until every listed format +passes the native GPU path and HDR acceptance where supported. Missing native +capability never lowers capture resolution or FPS and never activates the +oracle. It publishes typed native-unavailable state and no screen frame. + +The oracle and GPU output use the same golden fixture suite. A native 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 +- fixture-only oracle storage when a parity test explicitly admits it. + +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 structural validation. +A candidate that returns nil, selects an incompatible mode, copies, or names the +wrong IOSurface plane is rejected before the next candidate runs. Production +startup does not map frame bytes or consult a CPU parity result when selecting +an importer. + +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 fixture +and signed physical acceptance. Those gates alternate incompatible byte patterns +across every reused queue slot, sample immediately and after sustained load, +check the imported texture's IOSurface and plane identity, and compare completed +GPU output with the fixture-only CPU oracle. They qualify the shipped importer +implementation and never run as production path-selection logic. + +`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, structural validation 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. The fixture oracle may diagnose the failure, but production remains +native-unavailable and cannot satisfy the first-class Intel claim until the GPU +mechanism passes. + +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. + +A bounded diagnostic may copy only a completed GPU result into CPU-visible +memory after the owning command buffer signals completion. The readback belongs +to the final publication, uses a size-bounded staging allocation charged to the +diagnostic budget, and is dropped after protocol, device-output, or diagnostic +delivery. No readback result may select an importer, mutate capture state, feed a +later capture or composition pass, or provide a production recovery path. + +### 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. + +### 11.5 Native execution recovery + +The daemon owns one transactional native execution state: + +```text +Ready(target N) + -> Invalidating(error) + -> Rebuilding + -> Ready(target N+1) + -> Unavailable(last error) +``` + +A structural import, target-owner, extent, descriptor, encoder, or submission +failure clears the compositor's retained screen layer, releases every +screen-specific GPU cache, fences the failed target generation, and rebuilds +the bridge, reducer, preparer, and execution target. The replacement target is +published only after full construction succeeds, and active demand is then +resolved against its new identity. + +Only a specifically typed transient not-ready result may retain a still-fresh +current frame. Persistent and unclassified errors invalidate immediately. +`Unavailable` retains GPU-required demand and may attempt native reconstruction +after new demand or frame activity, but it exposes no CPU execution edge. + +## 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. + +The fixture-only CPU oracle and production GPU kernels 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, the diagnostic harness runs paired SDR and HDR +configurations through the same ScreenCaptureKit, IOSurface, Metal, and +SparkleFlinger path used in production. On an SDR-only Tahoe selection, it runs +one SDR configuration 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. + +Diagnostics may inspect only the bounded completed-GPU egress described in +section 11.3. Core Graphics snapshots and CPU-side platform reference capture +are not part of the shipped diagnostic. Fixture tests retain the platform-neutral +CPU oracle for deterministic parity vectors. + +## 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-surface +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 shared tone-map transition constants and GPU uniforms at a frame + boundary without reopening the native stream. Fixture oracles consume the + same constants only under test; +- 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 +- native-ready, native-invalidating, native-rebuilding, native-pending, or + native-unavailable execution state with an exact bounded reason; +- invalidation epoch, active target generation, rejected stale-publication + count, and the last completed recovery transaction state. + +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 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; +- 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`; +- injected structural import, conversion, reduction, and device-loss failures + atomically clear retained output and native caches, fence the failed target + generation, reject every stale publication, rebuild the complete native + target, and either resume with a newer generation or become + native-unavailable without a CPU recovery path; +- 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 fixture CPU oracle fences stale native frames and accepts only matching + source and session generations without registering a production publisher; +- `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. Native validation, import, and GPU + reduction are reported separately as capture-to-native-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. Missing or failed GPU capability reports native-unavailable state and drops + the screen layer. It never rewrites a request or selects CPU work 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: ScreenCaptureKit source and fixture oracle + +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 the fixture-only BGRA8 correctness oracle. +6. Wire status, API actions, UI remediation, and diagnostics. + +Exit: signed native acquisition is correct across topology, lifecycle, picker, +and permission acceptance. The fixture oracle matches golden fixtures and is +unavailable to production builds. + +### 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 fixture-oracle GPU parity on Apple + Silicon and Intel. +7. Prove the steady-state zero-full-frame-copy contract. + +Exit: native SDR capture is the only production path, production targets contain +no CPU capture, conversion, publication, reduction, or compositor executor, the +injected structural-failure matrix proves full GPU invalidation and rebuild, and +the path 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 shared reference + 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; +- Metal output matches the fixture-only oracle on canonical fixtures; +- production artifacts contain no CPU capture, conversion, publication, + reduction, or compositor executor for macOS screen input; +- injected structural GPU failures clear retained output, reject stale target + generations, rebuild the complete GPU target, and become unavailable without + CPU recovery when rebuilding cannot succeed; +- 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 GPU diagnostics ship for HDR-capable selections, and the + completed-GPU SDR 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 a +fixture-only oracle and is never a production 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 | diff --git a/docs/specs/77-macos-capture-security-and-release-hardening.md b/docs/specs/77-macos-capture-security-and-release-hardening.md new file mode 100644 index 000000000..1d5c059e3 --- /dev/null +++ b/docs/specs/77-macos-capture-security-and-release-hardening.md @@ -0,0 +1,926 @@ +# Spec 77: macOS Capture Security and Release Hardening + +Status: APPROVED +Author: Nova +Date: 2026-08-13 +Depends on: spec 61, spec 73, spec 76 +Baseline: `854f36f9ece49794f7c069da8e31cfcc86c0f96d` +Scope: macOS screen and host-input capture, GPU publication, daemon API +authorization, desktop trust, daemon ownership, installers, signing, CI, and +physical release acceptance. + +## Mission + +Finish the macOS capture and host-input feature as one coherent, production-ready +system. Close every confirmed correctness, security, maintainability, portability, +and release-integrity finding without reducing frame rate, resolution, preview +cadence, device-output cadence, or supported functionality. + +macOS production screen capture is GPU-only. ScreenCaptureKit acquisition, +IOSurface import, color conversion, tone mapping, composition, and LED reduction +remain on IOSurface, Metal, and wgpu. CPU implementations exist only as +fixture-gated parity oracles. A GPU failure invalidates stale output, rebuilds the +GPU route transactionally, and fails closed when recovery cannot complete. It +never selects a production CPU fallback. + +This spec is also the progress ledger for the hardening work. Task identifiers in +Sibyl mirror the identifiers below. + +## Non-negotiable invariants + +Invariants 1 and 3 describe the target capture plane and become binding when +H3.5 lands. Until then one named temporary mitigation remains: production +capture still carries the legacy CPU publication fallback +(`hypercolor-core/src/input/screen/macos.rs`), kept only so capture degrades +instead of dying where the GPU path cannot run. H3.5 removes it; no new code +may depend on it. + +1. Production macOS screen capture never materializes a full frame on the CPU for + composition, transformation, reduction, recovery, or fallback. +2. Explicit egress may read completed GPU output for a transport payload, such as + a WebSocket preview. The readback cannot feed another capture or compositor + path. +3. Missing, incompatible, or failed Metal capability produces `native_pending` or + `native_unavailable`. It never produces `cpu_fallback`. +4. Terminal capture or structural renderer failure clears retained screen output + before recovery begins. +5. The canonical `MacosDaemonGuard` flock is the only ownership authority. PID, + port health, tokens, diagnostics, and session artifacts cannot elect an owner. +6. Loopback is network locality, not user identity. TCC actions and sensitive + screen/input streams require protected-control authorization. +7. The packaged Tauri app renders bundled UI only. Daemon-served HTML never gains + Tauri command authority. +8. Managed owners stop through the topology that launched them. Handover never + signals a bare PID. +9. Release acceptance proves the exact signed artifacts that are promoted. No + accepted candidate is rebuilt before publication. +10. Existing performance ceilings remain product contracts. Hardening cannot + lower FPS, resolution, preview cadence, device cadence, or queue capacity to + hide a defect. + +## Target architecture + +### Capture data-plane ownership + +`hypercolor-macos-capture` owns ScreenCaptureKit callbacks, lifecycle ordering, +cancellation, and retained native-frame ownership. + +`hypercolor-core` owns publication authority, GPU-required demand, IOSurface +admission, tone-map transition state, freshness, and consumer accounting. + +`hypercolor-macos-gpu-interop` owns IOSurface, Core Video, and Metal wrapper +caches, including every backing lifetime those wrappers require. + +`hypercolor-daemon` owns Metal execution, target recovery, queue invalidation, +and compositor integration. + +### Control-plane authority + +Three authorities remain intentionally separate: + +1. The canonical flock decides which daemon owns macOS integration. +2. A private daemon-session attestation proves which server instance corresponds + to the flock winner. +3. A protected-control credential authorizes sensitive REST and WebSocket + operations. + +The session artifact and credential are evidence and authorization. Neither is a +second ownership lease. + +### GPU failure semantics + +The daemon owns a transactional native execution state machine: + +```text +Ready(target N) + -> Invalidating(error) + -> Rebuilding + -> Ready(target N+1) + -> Unavailable(last error) +``` + +A structural failure clears the compositor screen layer, releases every screen +GPU cache, fences the failed target generation, rebuilds the complete target, +and publishes the replacement only after construction succeeds. A specifically +typed transient not-ready condition may defer while retaining a still-fresh +current frame. Persistent and unclassified failures invalidate immediately. + +## Wave 0: contract and deterministic baseline + +### H0.1 Make GPU-only capture normative + +**Files:** `docs/specs/76-macos-screen-capture-and-host-input.md`, this spec + +**Depends on:** none + +**Parallel:** No + +#### Implementation + +- Remove every production CPU-fallback promise from spec 76. +- Define fixture-only CPU reference implementations. +- Define fail-closed Metal recovery and terminal publication invalidation. +- Define protected local control and exact-artifact acceptance. +- Sweep the revised spec for stale fallback terminology. + +#### Verify + +- [ ] Every remaining macOS `cpu` reference is explicitly fixture-only or a + rejected alternative. +- [ ] `just docs-build` passes. + +### H0.2 Restore portable macOS-capture compilation + +**Files:** `crates/hypercolor-macos-capture/src/lib.rs`, +`crates/hypercolor-macos-capture/src/frame.rs`, +`crates/hypercolor-macos-capture/src/diagnostics.rs`, +`crates/hypercolor-macos-capture/src/stream_contract.rs`, +`crates/hypercolor-macos-capture/src/worker.rs` + +**Depends on:** none + +**Parallel:** Yes, with H0.3 and H0.4 + +#### Implementation + +- Gate native-only modules and enum variants consistently. +- Preserve portable public contracts without compiling unused macOS internals. +- Add non-macOS compile-contract coverage. + +#### Verify + +- [ ] Linux no-default-feature daemon compilation passes. +- [ ] Workspace Clippy passes on non-macOS targets. +- [ ] Portable tests execute a nonzero test count. + +### H0.3 Complete the Python WebSocket generator + +**Files:** `protocol/websocket-v1.json`, +`python/scripts/generate_ws_protocol.py`, +`python/src/hypercolor/ws_protocol.py`, `python/tests/test_websocket.py` + +**Depends on:** none + +**Parallel:** Yes, with H0.2 and H0.4 + +#### Implementation + +- Generate every required and optional field schema. +- Preserve explicit defaults and distinguish no default from `null`. +- Compare generated event contracts directly with the protocol manifest. + +#### Verify + +- [ ] `just python-ws-protocol-check` passes. +- [ ] `just python-verify` passes. +- [ ] `just python-generate-check` passes. + +### H0.4 Restore deterministic CI gates + +**Files:** `.github/workflows/ci.yml`, `sdk/packages/core/src/input/data.ts` + +**Depends on:** none + +**Parallel:** Yes, with H0.2 and H0.3. One owner controls the workflow file. + +#### Implementation + +- Fix SDK Biome import ordering. +- Install NASM before Intel workspace compilation. +- Run the complete macOS capture fixture crate so inline lifecycle tests execute. +- Add a PR documentation build without enabling deployment. +- Add the GPU-only architecture check after H3.5 removes the old path. + +#### Verify + +- [ ] `just sdk-lint` passes. +- [ ] `just sdk-check` passes. +- [ ] `just sdk-build` passes. +- [ ] The macOS capture CI selector executes inline and integration tests. +- [ ] The docs job builds pull requests and deploys only from its existing release + boundary. + +## Wave 1: security and ownership boundaries + +### H1.1 Introduce protected-control authorization + +**Files:** `crates/hypercolor-daemon/src/api/security.rs`, +`crates/hypercolor-daemon/src/api/capture.rs`, +`crates/hypercolor-daemon/src/api/ws/protocol.rs`, +`crates/hypercolor-daemon/src/api/ws/session.rs`, daemon security and WebSocket +tests + +**Depends on:** none + +**Parallel:** Yes, with H1.4 and Wave 2 + +#### Implementation + +- Add one named `ProtectedControl` authorization requirement. +- Require it for input authorization, screen authorization, picker operations, + monitor enumeration, `screen_canvas`, `screen_zones`, and `input_events`. +- Ensure IP address, missing Origin, CORS, and Fetch Metadata cannot satisfy it. +- Reject unauthorized WebSocket subscriptions before creating capture demand. +- Preserve existing ordinary lighting-control loopback compatibility. + +#### Verify + +- [ ] Loopback without a credential receives 401 or 403. +- [ ] A read credential cannot satisfy protected control. +- [ ] A control credential succeeds. +- [ ] Rejected subscriptions create no screen or input demand. +- [ ] `cargo test -p hypercolor-daemon --test security_api_tests` passes. +- [ ] Focused WebSocket authorization tests pass. + +### H1.2 Publish private daemon-session attestation + +**Files:** `crates/hypercolor-macos-owner/src/lib.rs`, +`crates/hypercolor-daemon/src/main.rs`, +`crates/hypercolor-daemon/src/api/system.rs`, owner and daemon tests + +**Depends on:** H1.1 + +**Parallel:** No + +#### Implementation + +- Atomically publish a 0600 artifact after the daemon wins the canonical flock. +- Bind owner epoch, full process identity, server-instance ID, and protected + credential or verifier. +- Allow replacement or cleanup only for a matching identity and epoch. +- Keep ownership and journal schema v1 readable during upgrade and rollback. +- Ensure no code treats artifact presence as ownership authority. + +#### Verify + +- [ ] Wrong owner, UID, mode, identity, and epoch are rejected. +- [ ] A stale crash artifact is replaced by the next canonical winner. +- [ ] Attestation cannot create a second owner. +- [ ] `cargo test -p hypercolor-macos-owner` passes. + +### H1.3 Isolate Tauri from daemon-served content + +**Files:** `crates/hypercolor-app/src/main.rs`, +`crates/hypercolor-app/src/supervisor/mod.rs`, +`crates/hypercolor-app/tauri.conf.json`, +`crates/hypercolor-app/tauri.bundle.conf.json`, +`crates/hypercolor-app/capabilities/default.json`, +`crates/hypercolor-app/build.rs`, app tests, UI API and WebSocket connection +configuration + +**Depends on:** H1.2 + +**Parallel:** No. Serialize with H1.5 where both touch the supervisor. + +#### Implementation + +- Bundle staged UI through `frontendDist` and open `WebviewUrl::App` only. +- Remove remote URL command authority and wildcard custom-command access. +- Enumerate commands for the bundled app origin. +- Verify canonical ownership plus private attestation before exposing the + protected credential. +- Render a bundled offline/error shell on port preemption or identity mismatch. +- Keep screen pixels on the daemon GPU-backed stream. Do not add a Tauri proxy. + +#### Verify + +- [ ] A fixture pre-bound to port 9420 cannot supply the app document. +- [ ] Remote content cannot invoke Tauri ownership commands. +- [ ] Remote content never receives the protected credential. +- [ ] A matching child and a matching external owner open normally. +- [ ] `just ui-test` and app packaging tests pass. + +### H1.4 Remove PID as stop authority + +**Files:** `crates/hypercolor-macos-owner/src/lib.rs`, owner coordinator tests, +`crates/hypercolor-app/src/ownership.rs`, +`crates/hypercolor-app/src/supervisor/mod.rs`, app owner tests + +**Depends on:** none + +**Parallel:** Yes, with H1.1 and Wave 2. Serialize supervisor edits with H1.3. + +#### Implementation + +- Stop app sidecars through the retained child handle. +- Stop launchd and Homebrew owners through their exact service identities. +- Leave standalone owners user-directed. +- Remove production bare-PID signaling from restart and handover. +- Use flock release and a newer matching owner publication as progress proof. + +#### Verify + +- [ ] A stale record plus forced PID reuse signals no replacement process. +- [ ] Identity mismatch fails closed. +- [ ] Each managed topology targets only its selected launcher identity. +- [ ] Crash-phase replay remains idempotent. + +### H1.5 Make launcher metadata version-neutral + +**Files:** daemon launcher-resolution modules, +`crates/hypercolor-app/src/supervisor/mod.rs`, the `hypercolor` installer +transaction, launchd and Homebrew service files, `scripts/get-hypercolor.sh`, +`scripts/install-release.sh`, `scripts/dist.sh`, +`scripts/verify-release-artifact.sh`, packaging and supervisor tests + +**Depends on:** H1.4 + +**Parallel:** No. Serialize supervisor edits with H1.3. + +#### Implementation + +- During the compatibility window, launchers publish + `HYPERCOLOR_MACOS_OWNER` plus the equal deprecated `--macos-owner` argument. + New daemons prefer the environment, accept an equal argument, and reject a + conflict. Remove the argument only after the supported-version floor moves. +- Treat launcher metadata as a claim. Corroborate direct and Homebrew owners + through exact launchctl PID identity and app sidecars through their signed + parent before guard acquisition or owner publication. +- Add bounded legacy inference when metadata is absent. Malformed, ambiguous, + or failed identity inspection rejects startup. +- Harden artifact verification against path traversal, symlink, hardlink, + special, and duplicate members. Bind every member's type, mode, and digest. +- Let the candidate `hypercolor` binary own a Rust install transaction. Shell + wrappers only download, verify, and invoke it. Homebrew and app casks retain + their native transaction ownership. +- For raw direct installs, stage and verify an immutable digest-named unit + before stopping the current owner. Then preflight authority, unload, prove + guard release, switch one `active` symlink, reload, and require a newer exact + owner publication. +- Journal first-install conversion from an in-place layout into a complete + synthetic legacy unit before mutation. Keep this install journal and lock + separate from the canonical owner store, handover journal, and flock. +- Roll back the active unit, launcher metadata, and loaded state on failure. + Rollback completes only after a newer exact prior-owner publication. + +#### Verify + +- [ ] New launcher plus old daemon works in legacy mode. +- [ ] Old launcher plus new daemon is classified through bounded inference. +- [ ] Conflicting or uncorroborated launcher claims fail before guard + acquisition and owner publication. +- [ ] Unsafe archive members and manifest mismatches fail before install + mutation. +- [ ] Unsafe app and daemon skew fails before stopping the working owner. +- [ ] First conversion from an in-place install is crash-replay safe. +- [ ] Failure injection after every installer stage restores the prior unit. +- [ ] Rollback preserves the canonical flock, owner store, journal, and TCC + identity. + +## Wave 2: exact capture lifecycle and resource ownership + +### H2.1 Add coherent publication invalidation observations + +**Files:** `crates/hypercolor-core/src/input/screen/hub.rs`, +`crates/hypercolor-core/src/input/screen/macos.rs`, +`crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs`, +`crates/hypercolor-daemon/src/render_thread/frame_composer.rs` + +**Depends on:** none + +**Parallel:** Yes, with Wave 1 outside shared files + +#### Implementation + +- Add worker-authorized invalidation across every branch owned by a binding. +- Record a monotonic invalidation epoch. +- Expose publication, lifecycle, health, freshness, and epoch as one coherent + observation. +- Clear the compositor queue when the epoch advances before latching a newer + publication. + +#### Verify + +- [ ] Terminal invalidation clears all branches for exactly one binding. +- [ ] Pressure and recoverable health failure retain last-good output. +- [ ] A stale publisher cannot invalidate current authority. +- [ ] Invalidation followed by a fresh publication clears old output first. + +### H2.2 Separate lifecycle control from frame coalescing + +**Files:** `crates/hypercolor-macos-capture/src/worker.rs`, +`crates/hypercolor-macos-capture/src/mailbox.rs`, +`crates/hypercolor-macos-capture/src/native.rs`, +`crates/hypercolor-core/src/input/screen/macos.rs` + +**Depends on:** H2.1 + +**Parallel:** No + +#### Implementation + +- Admit only complete frames to the latest-value slot. +- Give lifecycle events an ordered control snapshot and invalidation generation. +- Stamp asynchronous decode work with the active generation. +- Reject completed decode work after a later terminal generation. +- Keep recoverable errors diagnostic without overwriting control or frame state. + +#### Verify + +- [ ] A blocked pre-suspend decode cannot publish after restart. +- [ ] Lifecycle events cannot be superseded by frame pressure. +- [ ] Frame traffic remains constant-memory and latest-value. +- [ ] Fatal errors invalidate exactly once. + +### H2.3 Bound and cancel native stream transactions + +**Files:** `crates/hypercolor-macos-capture/src/native.rs`, new internal +`native/transactions.rs` and `native/lifecycle.rs`, capture lifecycle tests + +**Depends on:** H2.2 + +**Parallel:** No + +#### Implementation + +- Replace raw receivers with typed transactions carrying cancellation and + deadlines. +- Bound native start completion, first complete frame, and stop completion. +- Preserve the previous committed stream when a candidate fails or times out. +- Fence late callbacks from cancelled, timed-out, or superseded epochs. +- Retire authority synchronously but wait and join off the main thread. +- Quarantine timed-out native objects until late completion or destruction. + +#### Verify + +- [ ] Missing start callback and missing first frame time out deterministically. +- [ ] Timeout racing with a valid frame commits exactly one result. +- [ ] Stop returns to the main-thread caller without waiting. +- [ ] Repeated activate and deactivate leaves no pending transaction. + +### H2.4 Track live IOSurface identities + +**Files:** `crates/hypercolor-core/src/input/screen/macos.rs`, new internal +`macos/surface_pool.rs`, admission tests + +**Depends on:** none + +**Parallel:** Yes, with H2.1 + +#### Implementation + +- Replace historical identity accumulation with one token per live IOSurface. +- Share tokens for repeated observation of the same identity. +- Reconcile exact bytes when the final token drops. +- Treat queue depth as an initial reserve, not an identity cap. + +#### Verify + +- [ ] A ninth historical IOSurface succeeds after an earlier identity drops. +- [ ] More than eight simultaneous identities depend only on real byte capacity. +- [ ] Repeated observation shares one token. +- [ ] Allocation mismatch for one identity is rejected. + +### H2.5 Retain capture owners in GPU wrapper caches + +**Files:** `crates/hypercolor-macos-gpu-interop/src/macos.rs`, +`crates/hypercolor-macos-gpu-interop/src/screen_capture.rs`, +`crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu.rs` + +**Depends on:** H2.4 + +**Parallel:** No + +#### Implementation + +- Retain the capture owner in direct wgpu, Core Video, and native Metal cache + entries. +- Add one `clear_capture_caches()` operation for all screen-specific caches. +- Clear caches on route retirement and before Metal recovery. + +#### Verify + +- [ ] Every cache keeps admission alive after the current frame drops. +- [ ] Eviction and explicit clearing release admission ownership. +- [ ] Re-import does not lose live ownership. + +## Wave 3: GPU-only execution and recovery + +### H3.1 Make native execution a typed macOS requirement + +**Files:** `crates/hypercolor-core/src/input/screen/publication.rs`, +`crates/hypercolor-core/src/input/screen/macos.rs`, daemon demand and publication +binding modules + +**Depends on:** H2.1 + +**Parallel:** No within the GPU lane + +#### Implementation + +- Let demand describe output kind, extent, processing profile, and cadence. +- Bind the executor at render commit against the current native target. +- Give production macOS a native-required constructor without a CPU option. +- Preserve intentional generic policies on other platforms. + +#### Verify + +- [ ] Missing and incompatible macOS targets resolve no CPU branch. +- [ ] Windows and generic fallback behavior remains unchanged where intentional. +- [ ] macOS telemetry reports only native states. + +### H3.2 Complete native Metal publication capabilities + +**Files:** `crates/hypercolor-macos-gpu-interop/src/screen_capture.rs`, +`crates/hypercolor-macos-gpu-interop/src/native_reduction.rs`, Metal shader +sources, daemon macOS GPU execution and tests + +**Depends on:** H3.1 + +**Parallel:** No + +#### Implementation + +- Implement every production operation still unsupported by Metal, including + edge-extend letterboxing. +- Validate target identity, generation, device, format, extent, colorimetry, and + descriptors before publication. +- Keep preview consumers on renderer-bound GPU publications. + +#### Verify + +- [ ] Native operations cover every production processing profile. +- [ ] Target and descriptor mismatches fail as typed native capability errors. +- [ ] No consumer creates a private macOS CPU branch. + +### H3.3 Rebuild failed Metal execution transactionally + +**Files:** daemon `sparkleflinger/gpu.rs`, new internal +`sparkleflinger/gpu/macos_screen.rs`, GPU interop cache APIs, renderer tests + +**Depends on:** H2.5, H3.1, H3.2 + +**Parallel:** No + +#### Implementation + +- Introduce `Ready`, `Invalidating`, `Rebuilding`, and `Unavailable` states. +- Clear old compositor output and every screen GPU cache on structural failure. +- Fence failed target generations and publish replacements transactionally. +- Couple queue behavior and recovery through a typed copy outcome. +- Retry native reconstruction without opening a CPU path. + +#### Verify + +- [ ] One injected import failure clears old output and creates a new target. +- [ ] Repeated failures never retain a permanently stale image. +- [ ] Rebuild failure becomes unavailable without CPU demand. +- [ ] Publications for failed target generations are rejected. +- [ ] A valid replacement target restores output. + +### H3.4 Share SDR and HDR tone transitions + +**Files:** `crates/hypercolor-core/src/input/screen/tone_map.rs`, +`crates/hypercolor-core/src/input/screen/hub.rs`, core macOS publication, +daemon Metal screen execution, parity tests + +**Depends on:** H3.3 + +**Parallel:** No + +#### Implementation + +- Give each managed native route one shared 250 ms tone-map transition. +- Sample the transition at the capture timestamp. +- Carry immutable sampled constants in the native work payload. +- Preserve the transition across compatible runtime replacement. + +#### Verify + +- [ ] GPU output matches the fixture CPU oracle at 0, 125, and 250 ms. +- [ ] SDR-to-HDR and HDR-to-SDR transitions match. +- [ ] Mid-transition retarget begins from the current interpolated curve. +- [ ] Readback differs by at most one 8-bit code value. + +### H3.5 Remove production macOS CPU publication + +**Files:** core macOS screen runtime, daemon publication and telemetry, fixture +modules, new `scripts/check-macos-gpu-only.sh`, `.github/workflows/ci.yml` + +**Depends on:** H3.2, H3.3, H3.4 + +**Parallel:** No + +#### Implementation + +- Delete CPU executor storage, CPU fanout, scalar production publication, and + full-frame production mapping. +- Move scalar reducers and reference frames behind fixture-only compilation. +- Remove `cpu_fallback` from production macOS telemetry. +- Add and wire a GPU-only architecture check. +- Prove the new guard fires against a deliberate forbidden fixture and remains + quiet on production code. + +#### Verify + +- [ ] Production macOS cannot request or construct a CPU capture executor. +- [ ] `scripts/check-macos-gpu-only.sh` passes normally and fails against an + injected forbidden symbol. +- [ ] macOS fixture parity tests remain available. + +## Wave 4: state ownership and generated contracts + +### H4.1 Add an authoritative interaction consumer registry + +**Files:** core input routing, new `interaction_consumers.rs`, input status, +daemon authoritative and preview routing, focused tests + +**Depends on:** H3.1 before shared daemon routing edits + +**Parallel:** No within shared input routing files + +#### Implementation + +- Key registrations by consumer and selected-source incarnation. +- Replace routes atomically. +- Derive active consumer count from registrations. +- Use generation-fenced teardown for previews and reconnects. + +#### Verify + +- [ ] One authoritative route plus two previews reports three consumers. +- [ ] Reroute, duplicate commit, stale generation, and teardown remain exact. +- [ ] Failed previews leak no registration. + +### H4.2 Add actionable capture and recovery telemetry + +**Files:** core input status, daemon status and diagnostics, shared API types and +consumer tests where public + +**Depends on:** H2.1, H2.4, H3.3, H4.1 + +**Parallel:** No + +#### Implementation + +- Report publication invalidation epoch and transaction state. +- Report live IOSurface count, admitted bytes, and cache-retained bytes. +- Report native target generation and recovery state. +- Report authoritative and preview consumer counts. +- Report protected-control rejection without exposing credentials. + +#### Verify + +- [ ] Status values move with injected lifecycle, cache, and recovery changes. +- [ ] Sensitive credential material never appears in logs or diagnostics. +- [ ] Public consumers tolerate future enum additions. + +## Wave 5: signing, rollout, and release provenance + +### H5.1 Remove signing secrets from process arguments + +**Files:** `scripts/sign-macos-artifacts.sh`, audited Security.framework helper, +signing-script tests, public CI validation only + +**Depends on:** none + +**Parallel:** Yes, with Waves 2 through 4 outside the workflow file + +#### Implementation + +- Import PKCS#12 and ephemeral-keychain credentials through stdin or private file + descriptors and Security.framework. +- Keep all release credentials and signed-release orchestration in the + proprietary build system. Public CI never receives release credentials. +- Keep an explicit file-based App Store Connect API-key interface for the + proprietary build system. +- Keep Apple-ID mode interactive through a stored notarytool keychain profile. +- Remove password-bearing argv paths. + +#### Verify + +- [x] Sentinel credentials never appear in argv, logs, receipts, xtrace, or + cleanup output while another process polls the process table. +- [ ] Existing signature, entitlement, notarization, and stapling verification + remains green. +- [x] Public workflows contain no Apple release-secret references and never + publish unsigned macOS artifacts as releases. + +### H5.2 Bind physical acceptance to immutable artifacts + +**Files:** public promotion contract, proprietary release workflow, TCC canary +runner and receipt validator, artifact verification scripts, canary tests + +**Depends on:** H1.3, H1.5, H5.1 + +**Parallel:** No + +#### Implementation + +- Build, sign, notarize, staple, checksum, and upload each macOS candidate once + in the proprietary build system. +- Drive the production app and daemon with a separate signed canary harness. +- Bind receipts to artifact and inner Mach-O digests, signing identity, commit, + version, architecture, OS, topology, and every required TCC row. +- Make proprietary macOS release and Homebrew publication consume only artifact + IDs and receipt bundles produced by the accepted build. Export public + provenance, never signing credentials. +- Forbid post-canary rebuilds. + +#### Verify + +- [ ] Missing, failed, duplicate, stale, wrong-architecture, wrong-identity, and + digest-mismatched receipts block publication. +- [ ] A one-byte artifact mutation blocks release. +- [ ] Receipt replay against another commit or version blocks release. +- [ ] The promoted artifact hashes equal the physically accepted hashes. + +## Wave 6: module decomposition + +### H6.1 Decompose capture and GPU modules behind stable facades + +**Files:** `hypercolor-macos-capture/src/native.rs`, core macOS screen runtime, +daemon macOS GPU runtime and their new internal modules + +**Depends on:** Waves 2 and 3 green + +**Parallel:** One exclusive owner per module split + +#### Implementation + +- Keep `MacosScreenCaptureSession`, `MacosScreenCaptureInput`, and + `MacosScreenBridge` as narrow facades. +- Split capture into stream, mailbox, lifecycle, transactions, picker, frame + decode, reference, capabilities, and tests. +- Split core publication into control, admission, publication, status, worker, + fixtures, and tests. +- Split daemon Metal execution into preparation, import, reduction, cache, + color, recovery, and tests. +- Keep fixture modules unavailable to production builds. + +#### Verify + +- [ ] Focused capture, core, interop, and daemon suites pass after each move. +- [ ] Public API shape remains unchanged unless an earlier task intentionally + changed it. +- [ ] Production binaries contain no fixture-only symbols. + +### H6.2 Decompose ownership, app commands, and canary modules + +**Files:** `hypercolor-macos-owner/src/lib.rs`, +`hypercolor-app/src/ownership.rs`, `hypercolor-daemon/src/macos_tcc_canary.rs` + +**Depends on:** H1.5 and H5.2 + +**Parallel:** One exclusive owner per crate + +#### Implementation + +- Split owner model, store, guard, journal, coordinator, executor, process + identity, and tests. +- Split app commands, planning, executor, launchd, Homebrew, remediation, and + tests. +- Split canary identity, artifacts, rows, receipts, validation, and harness + protocol. +- Preserve narrow stable facades. + +#### Verify + +- [ ] Owner crash and replay matrix passes after the split. +- [ ] App command and packaging tests pass. +- [ ] Canary receipt validation remains byte-for-byte equivalent. + +## Wave 7: final verification and acceptance + +### H7.1 Run repository and platform gates + +**Files:** no planned product edits + +**Depends on:** Waves 0 through 6 + +**Parallel:** Independent verification agents may run non-mutating gates in +parallel. + +#### Verify + +- [ ] `just fmt-check` passes. +- [ ] `just check` passes. +- [ ] `just lint` passes. +- [ ] `just test` passes. +- [ ] `just verify` passes. +- [ ] `just ui-test` passes. +- [ ] `just ui-build` passes. +- [ ] `just sdk-lint` passes. +- [ ] `just sdk-check` passes. +- [ ] `just sdk-build` passes. +- [ ] `just python-ws-protocol-check` passes. +- [ ] `just python-verify` passes. +- [ ] `just python-generate-check` passes. +- [ ] `just docs-build` passes. +- [ ] Linux, Windows, Apple Silicon, and Intel CI lanes pass with nonzero test + counts. + +### H7.2 Prove the same-machine account boundary + +**Files:** physical acceptance receipts only + +**Depends on:** H1.3, H1.5, H3.5 + +**Parallel:** No + +#### Verify + +- [ ] User B cannot call protected REST routes against user A. +- [ ] User B cannot subscribe to user A's screen or input streams. +- [ ] No unauthorized prompt, picker, mutation, pixel payload, or key payload + occurs. +- [ ] A pre-bound server on port 9420 cannot become the app document. +- [ ] User A's attested app session succeeds. +- [ ] Fast user switching, crash/restart, lock/unlock, and configured API keys + preserve the boundary. + +### H7.3 Prove GPU correctness and lifecycle recovery + +**Files:** physical acceptance receipts only + +**Depends on:** H2.5, H3.5, H4.2 + +**Parallel:** Can run beside H7.2 on separate hosts + +#### Verify + +- [ ] Apple Silicon and Intel pass SDR and HDR capture. +- [ ] Sleep/wake, source repick, resolution changes, and native-plane changes + recover without daemon restart. +- [ ] Rapid activate/deactivate keeps the main thread responsive. +- [ ] Injected Metal failure clears old output and restores a new target + generation. +- [ ] More than eight historical IOSurfaces rotate without false exhaustion. +- [ ] A 30-minute 4K60 run preserves cadence and zero full-frame CPU copies. +- [ ] 4K120 passes where supported hardware is available. +- [ ] A four-hour lifecycle soak leaks no transaction, surface, cache, or + consumer registration. +- [ ] Instruments shows no production CPU reducer or full-frame CPU fallback. + +### H7.4 Prove upgrade, rollback, and artifact promotion + +**Files:** physical and workflow acceptance receipts only + +**Depends on:** H5.2 + +**Parallel:** Can run beside H7.2 and H7.3 on separate hosts + +#### Verify + +- [ ] App sidecar, direct launchd, and Homebrew upgrade and rollback succeed. +- [ ] Every old/new launcher and daemon combination has defined behavior. +- [ ] Failure injection restores the prior complete installation. +- [ ] TCC revoke/regrant and topology transitions preserve ownership integrity. +- [ ] Release promotion uses exactly the accepted signed artifacts. + +## Parallel execution and collision control + +Four implementation lanes may run concurrently: + +1. API security and bundled app origin. +2. Ownership, launcher, signing, and release provenance. +3. Capture lifecycle, IOSurface ownership, and Metal recovery. +4. Generated contracts, consumer accounting, CI, and documentation. + +Shared surfaces are serialized: + +- One owner controls `.github/workflows/ci.yml`. +- App attestation lands before launcher work touches the supervisor. +- Consumer accounting lands before GPU demand changes touch daemon routing. +- The capture and renderer lane exclusively owns macOS screen publication files. +- Broad decomposition begins only after functional remediation is green. +- Broad formatting waits for integration. +- Every atomic commit runs its focused gate before handoff. + +Each non-trivial wave receives independent adversarial verification from an agent +that did not implement it. Security and GPU recovery always receive focused +passes. A failed review returns to implementation and repeats verification before +the next wave. + +## Rejected alternatives and non-goals + +- Do not add a second ownership lock or make attestation an ownership lease. +- Do not treat loopback, CORS, Host, Origin, or Fetch Metadata as user identity. +- Do not narrow the PID race and keep bare-PID signaling. +- Do not proxy capture frames through Tauri. +- Do not disable protected previews or sensitive input features. +- Do not lower frame rate, resolution, cadence, or performance baselines. +- Do not add retries as a substitute for exact invalidation and recovery. +- Do not keep launcher topology only in argv. +- Do not expose signing credentials through environment variables as a substitute + for deliberate secret transport. +- Do not physically test one binary and rebuild another for release. +- Do not pursue the disproven `codesign -d -r-` stdout finding. + +## Progress ledger + +- [x] Deep review completed at baseline `854f36f`. +- [x] GPU-only production requirement approved. +- [x] Follow-up architecture and implementation plan approved. +- [x] Wave 0 completed in commits `29598732`, `165dc483`, `423af8a`, and + `3f4a4a5` with independent verification. +- [ ] Wave 1 in progress. +- [ ] Wave 2 pending. +- [ ] Wave 3 pending. +- [ ] Wave 4 pending. +- [ ] Wave 5 pending. +- [ ] Wave 6 pending. +- [ ] Wave 7 pending. + +Active tasks: H1.1 protected-control authorization and H1.4 identity-safe owner +handover. diff --git a/justfile b/justfile index 11c0df725..821116111 100644 --- a/justfile +++ b/justfile @@ -442,22 +442,35 @@ app-build *args='': app-assets app-build *args='': app-assets powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/cargo-cache-build.ps1 cargo build -p hypercolor-app --bin hypercolor-app {{ args }} +# Build the native sidecars consumed by the Tauri bundle stage. +[unix] +app-bundle-binaries: + ./scripts/cargo-cache-build.sh cargo build --release -p hypercolor-daemon --bin hypercolor-daemon -p hypercolor-cli --bin hypercolor + +[windows] +app-bundle-binaries: + powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/cargo-cache-build.ps1 cargo build --release -p hypercolor-daemon --bin hypercolor-daemon -p hypercolor-cli --bin hypercolor -p hypercolor-windows-pawnio --bin hypercolor-smbus-service -p hypercolor-windows-helper --bin hypercolor-windows-helper + # Stage triple-suffixed sidecars (and Windows-only PawnIO/SMBus payloads) under target/bundle-stage/ [unix] -app-bundle-assets *args='': +app-bundle-assets *args='': app-bundle-binaries ./scripts/stage-app-bundle-assets.sh {{ args }} [windows] -app-bundle-assets *args='': +app-bundle-assets *args='': app-bundle-binaries powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/stage-app-bundle-assets.ps1 {{ args }} -# Build native Tauri bundles for the unified desktop app +# Build native Tauri bundles for the unified desktop app. On macOS the +# bundle signs with APPLE_SIGNING_IDENTITY, falling back to the local +# "Hypercolor Dev" certificate so TCC grants survive rebuilds (ad-hoc +# signatures change identity every build); see docs/development/DEV_SETUP.md. [unix] -app-bundle *args='': app-assets - cd crates/hypercolor-app && HYPERCOLOR_FORCE_SCCACHE=1 ../../scripts/cargo-cache-build.sh cargo tauri build --config tauri.bundle.conf.json {{ args }} +app-bundle *args='': app-assets app-bundle-assets + cd crates/hypercolor-app && APPLE_SIGNING_IDENTITY="$(../../scripts/macos-dev-signing-identity.sh)" HYPERCOLOR_FORCE_SCCACHE=1 ../../scripts/cargo-cache-build.sh cargo tauri build --config tauri.bundle.conf.json {{ args }} + ./scripts/macos-dev-postsign.sh [windows] -app-bundle *args='': app-assets +app-bundle *args='': app-assets app-bundle-assets powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "Set-Location crates/hypercolor-app; cargo tauri build --config tauri.bundle.conf.json --config tauri.windows.bundle.conf.json {{ args }}" # Build the full unsigned Windows NSIS installer package @@ -470,8 +483,7 @@ windows-installer *args='': mac-installer *args='': ./scripts/build-mac-installer.sh {{ args }} -# Regenerate the macOS icon ladder (.icns + PNG ladder) from packaging/icons/hypercolor.svg -[macos] +# Regenerate the app icon set from the brand masters (assets/brand) mac-icons: ./scripts/generate-mac-icons.sh 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..e68c20c45 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -5,15 +5,34 @@ # 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" + 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 @@ -66,11 +85,11 @@ def caveats end service do - run [opt_bin/"hypercolor-daemon", "--ui-dir", share/"hypercolor/ui"] - keep_alive true + 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" - environment_variables HYPERCOLOR_LOG: "info" + environment_variables HYPERCOLOR_LOG: "info", HYPERCOLOR_MACOS_OWNER: "homebrew" end test do diff --git a/packaging/launchd/tech.hyperbliss.hypercolor.plist b/packaging/launchd/tech.hyperbliss.hypercolor.plist index c9acb6282..ab19e61cd 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@ @@ -33,6 +35,8 @@ EnvironmentVariables + HYPERCOLOR_MACOS_OWNER + direct-launchd HYPERCOLOR_LOG info PATH 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/protocol/websocket-v1.json b/protocol/websocket-v1.json index c57d41ffc..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", @@ -209,6 +210,20 @@ "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, + "recovery_required": 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..aeb86ade3 100644 --- a/python/scripts/generate_ws_protocol.py +++ b/python/scripts/generate_ws_protocol.py @@ -52,6 +52,8 @@ 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"]) + json_payload_contracts = render_python_value(json_payloads, indent=0) binary_messages = expect_list(manifest["binary_messages"]) preview_messages = [ message for message in binary_messages if message.get("layout") == "preview_frame" @@ -75,6 +77,9 @@ def render(manifest: dict[str, Any]) -> str: ")", *tuple_assignment("WS_CAPABILITIES", manifest["capabilities"]), "", + f"JSON_PAYLOAD_CONTRACTS: Final = {json_payload_contracts[0]}", + *json_payload_contracts[1:], + "", "BINARY_MESSAGE_TAGS: Final = MappingProxyType(", " {", *[ @@ -104,6 +109,68 @@ def render(manifest: dict[str, Any]) -> str: return "\n".join(lines) +def render_python_value(value: Any, *, indent: int) -> list[str]: + if isinstance(value, dict): + return render_python_mapping(value, indent=indent) + + if isinstance(value, list): + return render_python_list(value, indent=indent) + + if value is None: + rendered = "None" + elif isinstance(value, bool): + rendered = str(value) + elif isinstance(value, (int, float)): + rendered = repr(value) + elif isinstance(value, str): + rendered = quote(value) + else: + raise TypeError("expected JSON value") + return [rendered] + + +def render_python_mapping(value: dict[Any, Any], *, indent: int) -> list[str]: + if not value: + return ["MappingProxyType({})"] + lines = ["MappingProxyType(", f"{' ' * (indent + 4)}{{"] + child_indent = indent + 8 + child_prefix = " " * child_indent + for key, child in value.items(): + if not isinstance(key, str): + raise TypeError("expected JSON object key") + rendered = render_python_value(child, indent=child_indent) + if len(rendered) == 1: + lines.append(f"{child_prefix}{quote(key)}: {rendered[0]},") + continue + lines.append(f"{child_prefix}{quote(key)}: {rendered[0]}") + lines.extend(rendered[1:-1]) + lines.append(f"{rendered[-1]},") + lines.extend((f"{' ' * (indent + 4)}}}", f"{' ' * indent})")) + return lines + + +def render_python_list(value: list[Any], *, indent: int) -> list[str]: + if not value: + return ["()"] + if len(value) == 1: + rendered = render_python_value(value[0], indent=indent) + if len(rendered) == 1: + return [f"({rendered[0]},)"] + lines = ["("] + child_indent = indent + 4 + child_prefix = " " * child_indent + for child in value: + rendered = render_python_value(child, indent=child_indent) + if len(rendered) == 1: + lines.append(f"{child_prefix}{rendered[0]},") + continue + lines.append(f"{child_prefix}{rendered[0]}") + lines.extend(rendered[1:-1]) + lines.append(f"{rendered[-1]},") + lines.append(f"{' ' * indent})") + return lines + + def tuple_assignment(name: str, values: Any) -> list[str]: strings = [str(value) for value in expect_list(values)] if len(strings) == 1: @@ -112,7 +179,7 @@ def tuple_assignment(name: str, values: Any) -> list[str]: def quote(value: str) -> str: - return json.dumps(value) + return json.dumps(value, ensure_ascii=False) def expect_dict(value: Any) -> dict[str, Any]: diff --git a/python/src/hypercolor/_generated/api/assets/__init__.py b/python/src/hypercolor/_generated/api/assets/__init__.py new file mode 100644 index 000000000..2d7c0b23d --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/python/src/hypercolor/_generated/api/assets/delete_asset.py b/python/src/hypercolor/_generated/api/assets/delete_asset.py new file mode 100644 index 000000000..d016c0ae3 --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/delete_asset.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/api/v1/assets/{id}".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Delete one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Delete one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/get_asset.py b/python/src/hypercolor/_generated/api/assets/get_asset.py new file mode 100644 index 000000000..ae5587670 --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/get_asset.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/assets/{id}".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Get one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Get one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/get_asset_blob.py b/python/src/hypercolor/_generated/api/assets/get_asset_blob.py new file mode 100644 index 000000000..8e7a352a9 --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/get_asset_blob.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/assets/{id}/blob".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Download media asset bytes + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Download media asset bytes + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/get_asset_thumbnail.py b/python/src/hypercolor/_generated/api/assets/get_asset_thumbnail.py new file mode 100644 index 000000000..48f73a012 --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/get_asset_thumbnail.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/assets/{id}/thumbnail".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Get a media asset thumbnail + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Get a media asset thumbnail + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/list_assets.py b/python/src/hypercolor/_generated/api/assets/list_assets.py new file mode 100644 index 000000000..33104aa4f --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/list_assets.py @@ -0,0 +1,103 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/assets", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """List media assets + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """List media assets + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/update_asset.py b/python/src/hypercolor/_generated/api/assets/update_asset.py new file mode 100644 index 000000000..1766fabc3 --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/update_asset.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "put", + "url": "/api/v1/assets/{id}".format( + id=quote(str(id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Update one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Update one media asset + + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/assets/upload_asset.py b/python/src/hypercolor/_generated/api/assets/upload_asset.py new file mode 100644 index 000000000..061c4937d --- /dev/null +++ b/python/src/hypercolor/_generated/api/assets/upload_asset.py @@ -0,0 +1,103 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/assets", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Upload a media asset + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Upload a media asset + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/capture/__init__.py b/python/src/hypercolor/_generated/api/capture/__init__.py new file mode 100644 index 000000000..2d7c0b23d --- /dev/null +++ b/python/src/hypercolor/_generated/api/capture/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/python/src/hypercolor/_generated/api/capture/authorize_input_monitoring.py b/python/src/hypercolor/_generated/api/capture/authorize_input_monitoring.py new file mode 100644 index 000000000..8fe44d3ba --- /dev/null +++ b/python/src/hypercolor/_generated/api/capture/authorize_input_monitoring.py @@ -0,0 +1,138 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.api_error_response import ApiErrorResponse +from ...models.api_response_capture_authorization_response import ( + ApiResponseCaptureAuthorizationResponse, +) +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/input/authorize", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + if response.status_code == 200: + response_200 = ApiResponseCaptureAuthorizationResponse.from_dict( + response.json() + ) + + return response_200 + + if response.status_code == 403: + response_403 = ApiErrorResponse.from_dict(response.json()) + + return response_403 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + """`POST /api/v1/input/authorize` — Request macOS Input Monitoring. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + """`POST /api/v1/input/authorize` — Request macOS Input Monitoring. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCaptureAuthorizationResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + """`POST /api/v1/input/authorize` — Request macOS Input Monitoring. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + """`POST /api/v1/input/authorize` — Request macOS Input Monitoring. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCaptureAuthorizationResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/python/src/hypercolor/_generated/api/capture/authorize_screen_recording.py b/python/src/hypercolor/_generated/api/capture/authorize_screen_recording.py new file mode 100644 index 000000000..3b1b81c10 --- /dev/null +++ b/python/src/hypercolor/_generated/api/capture/authorize_screen_recording.py @@ -0,0 +1,138 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.api_error_response import ApiErrorResponse +from ...models.api_response_capture_authorization_response import ( + ApiResponseCaptureAuthorizationResponse, +) +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/capture/authorize", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + if response.status_code == 200: + response_200 = ApiResponseCaptureAuthorizationResponse.from_dict( + response.json() + ) + + return response_200 + + if response.status_code == 403: + response_403 = ApiErrorResponse.from_dict(response.json()) + + return response_403 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + """`POST /api/v1/capture/authorize` — Request macOS Screen Recording. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + """`POST /api/v1/capture/authorize` — Request macOS Screen Recording. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCaptureAuthorizationResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse]: + """`POST /api/v1/capture/authorize` — Request macOS Screen Recording. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCaptureAuthorizationResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCaptureAuthorizationResponse | None: + """`POST /api/v1/capture/authorize` — Request macOS Screen Recording. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCaptureAuthorizationResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/python/src/hypercolor/_generated/api/capture/list_capture_monitors.py b/python/src/hypercolor/_generated/api/capture/list_capture_monitors.py new file mode 100644 index 000000000..e59e06581 --- /dev/null +++ b/python/src/hypercolor/_generated/api/capture/list_capture_monitors.py @@ -0,0 +1,150 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.api_error_response import ApiErrorResponse +from ...models.api_response_vec_capture_monitor import ApiResponseVecCaptureMonitor +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/capture/monitors", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiErrorResponse | ApiResponseVecCaptureMonitor | None: + if response.status_code == 200: + response_200 = ApiResponseVecCaptureMonitor.from_dict(response.json()) + + return response_200 + + if response.status_code == 403: + response_403 = ApiErrorResponse.from_dict(response.json()) + + return response_403 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiErrorResponse | ApiResponseVecCaptureMonitor]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseVecCaptureMonitor]: + """`GET /api/v1/capture/monitors` — Display outputs capture can address. + + 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. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseVecCaptureMonitor] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseVecCaptureMonitor | None: + """`GET /api/v1/capture/monitors` — Display outputs capture can address. + + 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. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseVecCaptureMonitor + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseVecCaptureMonitor]: + """`GET /api/v1/capture/monitors` — Display outputs capture can address. + + 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. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseVecCaptureMonitor] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseVecCaptureMonitor | None: + """`GET /api/v1/capture/monitors` — Display outputs capture can address. + + 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. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseVecCaptureMonitor + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/python/src/hypercolor/_generated/api/capture/pick_capture_source.py b/python/src/hypercolor/_generated/api/capture/pick_capture_source.py new file mode 100644 index 000000000..cb0babf16 --- /dev/null +++ b/python/src/hypercolor/_generated/api/capture/pick_capture_source.py @@ -0,0 +1,144 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.api_error_response import ApiErrorResponse +from ...models.api_response_capture_picker_response import ( + ApiResponseCapturePickerResponse, +) +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/capture/source/pick", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiErrorResponse | ApiResponseCapturePickerResponse | None: + if response.status_code == 200: + response_200 = ApiResponseCapturePickerResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 403: + response_403 = ApiErrorResponse.from_dict(response.json()) + + return response_403 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiErrorResponse | ApiResponseCapturePickerResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCapturePickerResponse]: + """`POST /api/v1/capture/source/pick` — Re-open the portal source picker. + + The accepted choice is persisted according to the platform source grammar. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCapturePickerResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCapturePickerResponse | None: + """`POST /api/v1/capture/source/pick` — Re-open the portal source picker. + + The accepted choice is persisted according to the platform source grammar. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCapturePickerResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[ApiErrorResponse | ApiResponseCapturePickerResponse]: + """`POST /api/v1/capture/source/pick` — Re-open the portal source picker. + + The accepted choice is persisted according to the platform source grammar. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorResponse | ApiResponseCapturePickerResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> ApiErrorResponse | ApiResponseCapturePickerResponse | None: + """`POST /api/v1/capture/source/pick` — Re-open the portal source picker. + + The accepted choice is persisted according to the platform source grammar. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorResponse | ApiResponseCapturePickerResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/python/src/hypercolor/_generated/api/diagnostics/memory_diagnostics.py b/python/src/hypercolor/_generated/api/diagnostics/memory_diagnostics.py new file mode 100644 index 000000000..e4b569683 --- /dev/null +++ b/python/src/hypercolor/_generated/api/diagnostics/memory_diagnostics.py @@ -0,0 +1,103 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/diagnose/memory", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Run memory diagnostics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Run memory diagnostics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/api/effects/get_effect_screenshot.py b/python/src/hypercolor/_generated/api/effects/get_effect_screenshot.py new file mode 100644 index 000000000..a8c54e989 --- /dev/null +++ b/python/src/hypercolor/_generated/api/effects/get_effect_screenshot.py @@ -0,0 +1,103 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/effects/screenshots", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if response.status_code == 409: + return None + + if response.status_code == 412: + return None + + if response.status_code == 422: + return None + + if response.status_code == 500: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Serve bundled effect screenshots + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Serve bundled effect screenshots + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/python/src/hypercolor/_generated/models/__init__.py b/python/src/hypercolor/_generated/models/__init__.py index cf8fd5f8b..b296d4a8f 100644 --- a/python/src/hypercolor/_generated/models/__init__.py +++ b/python/src/hypercolor/_generated/models/__init__.py @@ -26,6 +26,16 @@ from .api_response_apply_effect_response_data_applied_controls import ( ApiResponseApplyEffectResponseDataAppliedControls, ) +from .api_response_capture_authorization_response import ( + ApiResponseCaptureAuthorizationResponse, +) +from .api_response_capture_authorization_response_data import ( + ApiResponseCaptureAuthorizationResponseData, +) +from .api_response_capture_picker_response import ApiResponseCapturePickerResponse +from .api_response_capture_picker_response_data import ( + ApiResponseCapturePickerResponseData, +) from .api_response_control_action_result import ApiResponseControlActionResult from .api_response_control_action_result_data import ApiResponseControlActionResultData from .api_response_control_action_result_data_result_type_0 import ( @@ -89,6 +99,10 @@ from .api_response_server_info_data import ApiResponseServerInfoData from .api_response_system_status import ApiResponseSystemStatus from .api_response_system_status_data import ApiResponseSystemStatusData +from .api_response_vec_capture_monitor import ApiResponseVecCaptureMonitor +from .api_response_vec_capture_monitor_data_item import ( + ApiResponseVecCaptureMonitorDataItem, +) from .applied_control_change import AppliedControlChange from .applied_control_change_value import AppliedControlChangeValue from .apply_control_changes_request import ApplyControlChangesRequest @@ -131,6 +145,9 @@ from .broadcast_media_layer_target import BroadcastMediaLayerTarget from .broadcast_media_layer_target_adjust import BroadcastMediaLayerTargetAdjust from .broadcast_media_layer_target_transform import BroadcastMediaLayerTargetTransform +from .capture_authorization_response import CaptureAuthorizationResponse +from .capture_monitor import CaptureMonitor +from .capture_picker_response import CapturePickerResponse from .control_access import ControlAccess from .control_action_descriptor import ControlActionDescriptor from .control_action_descriptor_availability import ControlActionDescriptorAvailability @@ -238,15 +255,23 @@ from .effect_summary import EffectSummary from .error_body import ErrorBody from .error_code import ErrorCode +from .full_frame_copy_session_status import FullFrameCopySessionStatus from .gpu_compositor_probe_status import GpuCompositorProbeStatus from .gradient_stop import GradientStop from .health_checks import HealthChecks 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 +from .latency_histogram_bucket_status import LatencyHistogramBucketStatus +from .latency_histogram_status import LatencyHistogramStatus +from .latency_percentiles_status import LatencyPercentilesStatus from .latest_frame_status import LatestFrameStatus from .layer_order_request import LayerOrderRequest from .layer_stack_response import LayerStackResponse @@ -266,6 +291,31 @@ 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 +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_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_screen_timing_api_status import MacosScreenTimingApiStatus +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_capabilities_api_status import MacosTahoeCapabilitiesApiStatus +from .macos_tahoe_selection_capabilities_api_status import ( + MacosTahoeSelectionCapabilitiesApiStatus, +) +from .macos_timing_api_status import MacosTimingApiStatus from .meta import Meta from .normalized_position import NormalizedPosition from .normalized_rect import NormalizedRect @@ -287,6 +337,7 @@ from .preview_demand_status import PreviewDemandStatus from .preview_runtime_status import PreviewRuntimeStatus from .preview_source import PreviewSource +from .protected_source_grant_owner import ProtectedSourceGrantOwner from .rebind_candidate_summary import RebindCandidateSummary from .rebind_device_request import RebindDeviceRequest from .rejected_control_change import RejectedControlChange @@ -309,6 +360,7 @@ from .screen_capture_capacity_status import ScreenCaptureCapacityStatus from .server_identity import ServerIdentity from .server_info import ServerInfo +from .session_performance_status import SessionPerformanceStatus from .set_brightness_request import SetBrightnessRequest from .set_config_request import SetConfigRequest from .set_output_power_request import SetOutputPowerRequest @@ -377,6 +429,10 @@ "ApiResponseApplyEffectResponse", "ApiResponseApplyEffectResponseData", "ApiResponseApplyEffectResponseDataAppliedControls", + "ApiResponseCaptureAuthorizationResponse", + "ApiResponseCaptureAuthorizationResponseData", + "ApiResponseCapturePickerResponse", + "ApiResponseCapturePickerResponseData", "ApiResponseControlActionResult", "ApiResponseControlActionResultData", "ApiResponseControlActionResultDataResultType0", @@ -414,6 +470,8 @@ "ApiResponseServerInfoData", "ApiResponseSystemStatus", "ApiResponseSystemStatusData", + "ApiResponseVecCaptureMonitor", + "ApiResponseVecCaptureMonitorDataItem", "AppliedControlChange", "AppliedControlChangeValue", "ApplyControlChangesRequest", @@ -448,6 +506,9 @@ "BroadcastMediaLayerTargetTransform", "BTreeMap", "BTreeMapAdditionalProperty", + "CaptureAuthorizationResponse", + "CaptureMonitor", + "CapturePickerResponse", "ControlAccess", "ControlActionDescriptor", "ControlActionDescriptorAvailability", @@ -547,15 +608,23 @@ "EffectSummary", "ErrorBody", "ErrorCode", + "FullFrameCopySessionStatus", "GpuCompositorProbeStatus", "GradientStop", "HealthChecks", "HealthResponse", "IdentifyRequest", "InputSourceIssueStatus", + "InputSourcePlatformStatusType0", + "InputSourcePlatformStatusType0Type", + "InputSourcePlatformStatusType1", + "InputSourcePlatformStatusType1Type", "InputSourceStatus", "InputStatus", "InvokeControlActionRequest", + "LatencyHistogramBucketStatus", + "LatencyHistogramStatus", + "LatencyPercentilesStatus", "LatestFrameStatus", "LayerOrderRequest", "LayerStackResponse", @@ -575,6 +644,27 @@ "LedTopologyType5Type", "LedTopologyType6", "LedTopologyType6Type", + "MacosArchitectureApi", + "MacosAuthorizationStateApi", + "MacosCapabilityOwnerApi", + "MacosDaemonHandoverPhaseApi", + "MacosDaemonOwnerConflictApiStatus", + "MacosDaemonOwnerRecoveryRequiredApiStatus", + "MacosDaemonOwnershipApiStatus", + "MacosFrameDropApiStatus", + "MacosInputTelemetryApiStatus", + "MacosProtectedSourceStateApi", + "MacosScreenTelemetryApiStatus", + "MacosScreenTimingApiStatus", + "MacosSelectionStateApiType0", + "MacosSelectionStateApiType0Type", + "MacosSelectionStateApiType1", + "MacosSelectionStateApiType1Type", + "MacosSelectionStateApiType2", + "MacosSelectionStateApiType2Type", + "MacosTahoeCapabilitiesApiStatus", + "MacosTahoeSelectionCapabilitiesApiStatus", + "MacosTimingApiStatus", "Meta", "NormalizedPosition", "NormalizedRect", @@ -596,6 +686,7 @@ "PreviewDemandStatus", "PreviewRuntimeStatus", "PreviewSource", + "ProtectedSourceGrantOwner", "RebindCandidateSummary", "RebindDeviceRequest", "RejectedControlChange", @@ -618,6 +709,7 @@ "ScreenCaptureCapacityStatus", "ServerIdentity", "ServerInfo", + "SessionPerformanceStatus", "SetBrightnessRequest", "SetConfigRequest", "SetOutputPowerRequest", diff --git a/python/src/hypercolor/_generated/models/api_response_capture_authorization_response.py b/python/src/hypercolor/_generated/models/api_response_capture_authorization_response.py new file mode 100644 index 000000000..d24a1484c --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_capture_authorization_response.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.api_response_capture_authorization_response_data import ( + ApiResponseCaptureAuthorizationResponseData, + ) + from ..models.meta import Meta + + +T = TypeVar("T", bound="ApiResponseCaptureAuthorizationResponse") + + +@_attrs_define +class ApiResponseCaptureAuthorizationResponse: + """Standard success response wrapper. + + Attributes: + data (ApiResponseCaptureAuthorizationResponseData): + meta (Meta): Response metadata included in every envelope. + """ + + data: ApiResponseCaptureAuthorizationResponseData + meta: Meta + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = self.data.to_dict() + + meta = self.meta.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + "meta": meta, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.api_response_capture_authorization_response_data import ( + ApiResponseCaptureAuthorizationResponseData, + ) + from ..models.meta import Meta + + d = dict(src_dict) + data = ApiResponseCaptureAuthorizationResponseData.from_dict(d.pop("data")) + + meta = Meta.from_dict(d.pop("meta")) + + api_response_capture_authorization_response = cls( + data=data, + meta=meta, + ) + + api_response_capture_authorization_response.additional_properties = d + return api_response_capture_authorization_response + + @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/api_response_capture_authorization_response_data.py b/python/src/hypercolor/_generated/models/api_response_capture_authorization_response_data.py new file mode 100644 index 000000000..65db617d9 --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_capture_authorization_response_data.py @@ -0,0 +1,71 @@ +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.protected_source_grant_owner import ProtectedSourceGrantOwner + +T = TypeVar("T", bound="ApiResponseCaptureAuthorizationResponseData") + + +@_attrs_define +class ApiResponseCaptureAuthorizationResponseData: + """ + Attributes: + authorized (bool): + grant_owner (ProtectedSourceGrantOwner): + """ + + authorized: bool + grant_owner: ProtectedSourceGrantOwner + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + authorized = self.authorized + + grant_owner = self.grant_owner.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "authorized": authorized, + "grant_owner": grant_owner, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + authorized = d.pop("authorized") + + grant_owner = ProtectedSourceGrantOwner(d.pop("grant_owner")) + + api_response_capture_authorization_response_data = cls( + authorized=authorized, + grant_owner=grant_owner, + ) + + api_response_capture_authorization_response_data.additional_properties = d + return api_response_capture_authorization_response_data + + @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/api_response_capture_picker_response.py b/python/src/hypercolor/_generated/models/api_response_capture_picker_response.py new file mode 100644 index 000000000..18d4a797b --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_capture_picker_response.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.api_response_capture_picker_response_data import ( + ApiResponseCapturePickerResponseData, + ) + from ..models.meta import Meta + + +T = TypeVar("T", bound="ApiResponseCapturePickerResponse") + + +@_attrs_define +class ApiResponseCapturePickerResponse: + """Standard success response wrapper. + + Attributes: + data (ApiResponseCapturePickerResponseData): + meta (Meta): Response metadata included in every envelope. + """ + + data: ApiResponseCapturePickerResponseData + meta: Meta + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = self.data.to_dict() + + meta = self.meta.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + "meta": meta, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.api_response_capture_picker_response_data import ( + ApiResponseCapturePickerResponseData, + ) + from ..models.meta import Meta + + d = dict(src_dict) + data = ApiResponseCapturePickerResponseData.from_dict(d.pop("data")) + + meta = Meta.from_dict(d.pop("meta")) + + api_response_capture_picker_response = cls( + data=data, + meta=meta, + ) + + api_response_capture_picker_response.additional_properties = d + return api_response_capture_picker_response + + @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/api_response_capture_picker_response_data.py b/python/src/hypercolor/_generated/models/api_response_capture_picker_response_data.py new file mode 100644 index 000000000..6b3caa1a0 --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_capture_picker_response_data.py @@ -0,0 +1,71 @@ +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.protected_source_grant_owner import ProtectedSourceGrantOwner + +T = TypeVar("T", bound="ApiResponseCapturePickerResponseData") + + +@_attrs_define +class ApiResponseCapturePickerResponseData: + """ + Attributes: + grant_owner (ProtectedSourceGrantOwner): + picking (bool): + """ + + grant_owner: ProtectedSourceGrantOwner + picking: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grant_owner = self.grant_owner.value + + picking = self.picking + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grant_owner": grant_owner, + "picking": picking, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grant_owner = ProtectedSourceGrantOwner(d.pop("grant_owner")) + + picking = d.pop("picking") + + api_response_capture_picker_response_data = cls( + grant_owner=grant_owner, + picking=picking, + ) + + api_response_capture_picker_response_data.additional_properties = d + return api_response_capture_picker_response_data + + @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/api_response_system_status_data.py b/python/src/hypercolor/_generated/models/api_response_system_status_data.py index 1aecc8155..dd0cec70c 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,11 +12,13 @@ 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 from ..models.screen_capture_capacity_status import ScreenCaptureCapacityStatus from ..models.server_identity import ServerIdentity + from ..models.session_performance_status import SessionPerformanceStatus T = TypeVar("T", bound="ApiResponseSystemStatusData") @@ -57,11 +59,13 @@ class ApiResponseSystemStatusData: screen_capture_capacity (ScreenCaptureCapacityStatus): Installed byte fences for transactional screen publication admission. server (ServerIdentity): Stable identity exposed by each Hypercolor daemon instance. + session_performance (SessionPerformanceStatus): uptime_seconds (int): version (str): 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 @@ -84,15 +88,20 @@ class ApiResponseSystemStatusData: scene_count: int screen_capture_capacity: ScreenCaptureCapacityStatus server: ServerIdentity + session_performance: SessionPerformanceStatus uptime_seconds: int version: str 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 @@ -134,6 +143,8 @@ def to_dict(self) -> dict[str, Any]: server = self.server.to_dict() + session_performance = self.session_performance.to_dict() + uptime_seconds = self.uptime_seconds version = self.version @@ -158,6 +169,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( @@ -182,6 +201,7 @@ def to_dict(self) -> dict[str, Any]: "scene_count": scene_count, "screen_capture_capacity": screen_capture_capacity, "server": server, + "session_performance": session_performance, "uptime_seconds": uptime_seconds, "version": version, } @@ -192,6 +212,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,11 +222,15 @@ 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 from ..models.screen_capture_capacity_status import ScreenCaptureCapacityStatus from ..models.server_identity import ServerIdentity + from ..models.session_performance_status import SessionPerformanceStatus d = dict(src_dict) active_scene_snapshot_locked = d.pop("active_scene_snapshot_locked") @@ -251,6 +277,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: server = ServerIdentity.from_dict(d.pop("server")) + session_performance = SessionPerformanceStatus.from_dict( + d.pop("session_performance") + ) + uptime_seconds = d.pop("uptime_seconds") version = d.pop("version") @@ -290,6 +320,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, @@ -311,11 +364,13 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: scene_count=scene_count, screen_capture_capacity=screen_capture_capacity, server=server, + session_performance=session_performance, uptime_seconds=uptime_seconds, version=version, 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/api_response_vec_capture_monitor.py b/python/src/hypercolor/_generated/models/api_response_vec_capture_monitor.py new file mode 100644 index 000000000..696ac2f16 --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_vec_capture_monitor.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.api_response_vec_capture_monitor_data_item import ( + ApiResponseVecCaptureMonitorDataItem, + ) + from ..models.meta import Meta + + +T = TypeVar("T", bound="ApiResponseVecCaptureMonitor") + + +@_attrs_define +class ApiResponseVecCaptureMonitor: + """Standard success response wrapper. + + Attributes: + data (list[ApiResponseVecCaptureMonitorDataItem]): + meta (Meta): Response metadata included in every envelope. + """ + + data: list[ApiResponseVecCaptureMonitorDataItem] + meta: Meta + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + meta = self.meta.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + "meta": meta, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.api_response_vec_capture_monitor_data_item import ( + ApiResponseVecCaptureMonitorDataItem, + ) + from ..models.meta import Meta + + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in _data: + data_item = ApiResponseVecCaptureMonitorDataItem.from_dict(data_item_data) + + data.append(data_item) + + meta = Meta.from_dict(d.pop("meta")) + + api_response_vec_capture_monitor = cls( + data=data, + meta=meta, + ) + + api_response_vec_capture_monitor.additional_properties = d + return api_response_vec_capture_monitor + + @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/api_response_vec_capture_monitor_data_item.py b/python/src/hypercolor/_generated/models/api_response_vec_capture_monitor_data_item.py new file mode 100644 index 000000000..61ffaa92d --- /dev/null +++ b/python/src/hypercolor/_generated/models/api_response_vec_capture_monitor_data_item.py @@ -0,0 +1,109 @@ +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="ApiResponseVecCaptureMonitorDataItem") + + +@_attrs_define +class ApiResponseVecCaptureMonitorDataItem: + """ + Attributes: + height (int): + id (str): + index (int): + name (str): + primary (bool): + value (str): + width (int): + """ + + height: int + id: str + index: int + name: str + primary: bool + value: str + width: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + height = self.height + + id = self.id + + index = self.index + + name = self.name + + primary = self.primary + + value = self.value + + width = self.width + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "height": height, + "id": id, + "index": index, + "name": name, + "primary": primary, + "value": value, + "width": width, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + height = d.pop("height") + + id = d.pop("id") + + index = d.pop("index") + + name = d.pop("name") + + primary = d.pop("primary") + + value = d.pop("value") + + width = d.pop("width") + + api_response_vec_capture_monitor_data_item = cls( + height=height, + id=id, + index=index, + name=name, + primary=primary, + value=value, + width=width, + ) + + api_response_vec_capture_monitor_data_item.additional_properties = d + return api_response_vec_capture_monitor_data_item + + @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/capture_authorization_response.py b/python/src/hypercolor/_generated/models/capture_authorization_response.py new file mode 100644 index 000000000..9f68f6227 --- /dev/null +++ b/python/src/hypercolor/_generated/models/capture_authorization_response.py @@ -0,0 +1,71 @@ +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.protected_source_grant_owner import ProtectedSourceGrantOwner + +T = TypeVar("T", bound="CaptureAuthorizationResponse") + + +@_attrs_define +class CaptureAuthorizationResponse: + """ + Attributes: + authorized (bool): + grant_owner (ProtectedSourceGrantOwner): + """ + + authorized: bool + grant_owner: ProtectedSourceGrantOwner + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + authorized = self.authorized + + grant_owner = self.grant_owner.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "authorized": authorized, + "grant_owner": grant_owner, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + authorized = d.pop("authorized") + + grant_owner = ProtectedSourceGrantOwner(d.pop("grant_owner")) + + capture_authorization_response = cls( + authorized=authorized, + grant_owner=grant_owner, + ) + + capture_authorization_response.additional_properties = d + return capture_authorization_response + + @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/capture_monitor.py b/python/src/hypercolor/_generated/models/capture_monitor.py new file mode 100644 index 000000000..3a5e0c809 --- /dev/null +++ b/python/src/hypercolor/_generated/models/capture_monitor.py @@ -0,0 +1,109 @@ +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="CaptureMonitor") + + +@_attrs_define +class CaptureMonitor: + """ + Attributes: + height (int): + id (str): + index (int): + name (str): + primary (bool): + value (str): + width (int): + """ + + height: int + id: str + index: int + name: str + primary: bool + value: str + width: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + height = self.height + + id = self.id + + index = self.index + + name = self.name + + primary = self.primary + + value = self.value + + width = self.width + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "height": height, + "id": id, + "index": index, + "name": name, + "primary": primary, + "value": value, + "width": width, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + height = d.pop("height") + + id = d.pop("id") + + index = d.pop("index") + + name = d.pop("name") + + primary = d.pop("primary") + + value = d.pop("value") + + width = d.pop("width") + + capture_monitor = cls( + height=height, + id=id, + index=index, + name=name, + primary=primary, + value=value, + width=width, + ) + + capture_monitor.additional_properties = d + return capture_monitor + + @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/capture_picker_response.py b/python/src/hypercolor/_generated/models/capture_picker_response.py new file mode 100644 index 000000000..eb3eba742 --- /dev/null +++ b/python/src/hypercolor/_generated/models/capture_picker_response.py @@ -0,0 +1,71 @@ +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.protected_source_grant_owner import ProtectedSourceGrantOwner + +T = TypeVar("T", bound="CapturePickerResponse") + + +@_attrs_define +class CapturePickerResponse: + """ + Attributes: + grant_owner (ProtectedSourceGrantOwner): + picking (bool): + """ + + grant_owner: ProtectedSourceGrantOwner + picking: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grant_owner = self.grant_owner.value + + picking = self.picking + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grant_owner": grant_owner, + "picking": picking, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grant_owner = ProtectedSourceGrantOwner(d.pop("grant_owner")) + + picking = d.pop("picking") + + capture_picker_response = cls( + grant_owner=grant_owner, + picking=picking, + ) + + capture_picker_response.additional_properties = d + return capture_picker_response + + @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/full_frame_copy_session_status.py b/python/src/hypercolor/_generated/models/full_frame_copy_session_status.py new file mode 100644 index 000000000..13fc80fca --- /dev/null +++ b/python/src/hypercolor/_generated/models/full_frame_copy_session_status.py @@ -0,0 +1,77 @@ +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="FullFrameCopySessionStatus") + + +@_attrs_define +class FullFrameCopySessionStatus: + """ + Attributes: + bytes_ (int): + count (int): + frames (int): + """ + + bytes_: int + count: int + frames: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + bytes_ = self.bytes_ + + count = self.count + + frames = self.frames + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "bytes": bytes_, + "count": count, + "frames": frames, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + bytes_ = d.pop("bytes") + + count = d.pop("count") + + frames = d.pop("frames") + + full_frame_copy_session_status = cls( + bytes_=bytes_, + count=count, + frames=frames, + ) + + full_frame_copy_session_status.additional_properties = d + return full_frame_copy_session_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/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..cfa75ecea --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_0.py @@ -0,0 +1,169 @@ +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, + ) + from ..models.macos_input_telemetry_api_status import MacosInputTelemetryApiStatus + + +T = TypeVar("T", bound="InputSourcePlatformStatusType0") + + +@_attrs_define +class InputSourcePlatformStatusType0: + """ + Attributes: + keyboard (MacosProtectedSourceStateApi): + keyboard_owner (MacosCapabilityOwnerApi): + keyboard_tcc (MacosAuthorizationStateApi): + pointer (MacosProtectedSourceStateApi): + pointer_owner (MacosCapabilityOwnerApi): + telemetry (MacosInputTelemetryApiStatus): + type_ (InputSourcePlatformStatusType0Type): + owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): + """ + + keyboard: MacosProtectedSourceStateApi + keyboard_owner: MacosCapabilityOwnerApi + 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) + + 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 + + telemetry = self.telemetry.to_dict() + + 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, + "telemetry": telemetry, + "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, + ) + from ..models.macos_input_telemetry_api_status import ( + MacosInputTelemetryApiStatus, + ) + + 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")) + + telemetry = MacosInputTelemetryApiStatus.from_dict(d.pop("telemetry")) + + 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, + telemetry=telemetry, + 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..e4b350c22 --- /dev/null +++ b/python/src/hypercolor/_generated/models/input_source_platform_status_type_1.py @@ -0,0 +1,281 @@ +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_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 + from ..models.macos_tahoe_capabilities_api_status import ( + MacosTahoeCapabilitiesApiStatus, + ) + 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): + tahoe (MacosTahoeCapabilitiesApiStatus): + tcc (MacosAuthorizationStateApi): + telemetry (MacosScreenTelemetryApiStatus): + type_ (InputSourcePlatformStatusType1Type): + owner_conflict (MacosDaemonOwnerConflictApiStatus | None | Unset): + tahoe_selection (MacosTahoeSelectionCapabilitiesApiStatus | None | Unset): + """ + + owner: MacosCapabilityOwnerApi + selection: ( + MacosSelectionStateApiType0 + | MacosSelectionStateApiType1 + | MacosSelectionStateApiType2 + ) + state: MacosProtectedSourceStateApi + tahoe: MacosTahoeCapabilitiesApiStatus + tcc: MacosAuthorizationStateApi + telemetry: MacosScreenTelemetryApiStatus + 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 + + tahoe = self.tahoe.to_dict() + + tcc = self.tcc.value + + telemetry = self.telemetry.to_dict() + + 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, + "tahoe": tahoe, + "tcc": tcc, + "telemetry": telemetry, + "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_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, + ) + from ..models.macos_tahoe_capabilities_api_status import ( + MacosTahoeCapabilitiesApiStatus, + ) + 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")) + + tahoe = MacosTahoeCapabilitiesApiStatus.from_dict(d.pop("tahoe")) + + tcc = MacosAuthorizationStateApi(d.pop("tcc")) + + telemetry = MacosScreenTelemetryApiStatus.from_dict(d.pop("telemetry")) + + 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, + tahoe=tahoe, + tcc=tcc, + telemetry=telemetry, + 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..1a3b4dbf0 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") @@ -20,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): @@ -38,8 +45,10 @@ class InputSourceStatus: issue (InputSourceIssueStatus | None | Unset): last_sample_age_ms (int | None | Unset): lifecycle_issue (InputSourceIssueStatus | None | Unset): + platform (InputSourcePlatformStatusType0 | InputSourcePlatformStatusType1 | None | Unset): """ + active_consumer_count: int backend: str configured: bool consented: bool @@ -58,10 +67,21 @@ 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, + ) + + active_consumer_count = self.active_consumer_count backend = self.backend @@ -125,10 +145,21 @@ 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( { + "active_consumer_count": active_consumer_count, "backend": backend, "configured": configured, "consented": consented, @@ -154,14 +185,24 @@ 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) + active_consumer_count = d.pop("active_consumer_count") + backend = d.pop("backend") configured = d.pop("configured") @@ -265,7 +306,50 @@ 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( + active_consumer_count=active_consumer_count, backend=backend, configured=configured, consented=consented, @@ -284,6 +368,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/latency_histogram_bucket_status.py b/python/src/hypercolor/_generated/models/latency_histogram_bucket_status.py new file mode 100644 index 000000000..d28fb70f7 --- /dev/null +++ b/python/src/hypercolor/_generated/models/latency_histogram_bucket_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="LatencyHistogramBucketStatus") + + +@_attrs_define +class LatencyHistogramBucketStatus: + """ + Attributes: + bucket_index (int): + count (int): + """ + + bucket_index: int + count: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + bucket_index = self.bucket_index + + count = self.count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "bucket_index": bucket_index, + "count": count, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + bucket_index = d.pop("bucket_index") + + count = d.pop("count") + + latency_histogram_bucket_status = cls( + bucket_index=bucket_index, + count=count, + ) + + latency_histogram_bucket_status.additional_properties = d + return latency_histogram_bucket_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/latency_histogram_status.py b/python/src/hypercolor/_generated/models/latency_histogram_status.py new file mode 100644 index 000000000..bcc91755d --- /dev/null +++ b/python/src/hypercolor/_generated/models/latency_histogram_status.py @@ -0,0 +1,117 @@ +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 ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.latency_histogram_bucket_status import LatencyHistogramBucketStatus + + +T = TypeVar("T", bound="LatencyHistogramStatus") + + +@_attrs_define +class LatencyHistogramStatus: + """ + Attributes: + bucket_width_us (int): + buckets (list[LatencyHistogramBucketStatus]): + overflow_bucket_index (int): + snapshot_frame_token (int | None | Unset): + """ + + bucket_width_us: int + buckets: list[LatencyHistogramBucketStatus] + overflow_bucket_index: int + snapshot_frame_token: int | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + bucket_width_us = self.bucket_width_us + + buckets = [] + for buckets_item_data in self.buckets: + buckets_item = buckets_item_data.to_dict() + buckets.append(buckets_item) + + overflow_bucket_index = self.overflow_bucket_index + + snapshot_frame_token: int | None | Unset + if isinstance(self.snapshot_frame_token, Unset): + snapshot_frame_token = UNSET + else: + snapshot_frame_token = self.snapshot_frame_token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "bucket_width_us": bucket_width_us, + "buckets": buckets, + "overflow_bucket_index": overflow_bucket_index, + } + ) + if snapshot_frame_token is not UNSET: + field_dict["snapshot_frame_token"] = snapshot_frame_token + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.latency_histogram_bucket_status import ( + LatencyHistogramBucketStatus, + ) + + d = dict(src_dict) + bucket_width_us = d.pop("bucket_width_us") + + buckets = [] + _buckets = d.pop("buckets") + for buckets_item_data in _buckets: + buckets_item = LatencyHistogramBucketStatus.from_dict(buckets_item_data) + + buckets.append(buckets_item) + + overflow_bucket_index = d.pop("overflow_bucket_index") + + def _parse_snapshot_frame_token(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + snapshot_frame_token = _parse_snapshot_frame_token( + d.pop("snapshot_frame_token", UNSET) + ) + + latency_histogram_status = cls( + bucket_width_us=bucket_width_us, + buckets=buckets, + overflow_bucket_index=overflow_bucket_index, + snapshot_frame_token=snapshot_frame_token, + ) + + latency_histogram_status.additional_properties = d + return latency_histogram_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/latency_percentiles_status.py b/python/src/hypercolor/_generated/models/latency_percentiles_status.py new file mode 100644 index 000000000..c33591028 --- /dev/null +++ b/python/src/hypercolor/_generated/models/latency_percentiles_status.py @@ -0,0 +1,137 @@ +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 ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.latency_histogram_status import LatencyHistogramStatus + + +T = TypeVar("T", bound="LatencyPercentilesStatus") + + +@_attrs_define +class LatencyPercentilesStatus: + """ + Attributes: + avg_ms (float): + max_ms (float): + p95_ms (float): + p99_ms (float): + sample_count (int): + cumulative_histogram (LatencyHistogramStatus | None | Unset): + """ + + avg_ms: float + max_ms: float + p95_ms: float + p99_ms: float + sample_count: int + cumulative_histogram: LatencyHistogramStatus | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.latency_histogram_status import LatencyHistogramStatus + + avg_ms = self.avg_ms + + max_ms = self.max_ms + + p95_ms = self.p95_ms + + p99_ms = self.p99_ms + + sample_count = self.sample_count + + cumulative_histogram: dict[str, Any] | None | Unset + if isinstance(self.cumulative_histogram, Unset): + cumulative_histogram = UNSET + elif isinstance(self.cumulative_histogram, LatencyHistogramStatus): + cumulative_histogram = self.cumulative_histogram.to_dict() + else: + cumulative_histogram = self.cumulative_histogram + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "avg_ms": avg_ms, + "max_ms": max_ms, + "p95_ms": p95_ms, + "p99_ms": p99_ms, + "sample_count": sample_count, + } + ) + if cumulative_histogram is not UNSET: + field_dict["cumulative_histogram"] = cumulative_histogram + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.latency_histogram_status import LatencyHistogramStatus + + d = dict(src_dict) + avg_ms = d.pop("avg_ms") + + max_ms = d.pop("max_ms") + + p95_ms = d.pop("p95_ms") + + p99_ms = d.pop("p99_ms") + + sample_count = d.pop("sample_count") + + def _parse_cumulative_histogram( + data: object, + ) -> LatencyHistogramStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + cumulative_histogram_type_1 = LatencyHistogramStatus.from_dict(data) + + return cumulative_histogram_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(LatencyHistogramStatus | None | Unset, data) + + cumulative_histogram = _parse_cumulative_histogram( + d.pop("cumulative_histogram", UNSET) + ) + + latency_percentiles_status = cls( + avg_ms=avg_ms, + max_ms=max_ms, + p95_ms=p95_ms, + p99_ms=p99_ms, + sample_count=sample_count, + cumulative_histogram=cumulative_histogram, + ) + + latency_percentiles_status.additional_properties = d + return latency_percentiles_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_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_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_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..2a4795cd5 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_input_telemetry_api_status.py @@ -0,0 +1,456 @@ +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_timing_api_status import MacosTimingApiStatus + + +T = TypeVar("T", bound="MacosInputTelemetryApiStatus") + + +@_attrs_define +class MacosInputTelemetryApiStatus: + """ + Attributes: + executable_architecture (MacosArchitectureApi): + authorization_last_transition_age_ms (int | None | Unset): + callback_to_publication_timing (MacosTimingApiStatus | 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 + callback_to_publication_timing: MacosTimingApiStatus | 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]: + from ..models.macos_timing_api_status import MacosTimingApiStatus + + 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 + ) + + callback_to_publication_timing: dict[str, Any] | None | Unset + if isinstance(self.callback_to_publication_timing, Unset): + callback_to_publication_timing = UNSET + elif isinstance(self.callback_to_publication_timing, MacosTimingApiStatus): + callback_to_publication_timing = ( + self.callback_to_publication_timing.to_dict() + ) + else: + callback_to_publication_timing = self.callback_to_publication_timing + + 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 callback_to_publication_timing is not UNSET: + field_dict["callback_to_publication_timing"] = ( + callback_to_publication_timing + ) + 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: + from ..models.macos_timing_api_status import MacosTimingApiStatus + + 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_callback_to_publication_timing( + data: object, + ) -> MacosTimingApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + callback_to_publication_timing_type_1 = MacosTimingApiStatus.from_dict( + data + ) + + return callback_to_publication_timing_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosTimingApiStatus | None | Unset, data) + + callback_to_publication_timing = _parse_callback_to_publication_timing( + d.pop("callback_to_publication_timing", 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, + callback_to_publication_timing=callback_to_publication_timing, + 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_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_screen_telemetry_api_status.py b/python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py new file mode 100644 index 000000000..99adc8bf5 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_screen_telemetry_api_status.py @@ -0,0 +1,666 @@ +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 + from ..models.macos_screen_timing_api_status import MacosScreenTimingApiStatus + + +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): + timing (MacosScreenTimingApiStatus | None | 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 + timing: MacosScreenTimingApiStatus | None | 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]: + from ..models.macos_screen_timing_api_status import MacosScreenTimingApiStatus + + 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 + + timing: dict[str, Any] | None | Unset + if isinstance(self.timing, Unset): + timing = UNSET + elif isinstance(self.timing, MacosScreenTimingApiStatus): + timing = self.timing.to_dict() + else: + timing = self.timing + + 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 timing is not UNSET: + field_dict["timing"] = timing + 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 + from ..models.macos_screen_timing_api_status import MacosScreenTimingApiStatus + + 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_timing(data: object) -> MacosScreenTimingApiStatus | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + timing_type_1 = MacosScreenTimingApiStatus.from_dict(data) + + return timing_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MacosScreenTimingApiStatus | None | Unset, data) + + timing = _parse_timing(d.pop("timing", 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, + timing=timing, + 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 diff --git a/python/src/hypercolor/_generated/models/macos_screen_timing_api_status.py b/python/src/hypercolor/_generated/models/macos_screen_timing_api_status.py new file mode 100644 index 000000000..ca121f22c --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_screen_timing_api_status.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.macos_timing_api_status import MacosTimingApiStatus + + +T = TypeVar("T", bound="MacosScreenTimingApiStatus") + + +@_attrs_define +class MacosScreenTimingApiStatus: + """ + Attributes: + callback (MacosTimingApiStatus): + capture_to_converted_publication (MacosTimingApiStatus): + capture_to_native_publication (MacosTimingApiStatus): + conversion (MacosTimingApiStatus): + cpu_reduction (MacosTimingApiStatus): + enqueue (MacosTimingApiStatus): + native_import (MacosTimingApiStatus): + native_reduction_submit (MacosTimingApiStatus): + publication (MacosTimingApiStatus): + retain (MacosTimingApiStatus): + """ + + callback: MacosTimingApiStatus + capture_to_converted_publication: MacosTimingApiStatus + capture_to_native_publication: MacosTimingApiStatus + conversion: MacosTimingApiStatus + cpu_reduction: MacosTimingApiStatus + enqueue: MacosTimingApiStatus + native_import: MacosTimingApiStatus + native_reduction_submit: MacosTimingApiStatus + publication: MacosTimingApiStatus + retain: MacosTimingApiStatus + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + callback = self.callback.to_dict() + + capture_to_converted_publication = ( + self.capture_to_converted_publication.to_dict() + ) + + capture_to_native_publication = self.capture_to_native_publication.to_dict() + + conversion = self.conversion.to_dict() + + cpu_reduction = self.cpu_reduction.to_dict() + + enqueue = self.enqueue.to_dict() + + native_import = self.native_import.to_dict() + + native_reduction_submit = self.native_reduction_submit.to_dict() + + publication = self.publication.to_dict() + + retain = self.retain.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "callback": callback, + "capture_to_converted_publication": capture_to_converted_publication, + "capture_to_native_publication": capture_to_native_publication, + "conversion": conversion, + "cpu_reduction": cpu_reduction, + "enqueue": enqueue, + "native_import": native_import, + "native_reduction_submit": native_reduction_submit, + "publication": publication, + "retain": retain, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.macos_timing_api_status import MacosTimingApiStatus + + d = dict(src_dict) + callback = MacosTimingApiStatus.from_dict(d.pop("callback")) + + capture_to_converted_publication = MacosTimingApiStatus.from_dict( + d.pop("capture_to_converted_publication") + ) + + capture_to_native_publication = MacosTimingApiStatus.from_dict( + d.pop("capture_to_native_publication") + ) + + conversion = MacosTimingApiStatus.from_dict(d.pop("conversion")) + + cpu_reduction = MacosTimingApiStatus.from_dict(d.pop("cpu_reduction")) + + enqueue = MacosTimingApiStatus.from_dict(d.pop("enqueue")) + + native_import = MacosTimingApiStatus.from_dict(d.pop("native_import")) + + native_reduction_submit = MacosTimingApiStatus.from_dict( + d.pop("native_reduction_submit") + ) + + publication = MacosTimingApiStatus.from_dict(d.pop("publication")) + + retain = MacosTimingApiStatus.from_dict(d.pop("retain")) + + macos_screen_timing_api_status = cls( + callback=callback, + capture_to_converted_publication=capture_to_converted_publication, + capture_to_native_publication=capture_to_native_publication, + conversion=conversion, + cpu_reduction=cpu_reduction, + enqueue=enqueue, + native_import=native_import, + native_reduction_submit=native_reduction_submit, + publication=publication, + retain=retain, + ) + + macos_screen_timing_api_status.additional_properties = d + return macos_screen_timing_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_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_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 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/macos_timing_api_status.py b/python/src/hypercolor/_generated/models/macos_timing_api_status.py new file mode 100644 index 000000000..d220cf289 --- /dev/null +++ b/python/src/hypercolor/_generated/models/macos_timing_api_status.py @@ -0,0 +1,93 @@ +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="MacosTimingApiStatus") + + +@_attrs_define +class MacosTimingApiStatus: + """ + Attributes: + max_ns (int): + p95_ns (int): + p99_ns (int): + sample_count (int): + total_ns (int): + """ + + max_ns: int + p95_ns: int + p99_ns: int + sample_count: int + total_ns: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + max_ns = self.max_ns + + p95_ns = self.p95_ns + + p99_ns = self.p99_ns + + sample_count = self.sample_count + + total_ns = self.total_ns + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "max_ns": max_ns, + "p95_ns": p95_ns, + "p99_ns": p99_ns, + "sample_count": sample_count, + "total_ns": total_ns, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + max_ns = d.pop("max_ns") + + p95_ns = d.pop("p95_ns") + + p99_ns = d.pop("p99_ns") + + sample_count = d.pop("sample_count") + + total_ns = d.pop("total_ns") + + macos_timing_api_status = cls( + max_ns=max_ns, + p95_ns=p95_ns, + p99_ns=p99_ns, + sample_count=sample_count, + total_ns=total_ns, + ) + + macos_timing_api_status.additional_properties = d + return macos_timing_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/protected_source_grant_owner.py b/python/src/hypercolor/_generated/models/protected_source_grant_owner.py new file mode 100644 index 000000000..b25ad7c42 --- /dev/null +++ b/python/src/hypercolor/_generated/models/protected_source_grant_owner.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class ProtectedSourceGrantOwner(str, Enum): + APP = "app" + APP_SIDECAR = "app_sidecar" + BROKER = "broker" + HOMEBREW_SERVICE = "homebrew_service" + LAUNCHD_SERVICE = "launchd_service" + PLATFORM_BACKEND = "platform_backend" + STANDALONE = "standalone" + + def __str__(self) -> str: + return str(self.value) diff --git a/python/src/hypercolor/_generated/models/session_performance_status.py b/python/src/hypercolor/_generated/models/session_performance_status.py new file mode 100644 index 000000000..519f7be20 --- /dev/null +++ b/python/src/hypercolor/_generated/models/session_performance_status.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.full_frame_copy_session_status import FullFrameCopySessionStatus + from ..models.latency_percentiles_status import LatencyPercentilesStatus + + +T = TypeVar("T", bound="SessionPerformanceStatus") + + +@_attrs_define +class SessionPerformanceStatus: + """ + Attributes: + full_frame_cpu_copies (FullFrameCopySessionStatus): + input_stage (LatencyPercentilesStatus): + """ + + full_frame_cpu_copies: FullFrameCopySessionStatus + input_stage: LatencyPercentilesStatus + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + full_frame_cpu_copies = self.full_frame_cpu_copies.to_dict() + + input_stage = self.input_stage.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "full_frame_cpu_copies": full_frame_cpu_copies, + "input_stage": input_stage, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.full_frame_copy_session_status import FullFrameCopySessionStatus + from ..models.latency_percentiles_status import LatencyPercentilesStatus + + d = dict(src_dict) + full_frame_cpu_copies = FullFrameCopySessionStatus.from_dict( + d.pop("full_frame_cpu_copies") + ) + + input_stage = LatencyPercentilesStatus.from_dict(d.pop("input_stage")) + + session_performance_status = cls( + full_frame_cpu_copies=full_frame_cpu_copies, + input_stage=input_stage, + ) + + session_performance_status.additional_properties = d + return session_performance_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..7004b117e 100644 --- a/python/src/hypercolor/_generated/models/system_status.py +++ b/python/src/hypercolor/_generated/models/system_status.py @@ -12,11 +12,13 @@ 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 from ..models.screen_capture_capacity_status import ScreenCaptureCapacityStatus from ..models.server_identity import ServerIdentity + from ..models.session_performance_status import SessionPerformanceStatus T = TypeVar("T", bound="SystemStatus") @@ -57,11 +59,13 @@ class SystemStatus: screen_capture_capacity (ScreenCaptureCapacityStatus): Installed byte fences for transactional screen publication admission. server (ServerIdentity): Stable identity exposed by each Hypercolor daemon instance. + session_performance (SessionPerformanceStatus): uptime_seconds (int): version (str): 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 @@ -84,15 +88,20 @@ class SystemStatus: scene_count: int screen_capture_capacity: ScreenCaptureCapacityStatus server: ServerIdentity + session_performance: SessionPerformanceStatus uptime_seconds: int version: str 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 @@ -134,6 +143,8 @@ def to_dict(self) -> dict[str, Any]: server = self.server.to_dict() + session_performance = self.session_performance.to_dict() + uptime_seconds = self.uptime_seconds version = self.version @@ -158,6 +169,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( @@ -182,6 +201,7 @@ def to_dict(self) -> dict[str, Any]: "scene_count": scene_count, "screen_capture_capacity": screen_capture_capacity, "server": server, + "session_performance": session_performance, "uptime_seconds": uptime_seconds, "version": version, } @@ -192,6 +212,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,11 +222,15 @@ 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 from ..models.screen_capture_capacity_status import ScreenCaptureCapacityStatus from ..models.server_identity import ServerIdentity + from ..models.session_performance_status import SessionPerformanceStatus d = dict(src_dict) active_scene_snapshot_locked = d.pop("active_scene_snapshot_locked") @@ -251,6 +277,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: server = ServerIdentity.from_dict(d.pop("server")) + session_performance = SessionPerformanceStatus.from_dict( + d.pop("session_performance") + ) + uptime_seconds = d.pop("uptime_seconds") version = d.pop("version") @@ -290,6 +320,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, @@ -311,11 +364,13 @@ def _parse_latest_frame(data: object) -> LatestFrameStatus | None | Unset: scene_count=scene_count, screen_capture_capacity=screen_capture_capacity, server=server, + session_performance=session_performance, uptime_seconds=uptime_seconds, version=version, active_effect=active_effect, active_scene=active_scene, latest_frame=latest_frame, + macos_daemon_ownership=macos_daemon_ownership, ) system_status.additional_properties = d diff --git a/python/src/hypercolor/ws_protocol.py b/python/src/hypercolor/ws_protocol.py index dc9b6b597..8abdd732d 100644 --- a/python/src/hypercolor/ws_protocol.py +++ b/python/src/hypercolor/ws_protocol.py @@ -49,6 +49,76 @@ "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": MappingProxyType( + { + "schema_version": 1, + "channel": "input_events", + "event": "input_event_received", + "required_fields": ("event",), + "optional_fields": MappingProxyType( + { + "at_ms": 0, + "seq": 0, + "physical_code": None, + "repeat_count": 1, + } + ), + "description": "Canonical captured input edge. Missing timing and metadata fields decode with their listed defaults for compatibility with the prior event-only payload.", + } + ), + "input_source_status_changed_v1": MappingProxyType( + { + "schema_version": 1, + "channel": "events", + "event": "input_source_status_changed", + "required_fields": ( + "source_id", + "kind", + "backend", + "configured", + "consented", + "demanded", + "active_consumer_count", + "state", + "freshness", + "source_graph_generation", + "session_generation", + "resource_count", + "denied_resource_count", + "retired", + ), + "optional_fields": MappingProxyType( + { + "lifecycle_issue_code": None, + "freshness_issue_code": None, + } + ), + "description": "Coalesced input-source lifecycle and freshness transition. Contains operational metadata only and never captured input contents.", + } + ), + "macos_daemon_ownership_changed_v1": MappingProxyType( + { + "schema_version": 1, + "channel": "events", + "event": "macos_daemon_ownership_changed", + "required_fields": ( + "active_owner", + "owner_epoch", + ), + "optional_fields": MappingProxyType( + { + "conflict": None, + "recovery_required": None, + } + ), + "description": "Authoritative macOS daemon topology snapshot. The event reports ownership state only and cannot request an owner change.", + } + ), + } +) + BINARY_MESSAGE_TAGS: Final = MappingProxyType( { "led_frame": 0x01, diff --git a/python/tests/test_websocket.py b/python/tests/test_websocket.py index 7f017e07d..e11640cd2 100644 --- a/python/tests/test_websocket.py +++ b/python/tests/test_websocket.py @@ -2,10 +2,14 @@ from __future__ import annotations +import ast import asyncio +import runpy import struct import uuid +from collections.abc import Callable, Mapping from pathlib import Path +from types import MappingProxyType from typing import Any, cast import msgspec @@ -27,6 +31,7 @@ ) PROTOCOL_MANIFEST = Path(__file__).resolve().parents[2] / "protocol" / "websocket-v1.json" +WS_GENERATOR = Path(__file__).resolve().parents[1] / "scripts" / "generate_ws_protocol.py" class _TestClient: @@ -54,6 +59,9 @@ 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 _thaw_json(ws_protocol.JSON_PAYLOAD_CONTRACTS) == _expect_dict( + manifest["json_payloads"] + ) assert dict(ws_protocol.BINARY_MESSAGE_TAGS) == { str(message["name"]): int(message["tag"]) for message in binary_messages } @@ -67,6 +75,36 @@ def test_ws_protocol_constants_match_manifest() -> None: } +def test_ws_protocol_field_defaults_distinguish_absent_from_null() -> None: + timed_input = _expect_mapping(ws_protocol.JSON_PAYLOAD_CONTRACTS["timed_input_event_v1"]) + required_fields = _expect_tuple(timed_input["required_fields"]) + optional_fields = _expect_mapping(timed_input["optional_fields"]) + + assert "event" in required_fields + assert "event" not in optional_fields + assert "physical_code" in optional_fields + assert optional_fields["physical_code"] is None + + +def test_ws_protocol_contracts_are_deeply_immutable() -> None: + timed_input = ws_protocol.JSON_PAYLOAD_CONTRACTS["timed_input_event_v1"] + required_fields = timed_input["required_fields"] + optional_fields = timed_input["optional_fields"] + + assert isinstance(timed_input, MappingProxyType) + assert isinstance(required_fields, tuple) + assert isinstance(optional_fields, MappingProxyType) + with pytest.raises(TypeError): + cast(dict[str, Any], optional_fields)["at_ms"] = 99 + + +def test_ws_protocol_generator_round_trips_non_bmp_strings() -> None: + value = "😀" + quote = cast(Callable[[str], str], runpy.run_path(str(WS_GENERATOR))["quote"]) + + assert ast.literal_eval(quote(value)) == value + + def test_decode_hello_message() -> None: message = HypercolorEventStream._decode_json( '{"type":"hello","version":"1.0","state":{"running":true},"capabilities":["events"],"subscriptions":["events"]}' @@ -220,6 +258,24 @@ def _expect_list(value: Any) -> list[Any]: return value +def _expect_mapping(value: Any) -> Mapping[str, Any]: + assert isinstance(value, Mapping) + return value + + +def _expect_tuple(value: Any) -> tuple[Any, ...]: + assert isinstance(value, tuple) + return value + + +def _thaw_json(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _thaw_json(child) for key, child in value.items()} + if isinstance(value, tuple): + return [_thaw_json(child) for child in value] + return value + + def test_parse_zone_preview() -> None: scene_id = uuid.uuid4() zone_id = uuid.uuid4() diff --git a/scripts/build-mac-installer.sh b/scripts/build-mac-installer.sh index 62bb45902..15e0e9698 100755 --- a/scripts/build-mac-installer.sh +++ b/scripts/build-mac-installer.sh @@ -1,22 +1,22 @@ #!/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: # # APPLE_SIGNING_IDENTITY="Developer ID Application: Stefanie Jane (TEAMID)" \ -# APPLE_ID="stef@hyperbliss.tech" \ # APPLE_TEAM_ID="TEAMID" \ -# APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \ +# APPLE_API_KEY_ID="KEYID" \ +# APPLE_API_ISSUER="issuer-uuid" \ +# APPLE_API_KEY_PATH="${HOME}/private_keys/AuthKey_KEYID.p8" \ # 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,14 +31,15 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${ROOT_DIR}/target}" PROFILE="release" TARGET="" -BUNDLES="dmg,app" SKIP_UI=0 SKIP_EFFECTS=0 NOTARIZE=0 CHECK_ONLY=0 +TCC_CANARY=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,17 +48,17 @@ 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 + --tcc-canary Include the signed physical TCC canary surface --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, -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). +Release signing is driven by APPLE_SIGNING_IDENTITY. Notarization additionally +needs APPLE_API_KEY_ID + APPLE_API_ISSUER + APPLE_API_KEY_PATH, or a +preconfigured APPLE_NOTARY_KEYCHAIN_PROFILE. Raw Apple ID passwords are not +accepted. EOF } @@ -75,10 +76,10 @@ 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 ;; + --tcc-canary) TCC_CANARY=1; shift ;; --check-only) CHECK_ONLY=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown option: $1" ;; @@ -91,6 +92,9 @@ case "${PROFILE}" in esac [[ "$(uname -s)" == "Darwin" ]] || die "this script only runs on macOS" +if [[ "${TCC_CANARY}" -eq 1 && "${NOTARIZE}" -ne 1 ]]; then + die "--tcc-canary requires --notarize" +fi assert_prerequisites() { require cargo "install Rust from https://rustup.rs/" @@ -106,18 +110,24 @@ 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 + if [[ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" || -n "${APPLE_ID:-}" ]]; then + die "raw Apple ID credentials are unsupported; use a notarytool keychain profile" + elif [[ -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}" - elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then - info "notarization will use Apple ID ${APPLE_ID}" + elif [[ -n "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]]; then + info "notarization will use keychain profile ${APPLE_NOTARY_KEYCHAIN_PROFILE}" else - die "--notarize needs APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD, or the API key trio (APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_API_KEY_PATH)" + die "--notarize needs the App Store Connect API key trio or APPLE_NOTARY_KEYCHAIN_PROFILE" fi fi } @@ -150,15 +160,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 +188,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 @@ -250,21 +218,34 @@ if [[ "${SKIP_EFFECTS}" -ne 1 ]]; then run_step "Build bundled effects" bash -c "cd '${ROOT_DIR}/sdk' && bun run build:effects" fi -build_cargo "Build daemon sidecar (with servo)" -p hypercolor-daemon --features servo +daemon_features="servo" +if [[ "${TCC_CANARY}" -eq 1 ]]; then + daemon_features="${daemon_features},macos-tcc-canary" +fi +build_cargo "Build daemon sidecar (with servo)" \ + -p hypercolor-daemon --features "${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..d1ce32038 100755 --- a/scripts/dist.sh +++ b/scripts/dist.sh @@ -26,9 +26,11 @@ BIN_DIR="" RUST_TARGET="" RELEASE_VERSION="" BUILD_ROOT="" +TCC_CANARY=0 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' "$*"; } @@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do --ci) CI_MODE=1; shift ;; --web-assets) WEB_ASSETS_DIR="$2"; shift 2 ;; --bin-dir) BIN_DIR="$2"; shift 2 ;; + --tcc-canary) TCC_CANARY=1; shift ;; --target) RUST_TARGET="$(normalize_target "$2")"; shift 2 ;; --version) RELEASE_VERSION="$2"; shift 2 ;; -h|--help) @@ -90,6 +93,7 @@ Options: --bin-dir Package pre-built binaries from instead of building them (absolute path; must contain the four release binaries) + --tcc-canary Include the signed physical TCC canary surface -h, --help Show this help EOF exit 0 @@ -141,6 +145,13 @@ case "${RUST_TARGET}" in *apple*|*darwin*) IS_MACOS=1 ;; esac +if [[ "${TCC_CANARY}" -eq 1 && "${IS_MACOS}" -ne 1 ]]; then + die "--tcc-canary requires a macOS target" +fi +if [[ "${TCC_CANARY}" -eq 1 && -n "${BIN_DIR}" ]]; then + die "--tcc-canary cannot verify pre-built binaries from --bin-dir" +fi + TARGET_FLAG=() if [[ "${RUST_TARGET}" != "${HOST_TARGET}" ]]; then TARGET_FLAG=(--target "${RUST_TARGET}") @@ -179,8 +190,13 @@ else # The daemon's merge alone still overruns a 15Gi arm64 runner into swap, # so this trims the peak rather than eliminating it; CI provisions swap # for the remainder. + DAEMON_FEATURE_FLAG=() + if [[ "${TCC_CANARY}" -eq 1 ]]; then + DAEMON_FEATURE_FLAG=(--features macos-tcc-canary) + fi ./scripts/cargo-cache-build.sh cargo build --release --locked \ -p hypercolor-daemon --bin hypercolor-daemon \ + ${DAEMON_FEATURE_FLAG[@]+"${DAEMON_FEATURE_FLAG[@]}"} \ ${TARGET_FLAG[@]+"${TARGET_FLAG[@]}"} ./scripts/cargo-cache-build.sh cargo build --release --locked \ -p hypercolor-cli --bin hypercolor \ @@ -342,6 +358,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/generate-mac-icons.sh b/scripts/generate-mac-icons.sh index ffb15b725..05d1dfb97 100755 --- a/scripts/generate-mac-icons.sh +++ b/scripts/generate-mac-icons.sh @@ -1,89 +1,9 @@ #!/usr/bin/env bash -# Generate the macOS icon ladder (PNG sizes + .icns) for hypercolor-app from -# packaging/icons/hypercolor.svg. Run on macOS; depends on qlmanage (Quick Look -# preview tool, ships with macOS), sips (built-in image utility), and iconutil -# (ships with the Xcode CLT). -# -# qlmanage renders SVGs through WebKit so gradient stops, opacity, and complex -# stroke fills come out the way the source intends. ImageMagick's bundled -# rasterizer drops gradient references to flat black on Macs that lack the -# librsvg delegate. -# -# Output lands in crates/hypercolor-app/icons/. Re-run after editing the SVG. -# Artifacts are committed so contributors without Quick Look tooling can build. +# Generate the Tauri app icon ladder from the canonical checked-in brand mark. set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" -SRC_SVG="packaging/icons/hypercolor.svg" -DEST_DIR="crates/hypercolor-app/icons" -WORK_DIR="$(mktemp -d -t hypercolor-icons.XXXXXX)" -ICONSET_DIR="${WORK_DIR}/icon.iconset" - -trap 'rm -rf "${WORK_DIR}"' EXIT - -info() { printf '\033[38;2;128;255;234m→\033[0m %s\n' "$*"; } -ok() { printf '\033[38;2;80;250;123m✅\033[0m %s\n' "$*"; } -die() { printf '\033[38;2;255;99;99m✗\033[0m %s\n' "$*" >&2; exit 1; } - -require() { - command -v "$1" >/dev/null 2>&1 || die "missing '$1' on PATH${2:+; $2}" -} - -[[ -f "${SRC_SVG}" ]] || die "source SVG not found at ${SRC_SVG}" -require qlmanage "ships with macOS; run on a Mac, not in Linux CI" -require sips "ships with macOS" -require iconutil "ships with the Xcode Command Line Tools" - -mkdir -p "${ICONSET_DIR}" "${DEST_DIR}" - -# The marketing SVG embeds a "HYPERCOLOR" wordmark that we deliberately drop -# from the rasterized icon: it is illegible below 128px and macOS HIG advises -# against text inside dock icons. The Finder/Dock label already names the app. -RENDER_SVG="${WORK_DIR}/hypercolor-no-text.svg" -sed -E '/]*>.*<\/text>/d' "${SRC_SVG}" > "${RENDER_SVG}" - -# Render once at the largest size, then downscale. qlmanage emits a single -# thumbnail per invocation, so doing it once amortizes WebKit startup cost. -info "rasterizing ${SRC_SVG} at 1024px via Quick Look" -qlmanage -t -s 1024 -o "${WORK_DIR}" "${RENDER_SVG}" >/dev/null 2>&1 -MASTER_PNG="${WORK_DIR}/$(basename "${RENDER_SVG}").png" -[[ -f "${MASTER_PNG}" ]] || die "qlmanage did not produce ${MASTER_PNG}" - -# Apple's iconset naming convention: icon_x[@2x].png. The 2x -# variant carries double the pixel count for retina displays. -declare -a APPLE_SIZES=( - "16x16:16" - "16x16@2x:32" - "32x32:32" - "32x32@2x:64" - "128x128:128" - "128x128@2x:256" - "256x256:256" - "256x256@2x:512" - "512x512:512" - "512x512@2x:1024" -) - -info "downscaling to iconset members" -for entry in "${APPLE_SIZES[@]}"; do - name="${entry%%:*}" - px="${entry##*:}" - out="${ICONSET_DIR}/icon_${name}.png" - sips -z "${px}" "${px}" "${MASTER_PNG}" --out "${out}" >/dev/null -done - -# Tauri's bundle config consumes individual PNGs plus the .icns directly. Mirror -# the conventional Tauri naming so the manifest stays declarative. -info "publishing icon files to ${DEST_DIR}" -cp "${ICONSET_DIR}/icon_32x32.png" "${DEST_DIR}/32x32.png" -cp "${ICONSET_DIR}/icon_128x128.png" "${DEST_DIR}/128x128.png" -cp "${ICONSET_DIR}/icon_128x128@2x.png" "${DEST_DIR}/128x128@2x.png" -cp "${MASTER_PNG}" "${DEST_DIR}/icon.png" - -info "assembling icon.icns" -iconutil --convert icns --output "${DEST_DIR}/icon.icns" "${ICONSET_DIR}" - -ok "wrote macOS icon ladder + icon.icns into ${DEST_DIR}" +exec uv run assets/brand/build.py app-icon diff --git a/scripts/get-hypercolor.sh b/scripts/get-hypercolor.sh index c00734bb3..4af443f14 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 @@ -114,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/graphics-pipeline-soak.test.ts b/scripts/graphics-pipeline-soak.test.ts new file mode 100644 index 000000000..97e05880e --- /dev/null +++ b/scripts/graphics-pipeline-soak.test.ts @@ -0,0 +1,601 @@ +import { describe, expect, test } from "bun:test" + +import { analyze, defaults, type JsonObject, type MetricSample } from "./graphics-pipeline-soak" + +function metrics(inputP95Ms: number, inputSampleCount: number, sessionFullFrameCount: number): JsonObject { + return { + fps: { actual: 60, target: 60 }, + frame_time: { p95_ms: 10 }, + input_latency: { p95_ms: inputP95Ms, sample_count: inputSampleCount }, + copies: { + full_frame_count: 0, + session_full_frame_count: sessionFullFrameCount, + }, + display_output: { + display_lane: { display_led_priority_wait_max_ms: 0 }, + }, + timeline: { frame_token: inputSampleCount }, + } +} + +function samples(first: JsonObject, last: JsonObject): MetricSample[] { + return [ + { receivedAtMs: 4_000, data: { ...first, timeline: { frame_token: 100 } } }, + { receivedAtMs: 5_500, data: { ...last, timeline: { frame_token: 101 } } }, + { receivedAtMs: 6_000, data: { ...last, timeline: { frame_token: 102 } } }, + ] +} + +const specConfig = { + ...defaults, + durationMs: 7_000, + requireMacosNativeCapture: true, +} + +function activeStatus( + inputSampleCount: number, + sessionFullFrameCount: number, + inputP95Ms: number, + framesPublished: number, + uptimeSeconds: number, +): JsonObject { + const source = (kind: string, freshness: string, platform?: JsonObject): JsonObject => ({ + source_id: `source-${kind}`, + kind, + demanded: true, + active_consumer_count: 1, + state: "live", + freshness, + source_graph_generation: 9, + session_generation: 3, + ...(platform ? { platform } : {}), + }) + return { + server: { instance_id: "daemon-1" }, + uptime_seconds: uptimeSeconds, + capture_available: true, + session_performance: { + input_stage: { + sample_count: inputSampleCount, + p95_ms: inputP95Ms, + cumulative_histogram: cumulativeHistogram( + inputSampleCount, + inputP95Ms, + framesPublished, + ), + }, + full_frame_cpu_copies: { count: sessionFullFrameCount }, + }, + input: { + sources: [ + source("screen", "fresh", { + type: "macos_screen", + telemetry: { + publication_path: "native", + capture_session_generation: 7, + frames_published: framesPublished, + }, + }), + source("audio", "fresh"), + source("interaction", "not_applicable"), + ], + }, + } +} + +function cumulativeHistogram( + sampleCount: number, + latestP95Ms: number, + snapshotFrameToken: number, +): JsonObject { + const historicalCount = Math.min(sampleCount, 10) + const latestCount = sampleCount - historicalCount + const counts = new Map() + counts.set(8, historicalCount) + if (latestCount > 0) { + const bucketIndex = Math.ceil(latestP95Ms * 10) + counts.set(bucketIndex, (counts.get(bucketIndex) ?? 0) + latestCount) + } + return { + bucket_width_us: 100, + overflow_bucket_index: 4096, + snapshot_frame_token: snapshotFrameToken, + buckets: [...counts] + .filter(([, count]) => count > 0) + .map(([bucket_index, count]) => ({ bucket_index, count })), + } +} + +function setCumulativeHistogram( + status: JsonObject, + buckets: Array<[number, number]>, + snapshotFrameToken: number, +): void { + const inputStage = valueAt(status, ["session_performance", "input_stage"]) as JsonObject + inputStage.cumulative_histogram = { + bucket_width_us: 100, + overflow_bucket_index: 4096, + snapshot_frame_token: snapshotFrameToken, + buckets: buckets.map(([bucket_index, count]) => ({ bucket_index, count })), + } +} + +function analyzeActive(first: JsonObject, last: JsonObject, intermediate: MetricSample[] = []) { + const observed = samples(first, last) + return analyze( + specConfig, + [observed[0]!, ...intermediate, ...observed.slice(1)], + [], + activeStatus( + metricNumber(first, ["input_latency", "sample_count"]), + metricNumber(first, ["copies", "session_full_frame_count"]), + metricNumber(first, ["input_latency", "p95_ms"]), + 100, + 100, + ), + activeStatus( + metricNumber(last, ["input_latency", "sample_count"]), + metricNumber(last, ["copies", "session_full_frame_count"]), + metricNumber(last, ["input_latency", "p95_ms"]), + 101, + 107, + ), + ) +} + +describe("Spec 76 graphics acceptance", () => { + test("accepts bounded input latency with zero full-frame copy growth", () => { + const report = analyzeActive(metrics(0.8, 10, 4), metrics(0.9, 11, 4)) + + expect(report.ok).toBe(true) + expect(report.summary.maxInputP95Ms).toBe(0.9) + expect(report.summary.inputSampleCountDelta).toBe(1) + expect(report.summary.sessionFullFrameCopyCountDelta).toBe(0) + }) + + test("rejects input latency above the one millisecond contract", () => { + const report = analyzeActive(metrics(0.8, 10, 4), metrics(1.01, 11, 4)) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "input-stage p95 ms", + ok: false, + actual: 1.1, + limit: 1, + }) + }) + + test("rejects session full-frame copy growth", () => { + const report = analyzeActive(metrics(0.8, 10, 4), metrics(0.9, 11, 5)) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "session full-frame-copy count delta", + ok: false, + actual: 1, + limit: 0, + }) + }) + + test("does not count a warmup copy as steady-state growth", () => { + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 5), metrics(0.9, 11, 5)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 5, 0.9, 101, 107), + activeStatus(10, 5, 0.8, 100, 105), + ) + + expect(report.ok).toBe(true) + expect(report.summary.sessionFullFrameCopyCountDelta).toBe(0) + }) + + test("counts a first-interval copy when warmup is disabled", () => { + const noWarmup = { + ...specConfig, + durationMs: 3_000, + warmupMs: 0, + } + const first = metrics(0.8, 11, 1) + const last = metrics(0.9, 12, 1) + const report = analyze( + noWarmup, + [ + { receivedAtMs: 500, data: { ...first, timeline: { frame_token: 101 } } }, + { receivedAtMs: 1_500, data: { ...last, timeline: { frame_token: 102 } } }, + { receivedAtMs: 2_500, data: { ...last, timeline: { frame_token: 103 } } }, + ], + [], + activeStatus(10, 0, 0.8, 100, 100), + activeStatus(12, 1, 0.9, 101, 103), + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "session full-frame-copy count delta", + ok: false, + actual: 1, + limit: 0, + }) + }) + + test("fails closed when required session telemetry is absent", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + const inputStage = valueAt(after, ["session_performance", "input_stage"]) as JsonObject + delete inputStage.cumulative_histogram + expect(() => + analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ), + ).toThrow("Missing input-stage cumulative histogram") + }) + + test("rejects a steady window without new input samples", () => { + const report = analyzeActive(metrics(0.8, 10, 4), metrics(0.9, 10, 4)) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "input-stage sample growth", + ok: false, + actual: 0, + limit: ">= 1", + }) + }) + + test("does not count a warmup input sample as steady growth", () => { + const report = analyze( + specConfig, + samples(metrics(0.8, 11, 4), metrics(0.9, 11, 4)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 4, 0.9, 101, 107), + activeStatus(11, 4, 0.9, 100, 105), + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "input-stage sample growth", + ok: false, + actual: 0, + limit: ">= 1", + }) + }) + + test("rejects an observation without a pre-warmup baseline", () => { + const report = analyze( + specConfig, + [{ receivedAtMs: 6_000, data: metrics(0.9, 11, 4) }], + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 4, 0.9, 101, 107), + ) + + expect(report.ok).toBe(false) + expect(report.checks[0]?.name).toBe("warmup baseline and steady metrics") + }) + + test("fails closed when a cumulative session counter regresses", () => { + expect(() => analyzeActive(metrics(0.8, 11, 4), metrics(0.9, 10, 4))).toThrow( + "Cumulative metric regressed: session_performance.input_stage.sample_count", + ) + }) + + test("fails closed when the full-frame copy counter regresses", () => { + expect(() => analyzeActive(metrics(0.8, 10, 5), metrics(0.9, 11, 4))).toThrow( + "Cumulative metric regressed: session_performance.full_frame_cpu_copies.count", + ) + }) + + test("keeps unordered WS counters out of authoritative REST deltas", () => { + const report = analyzeActive(metrics(0.8, 10, 4), metrics(0.9, 11, 4), [ + { receivedAtMs: 5_250, data: metrics(0.9, 9, 3) }, + ]) + + expect(report.ok).toBe(true) + expect(report.summary.inputSampleCountDelta).toBe(1) + expect(report.summary.sessionFullFrameCopyCountDelta).toBe(0) + }) + + test("rejects a truncated metrics observation", () => { + const report = analyze( + { ...specConfig, durationMs: 60_000 }, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 4, 0.9, 101, 160), + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "observation window coverage ms", + ok: false, + actual: 6_000, + limit: ">= 58000", + }) + }) + + test("does not shorten acceptance when the warmup checkpoint responds late", () => { + const first = metrics(0.8, 10, 4) + const last = metrics(0.9, 11, 4) + const report = analyze( + { ...specConfig, durationMs: 60_000 }, + [ + { receivedAtMs: 4_000, data: { ...first, timeline: { frame_token: 100 } } }, + { receivedAtMs: 59_000, data: { ...last, timeline: { frame_token: 101 } } }, + { receivedAtMs: 60_000, data: { ...last, timeline: { frame_token: 102 } } }, + ], + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 4, 0.9, 101, 160), + activeStatus(10, 4, 0.8, 100, 105), + 58_000, + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "observation window coverage ms", + ok: false, + actual: 60_000, + limit: ">= 111000", + }) + expect(report.checks).toContainEqual({ + name: "steady metrics samples", + ok: false, + actual: 2, + limit: ">= 54", + }) + }) + + test("seals the final copy interval from REST status", () => { + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(12, 5, 0.9, 101, 107), + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "session full-frame-copy count delta", + ok: false, + actual: 1, + limit: 0, + }) + }) + + test("seals the final input latency interval from REST status", () => { + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(12, 4, 1.01, 101, 107), + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "input-stage p95 ms", + ok: false, + actual: 1.1, + limit: 1, + }) + }) + + test("isolates observation latency from lifetime history", () => { + const before = activeStatus(100_000, 4, 0.1, 100, 100) + const after = activeStatus(100_100, 4, 0.1, 101, 107) + setCumulativeHistogram(before, [[1, 100_000]], 100) + setCumulativeHistogram( + after, + [ + [1, 100_000], + [11, 100], + ], + 101, + ) + const report = analyze( + specConfig, + samples(metrics(0.1, 100_000, 4), metrics(0.1, 100_100, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "input-stage p95 ms", + ok: false, + actual: 1.1, + limit: 1, + }) + }) + + test("rejects a nonnative or inactive workload", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + for (const status of [before, after]) { + const screen = (valueAt(status, ["input", "sources"]) as JsonObject[])[0] + if (screen) { + screen.platform = { type: "macos_screen", telemetry: { publication_path: "cpu" } } + } + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "native screen active", + ok: false, + actual: "inactive/inactive", + limit: "active/active", + }) + }) + + test("starts workload identity checks after warmup", () => { + const before = activeStatus(10, 4, 0.8, 90, 100) + const baseline = activeStatus(10, 4, 0.8, 100, 105) + const after = activeStatus(11, 4, 0.9, 101, 107) + const beforeScreen = (valueAt(before, ["input", "sources"]) as JsonObject[])[0] + if (beforeScreen) { + beforeScreen.state = "stopped" + beforeScreen.source_graph_generation = 8 + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + baseline, + ) + + expect(report.ok).toBe(true) + }) + + test("rejects an inactive host-input source", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + for (const status of [before, after]) { + const interaction = (valueAt(status, ["input", "sources"]) as JsonObject[])[2] + if (interaction) { + interaction.state = "stopped" + } + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks).toContainEqual({ + name: "interaction input active", + ok: false, + actual: "inactive/inactive", + limit: "active/active", + }) + }) + + test("rejects a stale native screen source", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + for (const status of [before, after]) { + const screen = (valueAt(status, ["input", "sources"]) as JsonObject[])[0] + if (screen) { + screen.freshness = "stale" + } + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks.find((check) => check.name === "native screen active")?.ok).toBe(false) + }) + + test("rejects a native screen without freshness tracking", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + for (const status of [before, after]) { + const screen = (valueAt(status, ["input", "sources"]) as JsonObject[])[0] + if (screen) { + screen.freshness = "not_applicable" + } + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks.find((check) => check.name === "native screen active")?.ok).toBe(false) + }) + + test("rejects a source graph replacement during acceptance", () => { + const before = activeStatus(10, 4, 0.8, 100, 100) + const after = activeStatus(11, 4, 0.9, 101, 107) + const screen = (valueAt(after, ["input", "sources"]) as JsonObject[])[0] + if (screen) { + screen.source_graph_generation = 10 + } + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + before, + after, + ) + + expect(report.ok).toBe(false) + expect(report.checks.find((check) => check.name === "native screen publication growth")?.ok).toBe(false) + }) + + test("rejects a daemon restart during acceptance", () => { + const report = analyze( + specConfig, + samples(metrics(0.8, 10, 4), metrics(0.9, 11, 4)), + [], + activeStatus(10, 4, 0.8, 100, 100), + activeStatus(11, 4, 0.9, 101, 2), + ) + + expect(report.ok).toBe(false) + expect(report.checks.find((check) => check.name === "daemon session continuity")?.ok).toBe(false) + }) + + test("keeps the generic graphics soak cross-platform", () => { + const genericConfig = { ...defaults, durationMs: 7_000 } + const report = analyze( + genericConfig, + samples(metrics(0, 0, 0), metrics(0, 0, 0)), + [], + {}, + {}, + {}, + 0, + ) + + expect(report.ok).toBe(true) + expect(report.checks.some((check) => check.name === "native screen active")).toBe(false) + expect(report.summary.maxInputP95Ms).toBeUndefined() + expect(report.summary.inputSampleCountDelta).toBeUndefined() + expect(report.summary.sessionFullFrameCopyCountDelta).toBeUndefined() + }) +}) + +function metricNumber(root: JsonObject, path: string[]): number { + const value = valueAt(root, path) + return typeof value === "number" ? value : 0 +} + +function valueAt(root: JsonObject, path: string[]): unknown { + let current: unknown = root + for (const part of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined + } + current = (current as JsonObject)[part] + } + return current +} diff --git a/scripts/graphics-pipeline-soak.ts b/scripts/graphics-pipeline-soak.ts index 984fda5bc..2df1c7a06 100644 --- a/scripts/graphics-pipeline-soak.ts +++ b/scripts/graphics-pipeline-soak.ts @@ -1,19 +1,22 @@ #!/usr/bin/env bun -type JsonObject = Record +export type JsonObject = Record type Config = { daemon: string durationMs: number intervalMs: number warmupMs: number + requireMacosNativeCapture: boolean minFpsRatio: number + maxInputP95Ms: number maxBackpressureFrames: number maxWriteFailureDelta: number maxRetryDelta: number maxOutputErrorDelta: number maxFullFrameCopyFrames: number maxFrameCopyCount: number + maxSessionFullFrameCopyCountDelta: number maxPoolSaturationDelta: number maxEffectFallbackDelta: number maxProducerGpuReadbackFailureDelta: number @@ -31,7 +34,7 @@ type Config = { json: boolean } -type MetricSample = { +export type MetricSample = { receivedAtMs: number data: JsonObject } @@ -70,18 +73,21 @@ const palette = { reset: "\x1b[0m", } -const defaults: Config = { +export const defaults: Config = { daemon: "http://127.0.0.1:9420", durationMs: 60_000, intervalMs: 1_000, warmupMs: 5_000, + requireMacosNativeCapture: false, minFpsRatio: 0.75, + maxInputP95Ms: 1, maxBackpressureFrames: 0, maxWriteFailureDelta: 0, maxRetryDelta: 0, maxOutputErrorDelta: 0, maxFullFrameCopyFrames: 0, maxFrameCopyCount: 0, + maxSessionFullFrameCopyCountDelta: 0, maxPoolSaturationDelta: 0, maxEffectFallbackDelta: 0, maxProducerGpuReadbackFailureDelta: 0, @@ -113,13 +119,18 @@ Options: --duration <30s|2m|1500ms> Friendlier duration syntax --interval-ms Metrics interval [${defaults.intervalMs}] --warmup-ms Exclude initial samples from steady-state checks [${defaults.warmupMs}] + --macos-native-capture Enforce the Spec 76 native screen, audio, and input workload --min-fps-ratio Median actual FPS must stay above target * ratio [${defaults.minFpsRatio}] + --max-input-p95-ms Maximum session input-stage p95 [${defaults.maxInputP95Ms}] --max-backpressure-frames Maximum dropped WS frames [${defaults.maxBackpressureFrames}] --max-write-failure-delta Maximum display write failures [${defaults.maxWriteFailureDelta}] --max-retry-delta Maximum display retry attempts [${defaults.maxRetryDelta}] --max-output-error-delta Maximum render pacing output-error frames [${defaults.maxOutputErrorDelta}] --max-full-frame-copy-frames Maximum pacing full-frame-copy frames [${defaults.maxFullFrameCopyFrames}] --max-frame-copy-count Maximum per-frame full-copy count [${defaults.maxFrameCopyCount}] + --max-session-full-frame-copy-count-delta + Maximum session full-frame-copy count growth + [${defaults.maxSessionFullFrameCopyCountDelta}] --max-pool-saturation-delta Maximum render-surface pool saturation reallocs [${defaults.maxPoolSaturationDelta}] --max-effect-fallback-delta Maximum effect fallbacks [${defaults.maxEffectFallbackDelta}] --max-producer-gpu-readback-failure-delta @@ -163,6 +174,10 @@ function parseArgs(argv: string[]): Config { config.json = true continue } + if (arg === "--macos-native-capture") { + config.requireMacosNativeCapture = true + continue + } const value = argv[index + 1] if (!value || value.startsWith("--")) { @@ -189,6 +204,9 @@ function parseArgs(argv: string[]): Config { case "--min-fps-ratio": config.minFpsRatio = parseNonNegativeNumber(arg, value) break + case "--max-input-p95-ms": + config.maxInputP95Ms = parseNonNegativeNumber(arg, value) + break case "--max-backpressure-frames": config.maxBackpressureFrames = parseNonNegativeInt(arg, value) break @@ -207,6 +225,9 @@ function parseArgs(argv: string[]): Config { case "--max-frame-copy-count": config.maxFrameCopyCount = parseNonNegativeInt(arg, value) break + case "--max-session-full-frame-copy-count-delta": + config.maxSessionFullFrameCopyCountDelta = parseNonNegativeInt(arg, value) + break case "--max-pool-saturation-delta": config.maxPoolSaturationDelta = parseNonNegativeInt(arg, value) break @@ -314,7 +335,7 @@ function wsEndpoint(raw: string): string { return prefix.toString() } -async function assertDaemonReachable(config: Config): Promise { +async function fetchStatus(config: Config): Promise { const statusUrl = `${apiPrefix(config.daemon)}/status` let response: Response try { @@ -325,26 +346,56 @@ async function assertDaemonReachable(config: Config): Promise { if (!response.ok) { throw new Error(`Daemon status check failed at ${statusUrl}: HTTP ${response.status}`) } + const envelope = (await response.json()) as JsonObject + const status = objectAt(envelope, ["data"]) + if (!status) { + throw new Error(`Daemon status at ${statusUrl} omitted the data object`) + } + return status } -async function observe(config: Config): Promise<{ samples: MetricSample[]; backpressure: BackpressureSample[] }> { - await assertDaemonReachable(config) +async function observe(config: Config): Promise<{ + samples: MetricSample[] + backpressure: BackpressureSample[] + statusBefore: JsonObject + statusBaseline: JsonObject + acceptanceStartedAtMs: number + statusAfter: JsonObject +}> { + const statusBefore = await fetchStatus(config) + let statusBaseline = + !config.requireMacosNativeCapture || config.warmupMs === 0 ? statusBefore : undefined + let acceptanceStartedAtMs = config.warmupMs const samples: MetricSample[] = [] const backpressure: BackpressureSample[] = [] const endpoint = wsEndpoint(config.daemon) const startedAtMs = Date.now() - return await new Promise((resolve, reject) => { + const observed = await new Promise<{ samples: MetricSample[]; backpressure: BackpressureSample[] }>((resolve, reject) => { const socket = new WebSocket(endpoint) let settled = false let sawOpen = false + const cleanup = () => { + clearTimeout(openTimer) + clearTimeout(finishTimer) + if (baselineTimer) { + clearTimeout(baselineTimer) + } + process.off("SIGINT", interrupt) + } + const finish = () => { if (settled) { return } + if (config.requireMacosNativeCapture && !statusBaseline) { + fail(new Error("Warmup status checkpoint did not complete before the observation window")) + return + } settled = true + cleanup() socket.close() resolve({ samples, backpressure }) } @@ -354,17 +405,40 @@ async function observe(config: Config): Promise<{ samples: MetricSample[]; backp return } settled = true + cleanup() socket.close() reject(error) } + const interrupt = () => { + fail(new Error("Graphics soak interrupted before the observation window completed")) + } + const openTimer = setTimeout(() => { if (!sawOpen) { fail(new Error(`Timed out opening ${endpoint}`)) } }, 5_000) - const finishTimer = setTimeout(finish, config.durationMs) + let finishTimer = setTimeout(finish, config.durationMs) + const baselineTimer = + !config.requireMacosNativeCapture || config.warmupMs === 0 + ? undefined + : setTimeout(() => { + void fetchStatus(config) + .then((status) => { + statusBaseline = status + acceptanceStartedAtMs = Date.now() - startedAtMs + clearTimeout(finishTimer) + finishTimer = setTimeout( + finish, + config.durationMs - config.warmupMs, + ) + }) + .catch((error) => { + fail(new Error(`Warmup status checkpoint failed: ${errorMessage(error)}`)) + }) + }, config.warmupMs) socket.onopen = () => { sawOpen = true @@ -379,11 +453,15 @@ async function observe(config: Config): Promise<{ samples: MetricSample[]; backp } socket.onerror = () => { - clearTimeout(openTimer) - clearTimeout(finishTimer) fail(new Error(`WebSocket error while observing ${endpoint}`)) } + socket.onclose = () => { + if (!settled) { + fail(new Error(`WebSocket closed before the observation window completed: ${endpoint}`)) + } + } + socket.onmessage = (event: MessageEvent) => { const text = typeof event.data === "string" ? event.data : "" if (!text) { @@ -415,15 +493,50 @@ async function observe(config: Config): Promise<{ samples: MetricSample[]; backp } } - process.once("SIGINT", finish) + process.once("SIGINT", interrupt) }) + const statusAfter = config.requireMacosNativeCapture ? await fetchStatus(config) : statusBefore + if (!statusBaseline) { + throw new Error("Warmup status checkpoint is unavailable") + } + return { ...observed, statusBefore, statusBaseline, acceptanceStartedAtMs, statusAfter } } -function analyze(config: Config, samples: MetricSample[], backpressure: BackpressureSample[]): Report { - const steadySamples = samples.filter((sample) => sample.receivedAtMs >= config.warmupMs) - const observed = steadySamples.length > 0 ? steadySamples : samples - const first = observed[0] - const last = observed.at(-1) +export function analyze( + config: Config, + samples: MetricSample[], + backpressure: BackpressureSample[], + statusBefore: JsonObject, + statusAfter: JsonObject, + statusBaseline: JsonObject = statusBefore, + acceptanceStartedAtMs: number = config.warmupMs, +): Report { + const acceptanceBoundaryMs = config.requireMacosNativeCapture + ? acceptanceStartedAtMs + : config.warmupMs + const acceptanceFrameToken = config.requireMacosNativeCapture + ? requiredInputHistogramFrameToken(statusBaseline) + : undefined + const steadySamples = config.requireMacosNativeCapture + ? samples.filter( + (sample) => requiredNumberAt(sample.data, ["timeline", "frame_token"]) > acceptanceFrameToken!, + ) + : samples.filter((sample) => sample.receivedAtMs >= acceptanceBoundaryMs) + const baseline = config.requireMacosNativeCapture + ? config.warmupMs === 0 + ? samples[0] + : samples + .filter( + (sample) => + requiredNumberAt(sample.data, ["timeline", "frame_token"]) <= acceptanceFrameToken!, + ) + .at(-1) + : config.warmupMs === 0 + ? samples[0] + : samples.filter((sample) => sample.receivedAtMs < acceptanceBoundaryMs).at(-1) + const observed = steadySamples + const first = baseline + const last = steadySamples.at(-1) const checks: Check[] = [] if (!first || !last) { @@ -434,10 +547,27 @@ function analyze(config: Config, samples: MetricSample[], backpressure: Backpres sampleCount: samples.length, backpressure, summary: {}, - checks: [{ name: "metrics samples", ok: false, actual: 0, limit: "> 0" }], + checks: [ + { + name: "warmup baseline and steady metrics", + ok: false, + actual: `${baseline ? 1 : 0}/${steadySamples.length}`, + limit: "baseline/steady > 0", + }, + ], } } + const steadyWindowMs = config.durationMs - config.warmupMs + const minimumLastSampleMs = Math.max( + 0, + acceptanceBoundaryMs + steadyWindowMs - config.intervalMs * 2, + ) + const expectedSteadySamples = Math.max( + 2, + Math.floor(steadyWindowMs / config.intervalMs) - 1, + ) + const fpsValues = observed.map((sample) => numberAt(sample.data, ["fps", "actual"])).filter((value) => value > 0) const targetFps = numberAt(last.data, ["fps", "target"]) const medianFps = median(fpsValues) @@ -460,6 +590,44 @@ function analyze(config: Config, samples: MetricSample[], backpressure: Backpres ]) const frameP95BudgetMs = targetFps > 0 ? (1_000 / targetFps) * 1.25 : Number.POSITIVE_INFINITY const maxFrameP95Ms = maxAt(observed, ["frame_time", "p95_ms"]) + let maxInputP95Ms = 0 + let inputSampleCountDelta = 0 + let sessionFullFrameCopyCountDelta = 0 + + if (config.requireMacosNativeCapture) { + checks.push( + checkAtLeast( + "observation window coverage ms", + last.receivedAtMs, + minimumLastSampleMs, + ), + ) + checks.push(checkAtLeast("steady metrics samples", steadySamples.length, expectedSteadySamples)) + inputSampleCountDelta = requiredMetricSequenceDelta( + statusBefore, + statusBaseline, + statusAfter, + ["session_performance", "input_stage", "sample_count"], + ) + sessionFullFrameCopyCountDelta = requiredMetricSequenceDelta( + statusBefore, + statusBaseline, + statusAfter, + ["session_performance", "full_frame_cpu_copies", "count"], + ) + maxInputP95Ms = requiredHistogramDeltaP95Ms(statusBaseline, statusAfter) + checks.push(daemonContinuityCheck(statusBefore, statusAfter, config.durationMs)) + checks.push(...workloadChecks(statusBaseline, statusAfter)) + checks.push(checkAtMost("input-stage p95 ms", maxInputP95Ms, config.maxInputP95Ms)) + checks.push(checkAtLeast("input-stage sample growth", inputSampleCountDelta, 1)) + checks.push( + checkAtMost( + "session full-frame-copy count delta", + sessionFullFrameCopyCountDelta, + config.maxSessionFullFrameCopyCountDelta, + ), + ) + } checks.push(checkAtLeast("median fps", round(medianFps), round(minFps))) checks.push(checkAtMost("frame p95 ms", round(maxFrameP95Ms), round(frameP95BudgetMs))) @@ -594,6 +762,13 @@ function analyze(config: Config, samples: MetricSample[], backpressure: Backpres targetFps, medianFps: round(medianFps), maxFrameP95Ms: round(maxFrameP95Ms), + ...(config.requireMacosNativeCapture + ? { + maxInputP95Ms: round(maxInputP95Ms), + inputSampleCountDelta, + sessionFullFrameCopyCountDelta, + } + : {}), backpressureFrames, writeFailureDelta: delta(first.data, last.data, ["display_output", "write_failures_total"]), retryDelta: delta(first.data, last.data, ["display_output", "retry_attempts_total"]), @@ -709,6 +884,237 @@ function delta(first: JsonObject, last: JsonObject, path: string[]): number { return Math.max(0, numberAt(last, path) - numberAt(first, path)) } +function requiredMetricSequenceDelta( + statusBefore: JsonObject, + statusBaseline: JsonObject, + statusAfter: JsonObject, + statusPath: string[], +): number { + const values = [ + requiredNumberAt(statusBefore, statusPath), + requiredNumberAt(statusBaseline, statusPath), + requiredNumberAt(statusAfter, statusPath), + ] + for (let index = 1; index < values.length; index += 1) { + const previous = values[index - 1] + const current = values[index] + if (current < previous) { + throw new Error(`Cumulative metric regressed: ${statusPath.join(".")} (${previous} -> ${current})`) + } + } + return values[values.length - 1] - values[1] +} + +type CumulativeHistogram = { + bucketWidthUs: number + overflowBucketIndex: number + snapshotFrameToken: number + buckets: Map +} + +function requiredInputHistogramFrameToken(status: JsonObject): number { + const inputStage = objectAt(status, ["session_performance", "input_stage"]) + const histogram = inputStage ? objectAt(inputStage, ["cumulative_histogram"]) : undefined + if (!histogram) { + throw new Error("Missing input-stage cumulative histogram") + } + return requiredNonNegativeInteger(histogram, ["snapshot_frame_token"]) +} + +function requiredHistogramDeltaP95Ms(statusBaseline: JsonObject, statusAfter: JsonObject): number { + const baseline = cumulativeInputHistogram(statusBaseline) + const after = cumulativeInputHistogram(statusAfter) + if ( + baseline.bucketWidthUs !== after.bucketWidthUs || + baseline.overflowBucketIndex !== after.overflowBucketIndex + ) { + throw new Error("Input latency histogram geometry changed during observation") + } + + const bucketIndexes = new Set([...baseline.buckets.keys(), ...after.buckets.keys()]) + const deltas = [...bucketIndexes] + .sort((left, right) => left - right) + .map((bucketIndex) => { + const beforeCount = baseline.buckets.get(bucketIndex) ?? 0 + const afterCount = after.buckets.get(bucketIndex) ?? 0 + if (afterCount < beforeCount) { + throw new Error( + `Cumulative input histogram regressed at bucket ${bucketIndex}: ` + + `${beforeCount} -> ${afterCount}`, + ) + } + return { bucketIndex, count: afterCount - beforeCount } + }) + + const sampleCount = deltas.reduce((total, bucket) => total + bucket.count, 0) + if (sampleCount === 0) { + return 0 + } + const rank = Math.ceil((sampleCount * 95) / 100) + let observed = 0 + for (const bucket of deltas) { + observed += bucket.count + if (observed >= rank) { + if (bucket.bucketIndex >= after.overflowBucketIndex) { + return Number.POSITIVE_INFINITY + } + return (bucket.bucketIndex * after.bucketWidthUs) / 1_000 + } + } + throw new Error("Input latency histogram did not contain its reported sample count") +} + +function cumulativeInputHistogram(status: JsonObject): CumulativeHistogram { + const inputStage = objectAt(status, ["session_performance", "input_stage"]) + const histogram = inputStage ? objectAt(inputStage, ["cumulative_histogram"]) : undefined + if (!inputStage || !histogram) { + throw new Error("Missing input-stage cumulative histogram") + } + const bucketWidthUs = requiredPositiveInteger(histogram, ["bucket_width_us"]) + const overflowBucketIndex = requiredPositiveInteger(histogram, ["overflow_bucket_index"]) + const snapshotFrameToken = requiredNonNegativeInteger(histogram, ["snapshot_frame_token"]) + const rawBuckets = valueAt(histogram, ["buckets"]) + if (!Array.isArray(rawBuckets)) { + throw new Error("Missing input-stage cumulative histogram buckets") + } + + const buckets = new Map() + for (const rawBucket of rawBuckets) { + if (!rawBucket || typeof rawBucket !== "object" || Array.isArray(rawBucket)) { + throw new Error("Invalid input-stage cumulative histogram bucket") + } + const bucket = rawBucket as JsonObject + const bucketIndex = requiredNonNegativeInteger(bucket, ["bucket_index"]) + const count = requiredNonNegativeInteger(bucket, ["count"]) + if (bucketIndex > overflowBucketIndex || buckets.has(bucketIndex)) { + throw new Error(`Invalid input-stage cumulative histogram bucket index: ${bucketIndex}`) + } + buckets.set(bucketIndex, count) + } + + const histogramSamples = [...buckets.values()].reduce((total, count) => total + count, 0) + const reportedSamples = requiredNonNegativeInteger(inputStage, ["sample_count"]) + if (histogramSamples !== reportedSamples) { + throw new Error( + `Input latency histogram sample count mismatch: ${histogramSamples} != ${reportedSamples}`, + ) + } + return { bucketWidthUs, overflowBucketIndex, snapshotFrameToken, buckets } +} + +function workloadChecks(statusBaseline: JsonObject, statusAfter: JsonObject): Check[] { + return [ + workloadCheck("native screen active", statusBaseline, statusAfter, nativeScreenActive), + nativeScreenPublicationCheck(statusBaseline, statusAfter), + workloadCheck("audio input active", statusBaseline, statusAfter, (status) => sourceActive(status, "audio")), + workloadCheck("interaction input active", statusBaseline, statusAfter, (status) => + sourceActive(status, "interaction"), + ), + ] +} + +function daemonContinuityCheck(statusBefore: JsonObject, statusAfter: JsonObject, durationMs: number): Check { + const before = stringAt(statusBefore, ["server", "instance_id"]) + const after = stringAt(statusAfter, ["server", "instance_id"]) + const uptimeBefore = numberAt(statusBefore, ["uptime_seconds"]) + const uptimeAfter = numberAt(statusAfter, ["uptime_seconds"]) + const minimumUptimeGrowth = Math.max(0, Math.floor(durationMs / 1_000) - 1) + const continuous = Boolean(before) && before === after && uptimeAfter - uptimeBefore >= minimumUptimeGrowth + return { + name: "daemon session continuity", + ok: continuous, + actual: continuous ? "continuous" : "changed", + limit: "continuous", + } +} + +function nativeScreenPublicationCheck(statusBefore: JsonObject, statusAfter: JsonObject): Check { + const before = nativeScreenSource(statusBefore) + const after = nativeScreenSource(statusAfter) + const beforeSourceId = before ? stringAt(before, ["source_id"]) : "" + const afterSourceId = after ? stringAt(after, ["source_id"]) : "" + const beforeSession = before ? numberAt(before, ["session_generation"]) : 0 + const afterSession = after ? numberAt(after, ["session_generation"]) : 0 + const beforeGraph = before ? numberAt(before, ["source_graph_generation"]) : 0 + const afterGraph = after ? numberAt(after, ["source_graph_generation"]) : 0 + const beforeCapture = before + ? numberAt(before, ["platform", "telemetry", "capture_session_generation"]) + : 0 + const afterCapture = after + ? numberAt(after, ["platform", "telemetry", "capture_session_generation"]) + : 0 + const sameSource = + Boolean(before) && + Boolean(after) && + Boolean(beforeSourceId) && + beforeSourceId === afterSourceId && + beforeSession > 0 && + beforeSession === afterSession && + beforeGraph > 0 && + beforeGraph === afterGraph && + beforeCapture > 0 && + beforeCapture === afterCapture + const beforeCount = before ? numberAt(before, ["platform", "telemetry", "frames_published"]) : 0 + const afterCount = after ? numberAt(after, ["platform", "telemetry", "frames_published"]) : 0 + const growth = sameSource && afterCount >= beforeCount ? afterCount - beforeCount : -1 + return checkAtLeast("native screen publication growth", growth, 1) +} + +function workloadCheck( + name: string, + statusBefore: JsonObject, + statusAfter: JsonObject, + predicate: (status: JsonObject) => boolean, +): Check { + const before = predicate(statusBefore) + const after = predicate(statusAfter) + return { + name, + ok: before && after, + actual: `${before ? "active" : "inactive"}/${after ? "active" : "inactive"}`, + limit: "active/active", + } +} + +function nativeScreenActive(status: JsonObject): boolean { + if (valueAt(status, ["capture_available"]) !== true) { + return false + } + return Boolean(nativeScreenSource(status)) +} + +function nativeScreenSource(status: JsonObject): JsonObject | undefined { + return sources(status).find( + (source) => + sourceIsActive(source, "screen") && + valueAt(source, ["freshness"]) === "fresh" && + valueAt(source, ["platform", "type"]) === "macos_screen" && + valueAt(source, ["platform", "telemetry", "publication_path"]) === "native", + ) +} + +function sourceActive(status: JsonObject, kind: string): boolean { + return sources(status).some((source) => sourceIsActive(source, kind)) +} + +function sourceIsActive(source: JsonObject, kind: string): boolean { + const freshness = valueAt(source, ["freshness"]) + return ( + valueAt(source, ["kind"]) === kind && + valueAt(source, ["demanded"]) === true && + numberAt(source, ["active_consumer_count"]) > 0 && + valueAt(source, ["state"]) === "live" && + (freshness === "fresh" || freshness === "not_applicable") + ) +} + +function sources(status: JsonObject): JsonObject[] { + const value = valueAt(status, ["input", "sources"]) + return Array.isArray(value) + ? value.filter((source): source is JsonObject => Boolean(source) && typeof source === "object") + : [] +} + function maxAt(samples: MetricSample[], path: string[]): number { return samples.reduce((max, sample) => Math.max(max, numberAt(sample.data, path)), 0) } @@ -763,6 +1169,22 @@ function requiredNumberAt(root: JsonObject, path: string[]): number { return value } +function requiredNonNegativeInteger(root: JsonObject, path: string[]): number { + const value = requiredNumberAt(root, path) + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Metric must be a non-negative integer: ${path.join(".")}`) + } + return value +} + +function requiredPositiveInteger(root: JsonObject, path: string[]): number { + const value = requiredNonNegativeInteger(root, path) + if (value === 0) { + throw new Error(`Metric must be a positive integer: ${path.join(".")}`) + } + return value +} + function stringAt(root: JsonObject, path: string[]): string { const value = valueAt(root, path) return typeof value === "string" ? value : "" @@ -830,8 +1252,17 @@ function printReport(report: Report): void { async function main(): Promise { const config = parseArgs(process.argv.slice(2)) - const { samples, backpressure } = await observe(config) - const report = analyze(config, samples, backpressure) + const { samples, backpressure, statusBefore, statusBaseline, acceptanceStartedAtMs, statusAfter } = + await observe(config) + const report = analyze( + config, + samples, + backpressure, + statusBefore, + statusAfter, + statusBaseline, + acceptanceStartedAtMs, + ) const json = `${JSON.stringify(report, null, 2)}\n` if (config.out) { @@ -847,7 +1278,9 @@ async function main(): Promise { process.exit(report.ok ? 0 : 1) } -main().catch((error) => { - console.error(`${palette.red}graphics soak failed:${palette.reset} ${errorMessage(error)}`) - process.exit(1) -}) +if (import.meta.main) { + main().catch((error) => { + console.error(`${palette.red}graphics soak failed:${palette.reset} ${errorMessage(error)}`) + process.exit(1) + }) +} 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 diff --git a/scripts/macos-dev-postsign.sh b/scripts/macos-dev-postsign.sh new file mode 100755 index 000000000..0eafa7a50 --- /dev/null +++ b/scripts/macos-dev-postsign.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Re-sign the dev bundle so the daemon carries the sidecar identity the +# launcher authority verifies. +# +# Tauri signs every nested binary with a filename-derived identifier +# (hypercolor-daemon), but the app-sidecar ownership handshake requires +# the daemon's designated requirement to open with +# identifier "tech.hyperbliss.hypercolor.sidecar" +# and share its certificate tail with the app. The release lane fixes +# identifiers up after the Tauri build the same way +# (scripts/sign-macos-artifacts.sh); this is the minimal dev-bundle +# equivalent. Re-signing the daemon breaks the outer bundle seal, so +# the app is resealed afterward. The DMG Tauri produced before this +# pass keeps the unpatched app; dev iteration launches the .app +# directly. +# +# No-op on non-macOS hosts and for ad-hoc builds, whose bare cdhash +# requirement takes the launcher authority's structural fallback +# instead. +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + exit 0 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +app_path="${1:-${repo_root}/target/release/bundle/macos/Hypercolor.app}" +entitlements="${repo_root}/crates/hypercolor-app/entitlements.plist" + +identity="$("${script_dir}/macos-dev-signing-identity.sh")" +if [[ "${identity}" == "-" ]]; then + exit 0 +fi +[[ -d "${app_path}" ]] || { echo "app bundle not found: ${app_path}" >&2; exit 1; } + +codesign --force --options runtime --timestamp=none \ + --identifier tech.hyperbliss.hypercolor.sidecar \ + --entitlements "${entitlements}" \ + --sign "${identity}" \ + "${app_path}/Contents/MacOS/hypercolor-daemon" + +codesign --force --options runtime --timestamp=none \ + --entitlements "${entitlements}" \ + --sign "${identity}" \ + "${app_path}" + +codesign --verify --deep --strict "${app_path}" +echo "dev bundle resealed: daemon carries the sidecar identity" diff --git a/scripts/macos-dev-signing-identity.sh b/scripts/macos-dev-signing-identity.sh new file mode 100755 index 000000000..ecd7e42e0 --- /dev/null +++ b/scripts/macos-dev-signing-identity.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Resolve the code-signing identity for local macOS bundle builds. +# +# Ad-hoc signatures carry a per-build cdhash designated requirement, so +# macOS TCC treats every rebuild as a brand-new app and drops Screen +# Recording / Input Monitoring grants. Signing dev bundles with a stable +# local certificate keeps those grants alive across rebuilds. See +# docs/development/DEV_SETUP.md for the one-time certificate setup. +# +# Resolution order: +# 1. APPLE_SIGNING_IDENTITY, when the caller already exported one. +# 2. The local "Hypercolor Dev" identity, when the keychain holds a +# valid one. +# 3. "-" (explicit ad-hoc), with a warning on stderr. +set -euo pipefail + +DEV_IDENTITY="Hypercolor Dev" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "-" + exit 0 +fi + +if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then + echo "${APPLE_SIGNING_IDENTITY}" + exit 0 +fi + +# Sign by certificate hash rather than name: a duplicate certificate with +# the same label (easy to create by running Certificate Assistant twice) +# makes codesign reject the name as ambiguous, while the hash of the one +# valid identity stays unique. +identity_hash="$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -v name="\"${DEV_IDENTITY}\"" '$0 ~ name { print $2; exit }')" +if [[ -n "${identity_hash}" ]]; then + echo "${identity_hash}" + exit 0 +fi + +echo "warning: no '${DEV_IDENTITY}' signing identity found; bundle will be ad-hoc signed and macOS permission grants will not survive rebuilds. See docs/development/DEV_SETUP.md." >&2 +echo "-" diff --git a/scripts/macos-signing-keychain.c b/scripts/macos-signing-keychain.c new file mode 100644 index 000000000..a953cb818 --- /dev/null +++ b/scripts/macos-signing-keychain.c @@ -0,0 +1,571 @@ +#define __STDC_WANT_LIB_EXT1__ 1 + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#define SECRET_INPUT_MAX (128U * 1024U) +#define PKCS12_INPUT_MAX (16U * 1024U * 1024U) + +extern OSStatus SecKeychainItemSetAccessWithPassword( + SecKeychainItemRef item, + SecAccessRef access, + UInt32 password_length, + const void *password +); + +static void clear_bytes(void *bytes, size_t length) { + if (bytes != NULL && length != 0) { + (void)memset_s(bytes, length, 0, length); + } +} + +static int fail_message(const char *message) { + fprintf(stderr, "macOS signing keychain helper failed: %s\n", message); + return 1; +} + +static int fail_status(const char *operation, OSStatus status) { + CFStringRef description = SecCopyErrorMessageString(status, NULL); + char buffer[512] = {0}; + if (description != NULL) { + CFStringGetCString(description, buffer, sizeof(buffer), kCFStringEncodingUTF8); + CFRelease(description); + } + fprintf( + stderr, + "macOS signing keychain helper failed: %s (%d%s%s)\n", + operation, + (int)status, + buffer[0] == '\0' ? "" : ": ", + buffer + ); + return 1; +} + +static int read_secret_frame( + uint8_t *buffer, + size_t capacity, + uint8_t **keychain_password, + size_t *keychain_password_length, + uint8_t **certificate_password, + size_t *certificate_password_length, + size_t *frame_length +) { + size_t used = 0; + while (used < capacity) { + ssize_t count = read(STDIN_FILENO, buffer + used, capacity - used); + if (count > 0) { + used += (size_t)count; + continue; + } + if (count == 0) { + break; + } + if (errno == EINTR) { + continue; + } + return fail_message("could not read the secret frame from stdin"); + } + if (used == capacity) { + uint8_t extra = 0; + ssize_t count; + do { + count = read(STDIN_FILENO, &extra, 1); + } while (count < 0 && errno == EINTR); + if (count != 0) { + return fail_message("secret frame exceeds the bounded input size"); + } + } + + uint8_t *first_end = memchr(buffer, '\0', used); + if (first_end == NULL || first_end == buffer) { + return fail_message("secret frame has no keychain password"); + } + size_t first_length = (size_t)(first_end - buffer); + size_t remaining = used - first_length - 1; + uint8_t *second = first_end + 1; + uint8_t *second_end = memchr(second, '\0', remaining); + if (second_end == NULL || second_end == second) { + return fail_message("secret frame has no certificate password"); + } + size_t second_length = (size_t)(second_end - second); + if ((size_t)(second_end - buffer) + 1 != used) { + return fail_message("secret frame contains trailing data"); + } + + *keychain_password = buffer; + *keychain_password_length = first_length; + *certificate_password = second; + *certificate_password_length = second_length; + *frame_length = used; + return 0; +} + +struct pkcs12_buffer { + uint8_t *bytes; + size_t length; + CFDataRef data; +}; + +static void clear_pkcs12(struct pkcs12_buffer *buffer) { + if (buffer->data != NULL) { + CFRelease(buffer->data); + } + clear_bytes(buffer->bytes, buffer->length); + free(buffer->bytes); + buffer->bytes = NULL; + buffer->length = 0; + buffer->data = NULL; +} + +static int read_pkcs12(const char *path, struct pkcs12_buffer *buffer) { + int descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { + return fail_message("could not open the PKCS#12 input"); + } + + struct stat metadata; + if (fstat(descriptor, &metadata) != 0 || !S_ISREG(metadata.st_mode) || + metadata.st_size <= 0 || metadata.st_size > PKCS12_INPUT_MAX) { + close(descriptor); + return fail_message("PKCS#12 input is not a bounded regular file"); + } + + size_t length = (size_t)metadata.st_size; + uint8_t *bytes = malloc(length); + if (bytes == NULL) { + close(descriptor); + return fail_message("could not allocate PKCS#12 input storage"); + } + + size_t used = 0; + while (used < length) { + ssize_t count = read(descriptor, bytes + used, length - used); + if (count > 0) { + used += (size_t)count; + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + clear_bytes(bytes, length); + free(bytes); + close(descriptor); + return fail_message("could not read the complete PKCS#12 input"); + } + close(descriptor); + + CFDataRef data = CFDataCreateWithBytesNoCopy( + kCFAllocatorDefault, + bytes, + (CFIndex)length, + kCFAllocatorNull + ); + if (data == NULL) { + clear_bytes(bytes, length); + free(bytes); + return fail_message("could not create PKCS#12 input data"); + } + buffer->bytes = bytes; + buffer->length = length; + buffer->data = data; + return 0; +} + +static CFStringRef hex_string(CFDataRef data) { + static const char digits[] = "0123456789abcdef"; + CFIndex byte_count = CFDataGetLength(data); + if (byte_count < 0 || byte_count > (CFIndex)(SIZE_MAX / 2U)) { + return NULL; + } + size_t character_count = (size_t)byte_count * 2U; + char *characters = malloc(character_count + 1U); + if (characters == NULL) { + return NULL; + } + const UInt8 *bytes = CFDataGetBytePtr(data); + for (CFIndex index = 0; index < byte_count; ++index) { + characters[(size_t)index * 2U] = digits[bytes[index] >> 4U]; + characters[(size_t)index * 2U + 1U] = digits[bytes[index] & 0x0fU]; + } + characters[character_count] = '\0'; + CFStringRef result = CFStringCreateWithBytes( + kCFAllocatorDefault, + (const UInt8 *)characters, + (CFIndex)character_count, + kCFStringEncodingASCII, + false + ); + free(characters); + return result; +} + +static CFStringRef partition_description(void) { + const void *partition_values[] = { + CFSTR("apple-tool:"), + CFSTR("apple:") + }; + CFArrayRef partitions = CFArrayCreate( + kCFAllocatorDefault, + partition_values, + 2, + &kCFTypeArrayCallBacks + ); + if (partitions == NULL) { + return NULL; + } + const void *keys[] = {CFSTR("Partitions")}; + const void *values[] = {partitions}; + CFDictionaryRef property = CFDictionaryCreate( + kCFAllocatorDefault, + keys, + values, + 1, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + CFRelease(partitions); + if (property == NULL) { + return NULL; + } + CFErrorRef error = NULL; + CFDataRef xml = CFPropertyListCreateData( + kCFAllocatorDefault, + property, + kCFPropertyListXMLFormat_v1_0, + 0, + &error + ); + CFRelease(property); + if (error != NULL) { + CFRelease(error); + } + if (xml == NULL) { + return NULL; + } + CFStringRef result = hex_string(xml); + CFRelease(xml); + return result; +} + +static OSStatus apply_partition_list( + SecKeychainRef keychain, + const uint8_t *keychain_password, + size_t keychain_password_length +) { + const void *search_values[] = {keychain}; + CFArrayRef search_list = CFArrayCreate( + kCFAllocatorDefault, + search_values, + 1, + &kCFTypeArrayCallBacks + ); + if (search_list == NULL) { + return errSecAllocate; + } + + CFMutableDictionaryRef query = CFDictionaryCreateMutable( + kCFAllocatorDefault, + 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + if (query == NULL) { + CFRelease(search_list); + return errSecAllocate; + } + CFDictionarySetValue(query, kSecClass, kSecClassKey); + CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitAll); + CFDictionarySetValue(query, kSecReturnRef, kCFBooleanTrue); + CFDictionarySetValue(query, kSecAttrCanSign, kCFBooleanTrue); + CFDictionarySetValue(query, kSecMatchSearchList, search_list); + + CFTypeRef matches = NULL; + OSStatus status = SecItemCopyMatching(query, &matches); + CFRelease(query); + CFRelease(search_list); + if (status != errSecSuccess) { + return status; + } + if (matches == NULL || CFGetTypeID(matches) != CFArrayGetTypeID()) { + if (matches != NULL) { + CFRelease(matches); + } + return errSecItemNotFound; + } + + CFStringRef description = partition_description(); + if (description == NULL) { + CFRelease(matches); + return errSecAllocate; + } + + CFIndex key_count = CFArrayGetCount((CFArrayRef)matches); + for (CFIndex key_index = 0; key_index < key_count; ++key_index) { + SecKeychainItemRef item = (SecKeychainItemRef)CFArrayGetValueAtIndex( + (CFArrayRef)matches, + key_index + ); + SecAccessRef access = NULL; + status = SecKeychainItemCopyAccess(item, &access); + if (status != errSecSuccess) { + break; + } + CFArrayRef acl_list = NULL; + status = SecAccessCopyACLList(access, &acl_list); + if (status != errSecSuccess) { + CFRelease(access); + break; + } + + CFIndex acl_count = CFArrayGetCount(acl_list); + for (CFIndex acl_index = 0; acl_index < acl_count; ++acl_index) { + SecACLRef acl = (SecACLRef)CFArrayGetValueAtIndex(acl_list, acl_index); + CSSM_ACL_AUTHORIZATION_TAG tags[64]; + uint32_t tag_count = (uint32_t)(sizeof(tags) / sizeof(tags[0])); + status = SecACLGetAuthorizations(acl, tags, &tag_count); + if (status != errSecSuccess) { + break; + } + for (uint32_t tag_index = 0; tag_index < tag_count; ++tag_index) { + if (tags[tag_index] != CSSM_ACL_AUTHORIZATION_PARTITION_ID) { + continue; + } + CFArrayRef applications = NULL; + CFStringRef prompt = NULL; + CSSM_ACL_KEYCHAIN_PROMPT_SELECTOR selector = {0}; + status = SecACLCopySimpleContents( + acl, + &applications, + &prompt, + &selector + ); + if (status == errSecSuccess) { + status = SecACLSetSimpleContents( + acl, + applications, + description, + &selector + ); + } + if (applications != NULL) { + CFRelease(applications); + } + if (prompt != NULL) { + CFRelease(prompt); + } + if (status != errSecSuccess) { + break; + } + } + if (status != errSecSuccess) { + break; + } + } + CFRelease(acl_list); + if (status == errSecSuccess) { + status = SecKeychainItemSetAccessWithPassword( + item, + access, + (UInt32)keychain_password_length, + keychain_password + ); + } + CFRelease(access); + if (status != errSecSuccess) { + break; + } + } + + CFRelease(description); + CFRelease(matches); + return status; +} + +static SecAccessRef signing_access(void) { + SecTrustedApplicationRef codesign = NULL; + SecTrustedApplicationRef security = NULL; + OSStatus status = SecTrustedApplicationCreateFromPath("/usr/bin/codesign", &codesign); + if (status == errSecSuccess) { + status = SecTrustedApplicationCreateFromPath("/usr/bin/security", &security); + } + if (status != errSecSuccess) { + if (codesign != NULL) { + CFRelease(codesign); + } + if (security != NULL) { + CFRelease(security); + } + fail_status("create trusted signing applications", status); + return NULL; + } + + const void *values[] = {codesign, security}; + CFArrayRef applications = CFArrayCreate( + kCFAllocatorDefault, + values, + 2, + &kCFTypeArrayCallBacks + ); + CFRelease(codesign); + CFRelease(security); + if (applications == NULL) { + fail_message("could not allocate the trusted signing application list"); + return NULL; + } + SecAccessRef access = NULL; + status = SecAccessCreate(CFSTR("Hypercolor signing key"), applications, &access); + CFRelease(applications); + if (status != errSecSuccess) { + fail_status("create signing key access", status); + return NULL; + } + return access; +} + +int main(int argc, char **argv) { + if (argc != 3) { + return fail_message("usage: macos-signing-keychain "); + } + + uint8_t secret_frame[SECRET_INPUT_MAX]; + uint8_t *keychain_password = NULL; + uint8_t *certificate_password = NULL; + size_t keychain_password_length = 0; + size_t certificate_password_length = 0; + size_t frame_length = 0; + int result = read_secret_frame( + secret_frame, + sizeof(secret_frame), + &keychain_password, + &keychain_password_length, + &certificate_password, + &certificate_password_length, + &frame_length + ); + if (result != 0) { + clear_bytes(secret_frame, sizeof(secret_frame)); + return result; + } + if (keychain_password_length > UINT32_MAX) { + clear_bytes(secret_frame, frame_length); + return fail_message("keychain password is too large"); + } + + SecKeychainRef keychain = NULL; + OSStatus status = SecKeychainCreate( + argv[1], + (UInt32)keychain_password_length, + keychain_password, + false, + NULL, + &keychain + ); + if (status != errSecSuccess) { + clear_bytes(secret_frame, frame_length); + return fail_status("create keychain", status); + } + + SecKeychainSettings settings = { + .version = SEC_KEYCHAIN_SETTINGS_VERS1, + .lockOnSleep = true, + .useLockInterval = true, + .lockInterval = 21600 + }; + status = SecKeychainSetSettings(keychain, &settings); + if (status == errSecSuccess) { + status = SecKeychainUnlock( + keychain, + (UInt32)keychain_password_length, + keychain_password, + true + ); + } + if (status != errSecSuccess) { + CFRelease(keychain); + clear_bytes(secret_frame, frame_length); + return fail_status("configure keychain", status); + } + + struct pkcs12_buffer pkcs12 = {0}; + if (read_pkcs12(argv[2], &pkcs12) != 0) { + CFRelease(keychain); + clear_bytes(secret_frame, frame_length); + return 1; + } + CFStringRef passphrase = CFStringCreateWithBytesNoCopy( + kCFAllocatorDefault, + certificate_password, + (CFIndex)certificate_password_length, + kCFStringEncodingUTF8, + false, + kCFAllocatorNull + ); + if (passphrase == NULL) { + clear_pkcs12(&pkcs12); + CFRelease(keychain); + clear_bytes(secret_frame, frame_length); + return fail_message("certificate password is not valid UTF-8"); + } + SecAccessRef access = signing_access(); + if (access == NULL) { + CFRelease(passphrase); + clear_pkcs12(&pkcs12); + CFRelease(keychain); + clear_bytes(secret_frame, frame_length); + return 1; + } + + SecItemImportExportKeyParameters parameters = { + .version = SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION, + .flags = 0, + .passphrase = passphrase, + .alertTitle = NULL, + .alertPrompt = NULL, + .accessRef = access, + .keyUsage = NULL, + .keyAttributes = NULL + }; + SecExternalFormat format = kSecFormatPKCS12; + SecExternalItemType item_type = kSecItemTypeAggregate; + CFArrayRef imported_items = NULL; + status = SecItemImport( + pkcs12.data, + CFSTR("certificate.p12"), + &format, + &item_type, + 0, + ¶meters, + keychain, + &imported_items + ); + CFRelease(access); + CFRelease(passphrase); + clear_pkcs12(&pkcs12); + if (imported_items != NULL) { + CFRelease(imported_items); + } + if (status == errSecSuccess) { + status = apply_partition_list( + keychain, + keychain_password, + keychain_password_length + ); + } + CFRelease(keychain); + clear_bytes(secret_frame, frame_length); + if (status != errSecSuccess) { + return fail_status("import and authorize signing identity", status); + } + return 0; +} diff --git a/scripts/run-macos-tcc-canary-row.sh b/scripts/run-macos-tcc-canary-row.sh new file mode 100755 index 000000000..80d8bd93a --- /dev/null +++ b/scripts/run-macos-tcc-canary-row.sh @@ -0,0 +1,782 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +die() { + printf 'macOS TCC canary failed: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/run-macos-tcc-canary-row.sh [options] + +Required: + --request PATH Validated row request JSON + --daemon PATH Signed daemon built with macos-tcc-canary + --witness-dir PATH Manual witness JSON and evidence directory + --topology NAME app-sidecar, direct-launchd, homebrew, standalone + --execute-protected-actions Allow TCC requests, picker UI, and launcher mutation + +Topology-specific: + --app PATH Hypercolor app executable for app-sidecar + --cli PATH hypercolor CLI executable for direct-launchd + --brew PATH brew executable for homebrew + +Optional: + --timeout-seconds N Receipt deadline with 30s operation headroom + -h, --help Print this help + +The driver uses the production launcher for one signed acceptance row. It may +request TCC access, present Apple's picker, restart the selected service, or +relaunch Hypercolor. It never resets TCC. Fresh-database, prompt, System +Settings, and process-replacement observations must be supplied as separately +hashed witness artifacts beside the receipt. +EOF +} + +request="" +daemon="" +data_dir="${HOME:?HOME must be set}/Library/Application Support/hypercolor" +witness_dir="" +topology="" +app="" +cli="" +brew="" +timeout_seconds="" +execute=false +armed_request="" +temporary_files=() +installed_row_artifacts=() +row_committed=false + +cleanup_canary_artifacts() { + if [[ -n "${armed_request}" && -f "${armed_request}" ]]; then + /bin/rm -f -- "${armed_request}" + fi + for temporary_file in "${temporary_files[@]-}"; do + if [[ -n "${temporary_file}" && -f "${temporary_file}" ]]; then + /bin/rm -f -- "${temporary_file}" + fi + done + if [[ "${row_committed}" != true ]]; then + for installed_artifact in "${installed_row_artifacts[@]-}"; do + if [[ -n "${installed_artifact}" && -f "${installed_artifact}" ]]; then + /bin/rm -f -- "${installed_artifact}" + fi + done + fi +} + +trap cleanup_canary_artifacts EXIT + +pid_is_alive() { + kill -0 "$1" 2>/dev/null +} + +process_fingerprint() { + local pid="$1" + local identity + identity="$(/bin/ps -p "${pid}" -o lstart= -o command= | awk '{$1=$1; print}')" \ + || return 1 + [[ -n "${identity}" ]] || return 1 + printf '%s' "${identity}" | /usr/bin/shasum -a 256 | awk '{print $1}' +} + +wait_for_pid_exit() { + local pid="$1" + local expected_fingerprint="$2" + local timeout="${3:-10}" + local deadline=$((SECONDS + timeout)) + local current_fingerprint + while pid_is_alive "${pid}" && ((SECONDS < deadline)); do + current_fingerprint="$(process_fingerprint "${pid}")" || return 0 + [[ "${current_fingerprint}" == "${expected_fingerprint}" ]] || return 0 + sleep 1 + done + if pid_is_alive "${pid}"; then + current_fingerprint="$(process_fingerprint "${pid}")" || return 0 + [[ "${current_fingerprint}" != "${expected_fingerprint}" ]] \ + || die "predecessor process ${pid} did not exit within ${timeout} seconds" + fi +} + +identifier_is_safe() { + [[ "$1" =~ ^[A-Za-z0-9_.-]{1,128}$ && "$1" != "." && "$1" != ".." ]] +} + +install_new_artifact() { + local source="$1" + local destination="$2" + "${daemon}" --macos-tcc-canary-publish \ + "${data_dir}/macos-tcc-canary" "${source}" "${destination}" >/dev/null \ + || die "artifact publication failed: ${destination}" + installed_row_artifacts+=("${destination}") +} + +require_real_path_ancestors() { + local path="$1" + [[ "${path}" == /* ]] || die "path must be absolute: ${path}" + local relative="${path#/}" + local current="" + local component + IFS='/' read -r -a components <<<"${relative}" + for component in "${components[@]-}"; do + [[ -z "${component}" ]] && continue + [[ "${component}" != "." && "${component}" != ".." ]] \ + || die "path contains traversal: ${path}" + current="${current}/${component}" + [[ ! -L "${current}" ]] || die "path has a symlink ancestor: ${current}" + [[ -e "${current}" ]] || die "path component does not exist: ${current}" + done +} + +ensure_real_directory() { + local directory="$1" + if [[ -e "${directory}" || -L "${directory}" ]]; then + [[ -d "${directory}" && ! -L "${directory}" ]] \ + || die "directory must be real and not a symlink: ${directory}" + else + /bin/mkdir "${directory}" || die "failed to create directory: ${directory}" + fi +} + +ensure_descendant_directory() { + local root="$1" + local directory="$2" + ensure_real_directory "${root}" + [[ "${directory}" == "${root}" || "${directory}" == "${root}/"* ]] \ + || die "directory escapes canary root: ${directory}" + local relative="${directory#"${root}"}" + relative="${relative#/}" + local current="${root}" + local component + IFS='/' read -r -a components <<<"${relative}" + for component in "${components[@]-}"; do + [[ -z "${component}" ]] && continue + identifier_is_safe "${component}" \ + || die "unsafe canary directory component: ${component}" + current="${current}/${component}" + ensure_real_directory "${current}" + done +} + +while (($# > 0)); do + case "$1" in + --request) + (($# >= 2)) || die '--request requires a path' + request="$2" + shift 2 + ;; + --daemon) + (($# >= 2)) || die '--daemon requires a path' + daemon="$2" + shift 2 + ;; + --witness-dir) + (($# >= 2)) || die '--witness-dir requires a path' + witness_dir="$2" + shift 2 + ;; + --topology) + (($# >= 2)) || die '--topology requires a value' + topology="$2" + shift 2 + ;; + --app) + (($# >= 2)) || die '--app requires a path' + app="$2" + shift 2 + ;; + --cli) + (($# >= 2)) || die '--cli requires a path' + cli="$2" + shift 2 + ;; + --brew) + (($# >= 2)) || die '--brew requires a path' + brew="$2" + shift 2 + ;; + --timeout-seconds) + (($# >= 2)) || die '--timeout-seconds requires a value' + timeout_seconds="$2" + shift 2 + ;; + --execute-protected-actions) + execute=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +require_real_path_ancestors "${request}" +require_real_path_ancestors "${witness_dir}" +require_real_path_ancestors "${witness_dir}/evidence" +require_real_path_ancestors "${data_dir}" +[[ -f "${request}" && ! -L "${request}" ]] \ + || die '--request must name a regular non-symlink file' +request_bytes="$(/usr/bin/stat -f '%z' "${request}")" +[[ "${request_bytes}" =~ ^[0-9]+$ ]] || die 'request size is invalid' +((request_bytes <= 65536)) || die 'request exceeds 65536 bytes' +request_snapshot="$(mktemp -t hypercolor-tcc-request.XXXXXX)" +temporary_files+=("${request_snapshot}") +/bin/cp "${request}" "${request_snapshot}" +chmod 600 "${request_snapshot}" +[[ "$(/usr/bin/stat -f '%z' "${request_snapshot}")" == "${request_bytes}" ]] \ + || die 'request changed during snapshot' +[[ -x "${daemon}" ]] || die '--daemon must name an executable file' +[[ -d "${witness_dir}" && ! -L "${witness_dir}" ]] \ + || die '--witness-dir must name a real non-symlink directory' +[[ -d "${witness_dir}/evidence" && ! -L "${witness_dir}/evidence" ]] \ + || die '--witness-dir/evidence must be a real non-symlink directory' +command -v jq >/dev/null 2>&1 || die 'jq is required' + +case "${topology}" in + app-sidecar) + [[ -x "${app}" ]] || die 'app-sidecar requires --app executable' + expected_topology='app_sidecar' + ;; + direct-launchd) + [[ -x "${cli}" ]] || die 'direct-launchd requires --cli executable' + expected_topology='direct_launchd' + ;; + homebrew) + [[ -x "${brew}" ]] || die 'homebrew requires --brew executable' + expected_topology='homebrew' + ;; + standalone) + expected_topology='standalone' + ;; + *) + die '--topology must be app-sidecar, direct-launchd, homebrew, or standalone' + ;; +esac + +request_topology="$(jq -er '.expected_topology' "${request_snapshot}")" \ + || die 'request is missing expected_topology' +[[ "${request_topology}" == "${expected_topology}" ]] \ + || die "request topology ${request_topology} does not match ${expected_topology}" +run_id="$(jq -er '.run_id' "${request_snapshot}")" || die 'request is missing run_id' +row_id="$(jq -er '.row_id' "${request_snapshot}")" || die 'request is missing row_id' +lifecycle_phase="$(jq -er '.lifecycle_phase' "${request_snapshot}")" \ + || die 'request is missing lifecycle_phase' +predecessor_row_id="$(jq -r '.predecessor_row_id // ""' "${request_snapshot}")" +replacement_witness_id="$(jq -r '.process_replacement_witness_id // ""' "${request_snapshot}")" +lifecycle_witness_id="$(jq -r '.lifecycle_action_witness_id // ""' "${request_snapshot}")" +login_witness_id="$(jq -r '.login_arbitration_witness_id // ""' "${request_snapshot}")" +settings_witness_id="$(jq -er '.system_settings_identity_witness_id' "${request_snapshot}")" \ + || die 'request is missing system_settings_identity_witness_id' +expected_prompt_text="$(jq -er '.expected_prompt_text' "${request_snapshot}")" \ + || die 'request is missing expected_prompt_text' +expected_system_settings_entry="$(jq -er '.expected_system_settings_entry' "${request_snapshot}")" \ + || die 'request is missing expected_system_settings_entry' +fresh_witness_id="$(jq -r '.fresh_tcc_reset_witness_id // ""' "${request_snapshot}")" +operation_timeout_ms="$(jq -er '.operation_timeout_ms' "${request_snapshot}")" \ + || die 'request is missing operation_timeout_ms' +identifier_is_safe "${run_id}" || die 'request run_id is invalid' +identifier_is_safe "${row_id}" || die 'request row_id is invalid' +identifier_is_safe "${settings_witness_id}" \ + || die 'request system_settings_identity_witness_id is invalid' +if [[ -n "${fresh_witness_id}" ]]; then + identifier_is_safe "${fresh_witness_id}" \ + || die 'request fresh_tcc_reset_witness_id is invalid' +fi +if [[ -n "${predecessor_row_id}" ]]; then + identifier_is_safe "${predecessor_row_id}" \ + || die 'request predecessor_row_id is invalid' + if [[ -n "${replacement_witness_id}" ]]; then + identifier_is_safe "${replacement_witness_id}" \ + || die 'request process_replacement_witness_id is invalid' + fi +elif [[ -n "${replacement_witness_id}" ]]; then + die 'process_replacement_witness_id requires predecessor_row_id' +fi +if [[ -n "${login_witness_id}" ]]; then + identifier_is_safe "${login_witness_id}" \ + || die 'request login_arbitration_witness_id is invalid' +fi +if [[ -n "${lifecycle_witness_id}" ]]; then + identifier_is_safe "${lifecycle_witness_id}" \ + || die 'request lifecycle_action_witness_id is invalid' +fi +[[ "${operation_timeout_ms}" =~ ^[0-9]+$ ]] \ + || die 'request operation_timeout_ms is invalid' +minimum_timeout_seconds=$(( (operation_timeout_ms + 999) / 1000 + 30 )) +if [[ -z "${timeout_seconds}" ]]; then + timeout_seconds="${minimum_timeout_seconds}" +fi +[[ "${timeout_seconds}" =~ ^[0-9]+$ ]] || die '--timeout-seconds must be an integer' +((timeout_seconds >= minimum_timeout_seconds && timeout_seconds <= 660)) \ + || die "--timeout-seconds must be ${minimum_timeout_seconds} through 660 for this row" + +request_canonical="$(jq -cS . "${request_snapshot}")" || die 'request is not valid JSON' +request_sha256="$(/usr/bin/shasum -a 256 "${request_snapshot}" | awk '{print $1}')" +"${daemon}" --macos-tcc-canary-check-request "${request_snapshot}" >/dev/null +[[ "$(/usr/bin/shasum -a 256 "${request_snapshot}" | awk '{print $1}')" == "${request_sha256}" ]] \ + || die 'request snapshot changed during validation' + +receipt_dir="${data_dir}/macos-tcc-canary/receipts/${run_id}" +receipt="${receipt_dir}/${row_id}.receipt.json" +pending_receipt="${receipt_dir}/${row_id}.receipt.pending" +[[ ! -e "${receipt}" ]] || die "receipt already exists: ${receipt}" +[[ ! -e "${pending_receipt}" ]] || die "pending receipt already exists: ${pending_receipt}" + +if [[ "${execute}" != true ]]; then + die 'refusing protected actions without --execute-protected-actions' +fi + +arm_output="$("${daemon}" --macos-tcc-canary-arm "${request_snapshot}")" +armed_request="${arm_output#macos_tcc_canary_armed=}" +[[ "${armed_request}" == "${data_dir}/macos-tcc-canary/request.json" ]] \ + || die 'daemon armed the request outside its canonical data directory' +[[ -f "${armed_request}" && ! -L "${armed_request}" ]] \ + || die 'daemon did not create a regular armed request' +armed_canonical="$(jq -cS . "${armed_request}")" \ + || die 'armed request is not valid JSON' +[[ "${armed_canonical}" == "${request_canonical}" ]] \ + || die 'armed request does not exactly match the validated row' + +install_witness() { + local witness_id="$1" + local expected_kind="$2" + local source_witness="${witness_dir}/${witness_id}.witness.json" + [[ -f "${source_witness}" && ! -L "${source_witness}" ]] \ + || die "missing regular non-symlink witness: ${source_witness}" + local source_witness_bytes + source_witness_bytes="$(/usr/bin/stat -f '%z' "${source_witness}")" + [[ "${source_witness_bytes}" =~ ^[0-9]+$ ]] \ + || die "witness size is invalid: ${source_witness}" + ((source_witness_bytes <= 65536)) \ + || die "witness exceeds 65536 bytes: ${source_witness}" + ensure_descendant_directory "${data_dir}/macos-tcc-canary" "${receipt_dir}/evidence" + local witness_temp + witness_temp="$(mktemp "${receipt_dir}/.witness.XXXXXX")" + temporary_files+=("${witness_temp}") + /bin/cp "${source_witness}" "${witness_temp}" + chmod 600 "${witness_temp}" + local evidence_sha256 + evidence_sha256="$(jq -er \ + --arg run_id "${run_id}" \ + --arg row_id "${row_id}" \ + --arg witness_id "${witness_id}" \ + --arg kind "${expected_kind}" \ + 'select( + .schema_version == 2 + and .run_id == $run_id + and .row_id == $row_id + and .witness_id == $witness_id + and .kind == $kind + ) | .evidence_sha256' \ + "${witness_temp}")" || die "invalid witness: ${source_witness}" + if [[ "${expected_kind}" == system_settings_identity ]]; then + jq -e \ + --arg topology "${expected_topology}" \ + --arg prompt_text "${expected_prompt_text}" \ + --arg system_settings_entry "${expected_system_settings_entry}" \ + 'select( + .prompt_text == $prompt_text + and .system_settings_entry == $system_settings_entry + and .observed_audit_token_identity == .observed_signing_audit_token_identity + and (.observed_designated_requirement_sha256 | test("^[0-9a-f]{64}$")) + and (if $topology == "app_sidecar" then + .parent_audit_token_identity == .parent_signing_audit_token_identity + and (.parent_designated_requirement_sha256 | test("^[0-9a-f]{64}$")) + else + .parent_pid == null + and .parent_audit_token_identity == null + and .parent_signing_audit_token_identity == null + and .parent_designated_requirement_sha256 == null + end) + )' "${witness_temp}" >/dev/null \ + || die "system settings witness lacks audit-token-bound signing evidence: ${source_witness}" + fi + [[ "${evidence_sha256}" =~ ^[0-9a-f]{64}$ ]] \ + || die "witness evidence hash is invalid: ${source_witness}" + local source_evidence="${witness_dir}/evidence/${evidence_sha256}.bin" + [[ -f "${source_evidence}" && ! -L "${source_evidence}" ]] \ + || die "missing regular witness evidence: ${source_evidence}" + local source_evidence_bytes + source_evidence_bytes="$(/usr/bin/stat -f '%z' "${source_evidence}")" + [[ "${source_evidence_bytes}" =~ ^[0-9]+$ ]] \ + || die "witness evidence size is invalid: ${source_evidence}" + ((source_evidence_bytes <= 16777216)) \ + || die "witness evidence exceeds 16777216 bytes: ${source_evidence}" + local destination_witness="${receipt_dir}/${witness_id}.witness.json" + local destination_evidence="${receipt_dir}/evidence/${evidence_sha256}.bin" + [[ ! -e "${destination_witness}" ]] \ + || die "witness already exists: ${destination_witness}" + if [[ ! -e "${destination_evidence}" ]]; then + local evidence_temp + evidence_temp="$(mktemp "${receipt_dir}/evidence/.witness-evidence.XXXXXX")" + temporary_files+=("${evidence_temp}") + /bin/cp "${source_evidence}" "${evidence_temp}" + chmod 600 "${evidence_temp}" + [[ "$(/usr/bin/shasum -a 256 "${evidence_temp}" | awk '{print $1}')" == "${evidence_sha256}" ]] \ + || die "witness evidence hash mismatch: ${source_evidence}" + install_new_artifact "${evidence_temp}" "${destination_evidence}" + else + [[ "$(/usr/bin/shasum -a 256 "${destination_evidence}" | awk '{print $1}')" == "${evidence_sha256}" ]] \ + || die "installed witness evidence hash mismatch: ${destination_evidence}" + fi + install_new_artifact "${witness_temp}" "${destination_witness}" +} + +ensure_descendant_directory "${data_dir}/macos-tcc-canary" "${receipt_dir}/evidence" +if [[ -n "${fresh_witness_id}" ]]; then + install_witness "${fresh_witness_id}" fresh_tcc_reset +fi +if [[ -n "${login_witness_id}" ]]; then + install_witness "${login_witness_id}" login_arbitration +fi + +predecessor_pid="" +predecessor_fingerprint="" +predecessor_audit_token_identity="" +predecessor_finished_unix_ms="" +predecessor_was_live=false +predecessor_parent_pid="" +predecessor_parent_audit_token_identity="" +predecessor_parent_fingerprint="" +predecessor_parent_was_live=false +launcher_action="" +replacement_required=false +case "${lifecycle_phase}" in + later_grant|grant_after_revocation|owner_restart|app_relaunch|service_restart|signed_update) + replacement_required=true + ;; +esac +if [[ -n "${predecessor_row_id}" ]]; then + if [[ "${replacement_required}" == true ]]; then + [[ -n "${replacement_witness_id}" ]] \ + || die 'replacement phase requires process_replacement_witness_id' + elif [[ -n "${replacement_witness_id}" ]]; then + die 'non-replacement phase cannot name process_replacement_witness_id' + fi + predecessor_receipt="${receipt_dir}/${predecessor_row_id}.receipt.json" + [[ -f "${predecessor_receipt}" && ! -L "${predecessor_receipt}" ]] \ + || die "predecessor receipt must be a regular non-symlink file: ${predecessor_receipt}" + predecessor_bytes="$(/usr/bin/stat -f '%z' "${predecessor_receipt}")" + [[ "${predecessor_bytes}" =~ ^[0-9]+$ ]] \ + || die 'predecessor receipt size is invalid' + ((predecessor_bytes <= 131072)) || die 'predecessor receipt exceeds 131072 bytes' + predecessor_snapshot="$(mktemp "${receipt_dir}/.predecessor.XXXXXX")" + temporary_files+=("${predecessor_snapshot}") + /bin/cp "${predecessor_receipt}" "${predecessor_snapshot}" + chmod 600 "${predecessor_snapshot}" + [[ "$(/usr/bin/stat -f '%z' "${predecessor_snapshot}")" == "${predecessor_bytes}" ]] \ + || die 'predecessor receipt changed during snapshot' + predecessor_identity="$(jq -er \ + --arg run_id "${run_id}" \ + --arg topology "${expected_topology}" \ + 'select(.schema_version == 2 and .run_id == $run_id and .topology == $topology) \ + | [.pid, .process_fingerprint, .audit_token_identity, .operation_finished_unix_ms] \ + | @tsv' \ + "${predecessor_snapshot}")" \ + || die 'predecessor receipt does not match this run and topology' + IFS=$'\t' read -r predecessor_pid predecessor_fingerprint \ + predecessor_audit_token_identity predecessor_finished_unix_ms <<<"${predecessor_identity}" + [[ "${predecessor_pid}" =~ ^[0-9]+$ ]] || die 'predecessor pid is invalid' + [[ "${predecessor_fingerprint}" =~ ^[0-9a-f]{64}$ ]] \ + || die 'predecessor process fingerprint is invalid' + [[ "${predecessor_audit_token_identity}" =~ ^([0-9a-fA-F]{8}:){7}[0-9a-fA-F]{8}$ ]] \ + || die 'predecessor audit token identity is invalid' + [[ "${predecessor_finished_unix_ms}" =~ ^[0-9]+$ ]] \ + || die 'predecessor completion time is invalid' + if pid_is_alive "${predecessor_pid}"; then + current_predecessor_fingerprint="$(process_fingerprint "${predecessor_pid}")" \ + || die 'live predecessor process identity is unavailable' + [[ "${current_predecessor_fingerprint}" == "${predecessor_fingerprint}" ]] \ + || die 'predecessor pid was reused by a different process' + predecessor_was_live=true + fi + if [[ "${topology}:${lifecycle_phase}" == app-sidecar:app_relaunch ]]; then + predecessor_parent_identity="$(jq -er \ + '[.launcher.parent_pid, .launcher.parent_signing.process_bound_fingerprint, \ + .system_settings_identity_witness_id] | @tsv' \ + "${predecessor_snapshot}")" \ + || die 'app predecessor parent identity is missing' + IFS=$'\t' read -r predecessor_parent_pid predecessor_parent_fingerprint \ + predecessor_settings_witness_id <<<"${predecessor_parent_identity}" + [[ "${predecessor_parent_pid}" =~ ^[0-9]+$ ]] \ + || die 'app predecessor parent pid is invalid' + [[ "${predecessor_parent_fingerprint}" =~ ^[0-9a-f]{64}$ ]] \ + || die 'app predecessor parent fingerprint is invalid' + identifier_is_safe "${predecessor_settings_witness_id}" \ + || die 'app predecessor settings witness id is invalid' + predecessor_settings_witness="${receipt_dir}/${predecessor_settings_witness_id}.witness.json" + [[ -f "${predecessor_settings_witness}" && ! -L "${predecessor_settings_witness}" ]] \ + || die 'app predecessor settings witness is missing' + predecessor_parent_audit_token_identity="$(jq -er \ + --arg run_id "${run_id}" \ + --arg row_id "${predecessor_row_id}" \ + 'select(.schema_version == 2 and .run_id == $run_id and .row_id == $row_id \ + and .kind == "system_settings_identity") | .parent_audit_token_identity' \ + "${predecessor_settings_witness}")" \ + || die 'app predecessor parent audit token is missing' + [[ "${predecessor_parent_audit_token_identity}" =~ ^([0-9a-fA-F]{8}:){7}[0-9a-fA-F]{8}$ ]] \ + || die 'app predecessor parent audit token is invalid' + pid_is_alive "${predecessor_parent_pid}" \ + || die 'app predecessor parent is no longer running' + current_parent_fingerprint="$(process_fingerprint "${predecessor_parent_pid}")" \ + || die 'live app predecessor parent identity is unavailable' + [[ "${current_parent_fingerprint}" == "${predecessor_parent_fingerprint}" ]] \ + || die 'app predecessor parent pid was reused by a different process' + predecessor_parent_was_live=true + fi +fi + +case "${topology}" in + app-sidecar) + case "${lifecycle_phase}" in + owner_restart) + die 'owner_restart requires launcher action app_supervisor_daemon_restart' + ;; + later_grant|grant_after_revocation) + die "${lifecycle_phase} requires launcher action app_supervisor_daemon_restart_after_authorization" + ;; + signed_update) + die 'signed_update requires launcher action signed_app_update_then_app_relaunch' + ;; + app_relaunch) + launcher_action='app_quit_then_minimized_launch' + ;; + app_launch) + launcher_action='app_minimized_launch' + ;; + esac + ;; + direct-launchd) + case "${lifecycle_phase}" in + service_install) launcher_action='hypercolor_service_enable' ;; + login_start) die 'login_start requires launcher action launchd_login_start' ;; + service_restart) launcher_action='hypercolor_service_restart' ;; + later_grant|grant_after_revocation) + launcher_action='hypercolor_service_restart_after_authorization' + ;; + signed_update) + die 'signed_update requires launcher action signed_daemon_update_then_hypercolor_service_restart' + ;; + *) launcher_action='hypercolor_service_restart' ;; + esac + ;; + homebrew) + case "${lifecycle_phase}" in + service_install) launcher_action='brew_services_start' ;; + login_start) die 'login_start requires launcher action brew_services_login_start' ;; + service_restart) launcher_action='brew_services_restart' ;; + later_grant|grant_after_revocation) + launcher_action='brew_services_restart_after_authorization' + ;; + signed_update) + die 'signed_update requires launcher action signed_daemon_update_then_brew_services_restart' + ;; + *) launcher_action='brew_services_restart' ;; + esac + ;; + standalone) + case "${lifecycle_phase}" in + later_grant|grant_after_revocation) + launcher_action='terminal_successor_launch_after_authorization' + ;; + signed_update) + die 'signed_update requires launcher action signed_daemon_update_then_terminal_launch' + ;; + *) launcher_action='terminal_launch' ;; + esac + ;; +esac + +if [[ "${replacement_required}" == true ]]; then + case "${topology}" in + app-sidecar) + "${app}" --quit >/dev/null 2>&1 + ;; + direct-launchd) + "${cli}" service stop + ;; + homebrew) + "${brew}" services stop hypercolor + ;; + standalone) + : + ;; + esac + if [[ "${predecessor_was_live}" == true ]]; then + wait_for_pid_exit "${predecessor_pid}" "${predecessor_fingerprint}" 60 + fi + if [[ "${predecessor_parent_was_live}" == true ]]; then + wait_for_pid_exit "${predecessor_parent_pid}" "${predecessor_parent_fingerprint}" 60 + fi +fi + +action_observed_unix_ms=$(( $(date +%s) * 1000 )) +while [[ -n "${predecessor_finished_unix_ms}" ]] \ + && ((action_observed_unix_ms < predecessor_finished_unix_ms)); do + sleep 1 + action_observed_unix_ms=$(( $(date +%s) * 1000 )) +done + +if [[ "${replacement_required}" == true ]]; then + evidence_temp="$(mktemp "${receipt_dir}/evidence/.replacement.XXXXXX")" + printf 'run=%s\nrow=%s\npredecessor=%s\npid=%s\naudit_token=%s\nfingerprint=%s\nparent_pid=%s\nparent_audit_token=%s\nparent_fingerprint=%s\ntopology=%s\naction=%s\nexit_observed=true\n' \ + "${run_id}" "${row_id}" "${predecessor_row_id}" "${predecessor_pid}" \ + "${predecessor_audit_token_identity}" "${predecessor_fingerprint}" \ + "${predecessor_parent_pid}" "${predecessor_parent_audit_token_identity}" \ + "${predecessor_parent_fingerprint}" \ + "${expected_topology}" "${launcher_action}" >"${evidence_temp}" + evidence_sha256="$(/usr/bin/shasum -a 256 "${evidence_temp}" | awk '{print $1}')" + evidence_path="${receipt_dir}/evidence/${evidence_sha256}.bin" + [[ ! -e "${evidence_path}" ]] || die "evidence already exists: ${evidence_path}" + install_new_artifact "${evidence_temp}" "${evidence_path}" + witness_path="${receipt_dir}/${replacement_witness_id}.witness.json" + [[ ! -e "${witness_path}" ]] || die "witness already exists: ${witness_path}" + witness_temp="$(mktemp "${receipt_dir}/.replacement-witness.XXXXXX")" + temporary_files+=("${witness_temp}") + jq -n \ + --arg run_id "${run_id}" \ + --arg row_id "${row_id}" \ + --arg witness_id "${replacement_witness_id}" \ + --arg evidence_sha256 "${evidence_sha256}" \ + --arg launcher_action "${launcher_action}" \ + --arg predecessor_audit_token_identity "${predecessor_audit_token_identity}" \ + --arg predecessor_process_fingerprint "${predecessor_fingerprint}" \ + --arg predecessor_parent_audit_token_identity "${predecessor_parent_audit_token_identity}" \ + --arg predecessor_parent_process_fingerprint "${predecessor_parent_fingerprint}" \ + --argjson observed_unix_ms "${action_observed_unix_ms}" \ + --argjson predecessor_pid "${predecessor_pid}" \ + --arg predecessor_parent_pid "${predecessor_parent_pid}" \ + '{ + schema_version: 2, + run_id: $run_id, + row_id: $row_id, + witness_id: $witness_id, + kind: "process_replacement", + observer: "run-macos-tcc-canary-row.sh", + observed_unix_ms: $observed_unix_ms, + evidence_sha256: $evidence_sha256, + prompt_text: null, + system_settings_entry: null, + fresh_tcc_database_observed: null, + predecessor_pid: $predecessor_pid, + predecessor_audit_token_identity: $predecessor_audit_token_identity, + predecessor_process_fingerprint: $predecessor_process_fingerprint, + predecessor_exit_observed: true, + predecessor_parent_pid: (if $predecessor_parent_pid == "" then null else ($predecessor_parent_pid | tonumber) end), + predecessor_parent_audit_token_identity: (if $predecessor_parent_audit_token_identity == "" then null else $predecessor_parent_audit_token_identity end), + predecessor_parent_process_fingerprint: (if $predecessor_parent_process_fingerprint == "" then null else $predecessor_parent_process_fingerprint end), + predecessor_parent_exit_observed: (if $predecessor_parent_pid == "" then null else true end), + launcher_action: $launcher_action + }' >"${witness_temp}" + chmod 600 "${witness_temp}" + install_new_artifact "${witness_temp}" "${witness_path}" +fi + +if [[ -n "${lifecycle_witness_id}" ]]; then + [[ "${replacement_required}" == false ]] \ + || die 'replacement rows cannot use lifecycle_action_witness_id' + action_evidence_temp="$(mktemp "${receipt_dir}/evidence/.lifecycle.XXXXXX")" + printf 'run=%s\nrow=%s\ntopology=%s\naction=%s\n' \ + "${run_id}" "${row_id}" "${expected_topology}" "${launcher_action}" \ + >"${action_evidence_temp}" + action_evidence_sha256="$(/usr/bin/shasum -a 256 "${action_evidence_temp}" | awk '{print $1}')" + action_evidence_path="${receipt_dir}/evidence/${action_evidence_sha256}.bin" + [[ ! -e "${action_evidence_path}" ]] \ + || die "evidence already exists: ${action_evidence_path}" + install_new_artifact "${action_evidence_temp}" "${action_evidence_path}" + lifecycle_witness_path="${receipt_dir}/${lifecycle_witness_id}.witness.json" + lifecycle_witness_temp="$(mktemp "${receipt_dir}/.lifecycle-witness.XXXXXX")" + temporary_files+=("${lifecycle_witness_temp}") + jq -n \ + --arg run_id "${run_id}" \ + --arg row_id "${row_id}" \ + --arg witness_id "${lifecycle_witness_id}" \ + --arg evidence_sha256 "${action_evidence_sha256}" \ + --arg launcher_action "${launcher_action}" \ + --argjson observed_unix_ms "${action_observed_unix_ms}" \ + '{ + schema_version: 2, + run_id: $run_id, + row_id: $row_id, + witness_id: $witness_id, + kind: "lifecycle_action", + observer: "run-macos-tcc-canary-row.sh", + observed_unix_ms: $observed_unix_ms, + evidence_sha256: $evidence_sha256, + launcher_action: $launcher_action + }' >"${lifecycle_witness_temp}" + chmod 600 "${lifecycle_witness_temp}" + install_new_artifact "${lifecycle_witness_temp}" "${lifecycle_witness_path}" +fi + +case "${topology}:${lifecycle_phase}" in + app-sidecar:*) + "${app}" --minimized >/dev/null 2>&1 & + ;; + direct-launchd:service_install) + "${cli}" service enable + ;; + direct-launchd:service_restart|direct-launchd:later_grant|direct-launchd:grant_after_revocation) + "${cli}" service start + ;; + direct-launchd:*) + "${cli}" service restart + ;; + homebrew:service_install) + "${brew}" services start hypercolor + ;; + homebrew:service_restart|homebrew:later_grant|homebrew:grant_after_revocation) + "${brew}" services start hypercolor + ;; + homebrew:*) + "${brew}" services restart hypercolor + ;; + standalone:*) + "${daemon}" --macos-owner standalone >/dev/null 2>&1 & + ;; +esac + +deadline=$((SECONDS + timeout_seconds)) +while [[ ! -f "${pending_receipt}" && ${SECONDS} -lt ${deadline} ]]; do + sleep 1 +done +[[ -f "${pending_receipt}" && ! -L "${pending_receipt}" ]] \ + || die "a regular atomic pending receipt did not arrive within ${timeout_seconds} seconds" + +install_witness "${settings_witness_id}" system_settings_identity + +while [[ ! -f "${receipt}" && ${SECONDS} -lt ${deadline} ]]; do + sleep 1 +done +[[ -f "${receipt}" && ! -L "${receipt}" ]] \ + || die "a regular atomic receipt did not arrive within ${timeout_seconds} seconds" + +jq -e \ + --arg run_id "${run_id}" \ + --arg row_id "${row_id}" \ + --arg topology "${expected_topology}" \ + '.schema_version == 2 + and .run_id == $run_id + and .row_id == $row_id + and .topology == $topology + and .acceptance_claim == "evidence_only" + and .signing.process_bound_valid == true + and .signing.audit_token_bound_valid == true + and .signing.process_bound_pid == .pid + and (if .topology == "app_sidecar" + then .launcher.parent_signing.audit_token_bound_valid == true + else true + end) + and .launcher.verified == true' \ + "${receipt}" >/dev/null \ + || die 'daemon receipt does not match the requested production launcher row' + +row_committed=true +printf 'macOS TCC canary receipt: %s\n' "${receipt}" diff --git a/scripts/sign-macos-artifacts.sh b/scripts/sign-macos-artifacts.sh new file mode 100755 index 000000000..1ffa06b0c --- /dev/null +++ b/scripts/sign-macos-artifacts.sh @@ -0,0 +1,738 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$-" == *x* ]]; then + set +x +fi + +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="" + +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 + 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, +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_API_KEY_ID, APPLE_API_ISSUER, APPLE_API_KEY_PATH trio or a preconfigured +APPLE_NOTARY_KEYCHAIN_PROFILE. Raw Apple ID passwords are not accepted. +EOF +} + +cleanup() { + 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" + local certificate="$2" + if printf '%s' "${certificate}" | base64 -D > "${output}" 2>/dev/null; then + return + fi + printf '%s' "${certificate}" | base64 --decode > "${output}" 2>/dev/null \ + || die "APPLE_CERTIFICATE is not valid base64" +} + +compile_signing_keychain_helper() { + local helper="$1" + require xcrun + xcrun --sdk macosx clang \ + -std=c17 -Wall -Wextra -Werror -Wno-deprecated-declarations \ + -mmacosx-version-min=15.2 \ + -framework Security -framework CoreFoundation \ + "${ROOT_DIR}/scripts/macos-signing-keychain.c" \ + -o "${helper}" + chmod 700 "${helper}" +} + +create_signing_keychain() { + local certificate="$1" + local keychain_password="$2" + local certificate_password="$3" + local helper="${SIGNING_TMP}/macos-signing-keychain" + compile_signing_keychain_helper "${helper}" + + if ! printf '%s\0%s\0' "${keychain_password}" "${certificate_password}" \ + | env -u APPLE_CERTIFICATE_PASSWORD \ + "${helper}" "${SIGNING_KEYCHAIN}" "${certificate}"; then + certificate_password="" + keychain_password="" + die "could not create the ephemeral signing keychain" + fi + certificate_password="" + keychain_password="" +} + +prepare_signing_identity() { + require codesign + require security + local encoded_certificate="${APPLE_CERTIFICATE:-}" + local certificate_password="${APPLE_CERTIFICATE_PASSWORD:-}" + unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD + [[ -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 "${encoded_certificate}" ]] \ + || die "signing identity is not installed and APPLE_CERTIFICATE is missing" + [[ -n "${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}" "${encoded_certificate}" + encoded_certificate="" + chmod 600 "${certificate}" + + create_signing_keychain \ + "${certificate}" "${keychain_password}" "${certificate_password}" + certificate_password="" + keychain_password="" + + 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_APP_SPECIFIC_PASSWORD:-}" || -n "${APPLE_ID:-}" ]]; then + die "raw Apple ID notarization credentials are unsupported; store them with 'xcrun notarytool store-credentials' and set APPLE_NOTARY_KEYCHAIN_PROFILE" + fi + 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:-}" \ + && -f "${APPLE_API_KEY_PATH:-}" && -s "${APPLE_API_KEY_PATH:-}" ]] \ + || die "notarization requires the complete App Store Connect API key trio" + [[ ! -L "${APPLE_API_KEY_PATH}" ]] \ + || die "APPLE_API_KEY_PATH must not be a symbolic link" + local key_mode + key_mode="$(stat -f '%Lp' "${APPLE_API_KEY_PATH}")" + [[ "${key_mode}" == "600" || "${key_mode}" == "400" ]] \ + || die "APPLE_API_KEY_PATH must have mode 0600 or 0400" + return + fi + [[ -n "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]] \ + || die "notarization requires an App Store Connect API key or APPLE_NOTARY_KEYCHAIN_PROFILE" +} + +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 + local profile_args=(--keychain-profile "${APPLE_NOTARY_KEYCHAIN_PROFILE}") + if [[ -n "${APPLE_NOTARY_KEYCHAIN_PATH:-}" ]]; then + profile_args+=(--keychain "${APPLE_NOTARY_KEYCHAIN_PATH}") + fi + xcrun notarytool submit "${submission}" --wait --output-format json \ + "${profile_args[@]}" > "${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}" +} + +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" + 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 --no-sign --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}" + ;; + 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 + ;; + *) + usage >&2 + die "unknown command: ${command_name}" + ;; +esac diff --git a/scripts/tests/macos-signing-secret-transport-tests.sh b/scripts/tests/macos-signing-secret-transport-tests.sh new file mode 100755 index 000000000..b2eb8904a --- /dev/null +++ b/scripts/tests/macos-signing-secret-transport-tests.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/hypercolor-signing-transport.XXXXXX")" +HELPER="${TEST_TMP}/macos-signing-keychain" +PROCESS_LOG="${TEST_TMP}/processes.log" +STOP_FILE="${TEST_TMP}/stop" +POLL_PID="" +KEYCHAINS=() + +cleanup() { + if [[ -n "${POLL_PID}" ]]; then + kill "${POLL_PID}" >/dev/null 2>&1 || true + wait "${POLL_PID}" >/dev/null 2>&1 || true + fi + local keychain + for keychain in "${KEYCHAINS[@]}"; do + security delete-keychain "${keychain}" >/dev/null 2>&1 || true + done + case "${TEST_TMP}" in + "${TMPDIR:-/tmp}"/hypercolor-signing-transport.*) rm -rf "${TEST_TMP}" ;; + *) printf 'refusing to remove unexpected test directory: %s\n' "${TEST_TMP}" >&2 ;; + esac +} +trap cleanup EXIT + +xcrun --sdk macosx clang \ + -std=c17 -Wall -Wextra -Werror -Wno-deprecated-declarations \ + -mmacosx-version-min=15.2 \ + -framework Security -framework CoreFoundation \ + "${ROOT_DIR}/scripts/macos-signing-keychain.c" \ + -o "${HELPER}" + +certificate_password="$(openssl rand -hex 32)" +keychain_password="$(openssl rand -hex 32)" +openssl req -x509 -newkey rsa:2048 \ + -keyout "${TEST_TMP}/key.pem" \ + -out "${TEST_TMP}/certificate.pem" \ + -nodes -days 1 \ + -subj '/CN=Hypercolor Signing Transport Test' \ + -addext 'keyUsage=digitalSignature' \ + -addext 'extendedKeyUsage=codeSigning' >/dev/null 2>&1 +printf '%s\n' "${certificate_password}" \ + | openssl pkcs12 -export \ + -inkey "${TEST_TMP}/key.pem" \ + -in "${TEST_TMP}/certificate.pem" \ + -out "${TEST_TMP}/identity.p12" \ + -passout stdin >/dev/null 2>&1 + +: > "${PROCESS_LOG}" +poll_processes() { + while [[ ! -e "${STOP_FILE}" ]]; do + ps -A -o command= >> "${PROCESS_LOG}" + done +} +poll_processes & +POLL_PID=$! + +for index in {1..16}; do + keychain="${TEST_TMP}/test-${index}.keychain-db" + KEYCHAINS+=("${keychain}") + printf '%s\0%s\0' "${keychain_password}" "${certificate_password}" \ + | "${HELPER}" "${keychain}" "${TEST_TMP}/identity.p12" + security find-key -s -t private "${keychain}" >/dev/null +done + +: > "${STOP_FILE}" +wait "${POLL_PID}" +POLL_PID="" + +while IFS= read -r command; do + if [[ "${command}" == *"${certificate_password}"* ]]; then + printf 'certificate password appeared in process arguments: %s\n' "${command}" >&2 + exit 1 + fi + if [[ "${command}" == *"${keychain_password}"* ]]; then + printf 'keychain password appeared in process arguments: %s\n' "${command}" >&2 + exit 1 + fi +done < "${PROCESS_LOG}" + +export APPLE_CERTIFICATE_PASSWORD="${certificate_password}" +export APPLE_APP_SPECIFIC_PASSWORD="${keychain_password}" +trace_output="$(bash -x "${ROOT_DIR}/scripts/sign-macos-artifacts.sh" validate-manifest 2>&1)" +unset APPLE_CERTIFICATE_PASSWORD APPLE_APP_SPECIFIC_PASSWORD +if [[ "${trace_output}" == *"${certificate_password}"* ]]; then + printf 'certificate password appeared in xtrace output\n' >&2 + exit 1 +fi +if [[ "${trace_output}" == *"${keychain_password}"* ]]; then + printf 'keychain password appeared in xtrace output\n' >&2 + exit 1 +fi + +certificate_password="" +keychain_password="" +printf 'macOS signing secret transport: PASS\n' 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}" 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 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..8dac4b1eb 100644 --- a/sdk/packages/core/src/input/data.ts +++ b/sdk/packages/core/src/input/data.ts @@ -14,6 +14,9 @@ import { MouseInputEvent, MouseInputState, MouseMode, + MouseScrollPhase, + MouseScrollState, + MouseScrollUnit, } 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..823bd6a27 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,46 @@ 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' + /** Integral vertical wheel delta in 1/120-notch units. */ + 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"). */ @@ -85,11 +120,13 @@ 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 /** 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..619dcd6e9 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: -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(-240) }) test('keeps an idle healthy routed source available', () => { diff --git a/sdk/src/effects/keystrike/main.ts b/sdk/src/effects/keystrike/main.ts index f7bfefa08..3b8b717c4 100644 --- a/sdk/src/effects/keystrike/main.ts +++ b/sdk/src/effects/keystrike/main.ts @@ -132,7 +132,9 @@ export default canvas( } // Wheel rotates the palette phase so scrolling recolors the rig. - hueOffset = (hueOffset + input.mouse.wheel * 0.04) % 1 + // mouse.wheel carries 1/120-notch units (120 per physical notch), + // and one notch should swing the hue by 0.04. + hueOffset = (hueOffset + (input.mouse.wheel / 120) * 0.04) % 1 if (hueOffset < 0) hueOffset += 1 const lifeSeconds = 0.6 + (decay / 100) * 2.4