From 5781d6bbfa8bb51e07ddef5943f1fc52768467ee Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:42:54 +0800 Subject: [PATCH] feat(client): complete native source and release boundary Complete the Native Client source/audio boundary, strict v5 loopback contract, Browser media lifecycle handling, and non-blocking release checks. Keep unverified platform/physical gates explicit; do not rewrite the Go Server. --- .github/workflows/repository-hygiene.yml | 136 ++- AGENTS.md | 6 + CONTRIBUTING.md | 16 + deploy/check-release.sh | 18 + docs/README.md | 10 +- .../0006-fixed-high-native-sender-canary.md | 16 +- ...07-path-isolated-representation-quality.md | 8 +- docs/adr/0008-window-scoped-audio-capture.md | 19 +- .../adr/0010-cross-platform-client-runtime.md | 161 +++ docs/deployment.md | 69 ++ docs/product/media-quality.md | 12 +- docs/product/presentation-lifecycle.md | 10 +- docs/product/rooms-access.md | 11 + docs/product/routing-transport.md | 15 + docs/project-memory.md | 19 +- .../research/cross-platform-client-runtime.md | 190 +++ docs/research/native-client-media.md | 180 +++ docs/research/native-sender.md | 6 +- docs/status.md | 33 +- docs/todo.md | 69 +- docs/verification-status.md | 55 +- native/client/README.md | 221 ++++ native/client/cmd/screener-client/main.go | 45 + native/client/cmd/screener-peer-gate/main.go | 591 +++++++++ .../cmd/screener-peer-gate/main_test.go | 50 + native/{sender => client}/go.mod | 18 +- native/{sender => client}/go.sum | 29 + native/client/internal/browser/open.go | 33 + native/client/internal/browser/open_test.go | 33 + native/client/internal/clientapp/app.go | 476 ++++++++ native/client/internal/clientapp/app_test.go | 140 +++ native/client/internal/clientconfig/config.go | 144 +++ .../internal/clientconfig/config_test.go | 83 ++ native/client/internal/lan/addresses.go | 67 + native/client/internal/lan/addresses_test.go | 21 + native/client/internal/launcher/server.go | 321 +++++ .../client/internal/launcher/server_test.go | 198 +++ native/client/internal/loopback/protocol.go | 73 ++ .../client/internal/loopback/protocol_test.go | 68 ++ native/client/internal/loopback/server.go | 443 +++++++ .../client/internal/loopback/server_test.go | 292 +++++ native/client/internal/mediaedge/audio.go | 175 +++ native/client/internal/mediaedge/edge.go | 277 +++++ native/client/internal/mediaedge/edge_test.go | 503 ++++++++ native/client/internal/mediaedge/engine.go | 183 +++ native/client/internal/mediaedge/quality.go | 158 +++ .../client/internal/mediaedge/quality_test.go | 50 + native/client/internal/mediaedge/source.go | 190 +++ native/client/internal/nativeaudio/encoder.go | 73 ++ .../internal/nativeaudio/encoder_test.go | 113 ++ native/client/internal/nativecapture/json.go | 20 + native/client/internal/nativecapture/probe.go | 194 +++ .../internal/nativecapture/probe_test.go | 48 + .../internal/nativecapture/process_other.go | 7 + .../internal/nativecapture/process_windows.go | 12 + .../client/internal/nativecapture/protocol.go | 69 ++ .../internal/nativecapture/protocol_test.go | 115 ++ .../client/internal/nativecapture/stream.go | 277 +++++ .../client/internal/nativecontrol/session.go | 519 ++++++++ .../internal/nativecontrol/session_test.go | 82 ++ native/client/internal/nativecontrol/wire.go | 183 +++ native/client/internal/nativehost/session.go | 499 ++++++++ .../internal/nativehost/session_test.go | 30 + native/client/internal/portmapping/mapping.go | 122 ++ .../internal/portmapping/mapping_test.go | 145 +++ .../client/internal/publictunnel/process.go | 251 ++++ .../internal/publictunnel/process_other.go | 7 + .../internal/publictunnel/process_test.go | 71 ++ .../internal/publictunnel/process_windows.go | 12 + native/client/internal/supervisor/process.go | 139 +++ .../internal/supervisor/process_test.go | 85 ++ .../client/platform/darwin/capture/README.md | 28 + .../client/platform/darwin/capture/build.sh | 21 + .../client/platform/darwin/capture/main.swift | 814 +++++++++++++ .../client/platform/windows/capture/README.md | 39 + .../platform/windows/capture}/build.ps1 | 7 +- .../windows/capture/capture_target.cpp | 355 ++++++ .../platform/windows/capture/capture_target.h | 19 + .../platform/windows/capture}/main.cpp | 371 ++++-- .../windows/capture}/process_audio.cpp | 261 ++-- .../platform/windows/capture}/process_audio.h | 14 +- native/fixtures/mf-h264-hardware/README.md | 5 +- native/fixtures/mf-h264-hardware/build.ps1 | 2 +- native/sender/README.md | 16 - .../cmd/screener-one-viewer-gate/main.go | 31 - native/sender/cmd/screener-sender/main.go | 71 -- native/sender/internal/app/security.go | 59 - native/sender/internal/app/security_test.go | 60 - native/sender/internal/app/server.go | 731 ----------- native/sender/internal/app/server_test.go | 725 ----------- native/sender/internal/app/ui/app.js | 822 ------------- .../internal/app/ui/hardware-status.d.ts | 11 - .../sender/internal/app/ui/hardware-status.js | 14 - native/sender/internal/app/ui/index.html | 76 -- native/sender/internal/app/ui/styles.css | 70 -- native/sender/internal/media/audio.go | 178 --- native/sender/internal/media/audio_test.go | 56 - native/sender/internal/media/envelope.go | 104 -- native/sender/internal/media/fanout.go | 281 ----- native/sender/internal/media/fanout_test.go | 257 ---- native/sender/internal/media/queue.go | 157 --- native/sender/internal/media/queue_test.go | 93 -- native/sender/internal/remote/admission.go | 103 -- .../sender/internal/remote/admission_test.go | 100 -- native/sender/internal/remote/codec.go | 56 - native/sender/internal/remote/codec_test.go | 66 - native/sender/internal/remote/peer.go | 320 ----- native/sender/internal/remote/peer_test.go | 71 -- native/sender/internal/remote/session.go | 1074 ----------------- native/sender/internal/remote/session_test.go | 891 -------------- native/sender/internal/remote/wire.go | 593 --------- native/sender/internal/remote/wire_test.go | 317 ----- .../sender/internal/windowcapture/provider.go | 56 - .../internal/windowcapture/provider_other.go | 26 - .../windowcapture/provider_windows.go | 307 ----- .../windowcapture/provider_windows_test.go | 149 --- native/window-capture-helper/README.md | 30 - package-lock.json | 26 +- package.json | 22 +- scripts/access-privacy-gate.ts | 5 +- scripts/assemble-client.mjs | 285 +++++ scripts/browser-gate-harness.ts | 4 +- scripts/check-client.mjs | 194 +++ scripts/client-gate-endpoint.ts | 36 + scripts/client-link-gate.ts | 320 +++++ scripts/client-local-gate.ts | 229 ++++ scripts/client-loopback-gate.ts | 303 +++++ scripts/client-media-gate.ts | 844 +++++++++++++ scripts/client-native-host-gate.ts | 938 ++++++++++++++ scripts/client-native-sfu-gate.ts | 390 ++++++ scripts/client-native-sfu-page.ts | 140 +++ scripts/client-package-targets.mjs | 48 + scripts/native-one-viewer-gate-core.ts | 146 --- scripts/native-one-viewer-gate.ts | 922 -------------- scripts/native-one-viewer-ledger.ts | 179 --- scripts/package-client-candidate.mjs | 345 ++++++ scripts/peer-assisted-benchmark.ts | 158 ++- scripts/release-update.mjs | 206 ++++ scripts/required-project-paths.txt | 25 +- src/client/App.tsx | 24 +- .../components/living/CaptureSourcePicker.tsx | 257 ++++ src/client/components/living/Header.tsx | 10 +- src/client/lib/release-update.ts | 110 ++ src/client/lib/session.ts | 37 + src/client/locales/en.ts | 27 + src/client/locales/zh.ts | 27 + src/client/media/host-provisional-child.ts | 98 +- src/client/media/quality.ts | 9 + src/client/native/capture-selection.ts | 24 + src/client/native/client.ts | 364 ++++++ src/client/native/host-edge.ts | 154 +++ src/client/native/media-bridge.ts | 197 +++ src/client/native/native-host-peer.ts | 194 +++ src/client/native/wire.ts | 236 ++++ src/client/pages/ClientLauncherPage.tsx | 226 ++++ src/client/pages/HostPage.tsx | 493 +++++++- src/client/pages/ViewerPage.tsx | 10 +- src/client/styles.css | 303 +++++ src/client/webrtc/host-peer.ts | 42 +- src/client/webrtc/viewer-peer.ts | 8 +- src/server/app.ts | 167 +-- src/server/hybrid-media-router.ts | 2 +- src/server/index.ts | 10 +- src/server/livekit-token.ts | 2 +- src/server/local-config.ts | 135 +++ src/server/local-index.ts | 34 + src/server/room-store.ts | 8 + src/server/sfu-resource-admission.ts | 28 + src/server/sfu-room-control.ts | 30 +- src/server/signaling.ts | 6 + tests/client-gate-endpoint.test.ts | 33 + tests/client-state.test.ts | 58 + tests/fake-sfu-room-control.ts | 2 +- tests/host-peer-tracks.test.ts | 15 +- tests/local-server-config.test.ts | 87 ++ tests/native-capture-selection.test.ts | 61 + tests/native-client.test.ts | 124 ++ tests/native-hardware-status.test.ts | 58 - tests/native-host-edge.test.ts | 99 ++ tests/native-host-peer.test.ts | 84 ++ tests/native-media-bridge.test.ts | 220 ++++ tests/native-one-viewer-gate-core.test.ts | 299 ----- tests/native-one-viewer-ledger.test.ts | 134 -- tests/peer-assisted-benchmark.test.ts | 21 + tests/protocol.test.ts | 14 +- tests/quality.test.ts | 10 + tests/release-app.test.ts | 2 +- tests/release-update.test.ts | 109 ++ tests/room-store.test.ts | 15 + tests/server-http.test.ts | 46 +- tests/server-signal.test.ts | 43 +- tests/sfu-room-control.test.ts | 6 +- tests/viewer-peer-generation.test.ts | 35 + tsconfig.tools.json | 8 +- 194 files changed, 19199 insertions(+), 10342 deletions(-) create mode 100644 deploy/check-release.sh create mode 100644 docs/adr/0010-cross-platform-client-runtime.md create mode 100644 docs/research/cross-platform-client-runtime.md create mode 100644 docs/research/native-client-media.md create mode 100644 native/client/README.md create mode 100644 native/client/cmd/screener-client/main.go create mode 100644 native/client/cmd/screener-peer-gate/main.go create mode 100644 native/client/cmd/screener-peer-gate/main_test.go rename native/{sender => client}/go.mod (61%) rename native/{sender => client}/go.sum (62%) create mode 100644 native/client/internal/browser/open.go create mode 100644 native/client/internal/browser/open_test.go create mode 100644 native/client/internal/clientapp/app.go create mode 100644 native/client/internal/clientapp/app_test.go create mode 100644 native/client/internal/clientconfig/config.go create mode 100644 native/client/internal/clientconfig/config_test.go create mode 100644 native/client/internal/lan/addresses.go create mode 100644 native/client/internal/lan/addresses_test.go create mode 100644 native/client/internal/launcher/server.go create mode 100644 native/client/internal/launcher/server_test.go create mode 100644 native/client/internal/loopback/protocol.go create mode 100644 native/client/internal/loopback/protocol_test.go create mode 100644 native/client/internal/loopback/server.go create mode 100644 native/client/internal/loopback/server_test.go create mode 100644 native/client/internal/mediaedge/audio.go create mode 100644 native/client/internal/mediaedge/edge.go create mode 100644 native/client/internal/mediaedge/edge_test.go create mode 100644 native/client/internal/mediaedge/engine.go create mode 100644 native/client/internal/mediaedge/quality.go create mode 100644 native/client/internal/mediaedge/quality_test.go create mode 100644 native/client/internal/mediaedge/source.go create mode 100644 native/client/internal/nativeaudio/encoder.go create mode 100644 native/client/internal/nativeaudio/encoder_test.go create mode 100644 native/client/internal/nativecapture/json.go create mode 100644 native/client/internal/nativecapture/probe.go create mode 100644 native/client/internal/nativecapture/probe_test.go create mode 100644 native/client/internal/nativecapture/process_other.go create mode 100644 native/client/internal/nativecapture/process_windows.go create mode 100644 native/client/internal/nativecapture/protocol.go create mode 100644 native/client/internal/nativecapture/protocol_test.go create mode 100644 native/client/internal/nativecapture/stream.go create mode 100644 native/client/internal/nativecontrol/session.go create mode 100644 native/client/internal/nativecontrol/session_test.go create mode 100644 native/client/internal/nativecontrol/wire.go create mode 100644 native/client/internal/nativehost/session.go create mode 100644 native/client/internal/nativehost/session_test.go create mode 100644 native/client/internal/portmapping/mapping.go create mode 100644 native/client/internal/portmapping/mapping_test.go create mode 100644 native/client/internal/publictunnel/process.go create mode 100644 native/client/internal/publictunnel/process_other.go create mode 100644 native/client/internal/publictunnel/process_test.go create mode 100644 native/client/internal/publictunnel/process_windows.go create mode 100644 native/client/internal/supervisor/process.go create mode 100644 native/client/internal/supervisor/process_test.go create mode 100644 native/client/platform/darwin/capture/README.md create mode 100644 native/client/platform/darwin/capture/build.sh create mode 100644 native/client/platform/darwin/capture/main.swift create mode 100644 native/client/platform/windows/capture/README.md rename native/{window-capture-helper => client/platform/windows/capture}/build.ps1 (75%) create mode 100644 native/client/platform/windows/capture/capture_target.cpp create mode 100644 native/client/platform/windows/capture/capture_target.h rename native/{window-capture-helper => client/platform/windows/capture}/main.cpp (85%) rename native/{window-capture-helper => client/platform/windows/capture}/process_audio.cpp (50%) rename native/{window-capture-helper => client/platform/windows/capture}/process_audio.h (54%) delete mode 100644 native/sender/README.md delete mode 100644 native/sender/cmd/screener-one-viewer-gate/main.go delete mode 100644 native/sender/cmd/screener-sender/main.go delete mode 100644 native/sender/internal/app/security.go delete mode 100644 native/sender/internal/app/security_test.go delete mode 100644 native/sender/internal/app/server.go delete mode 100644 native/sender/internal/app/server_test.go delete mode 100644 native/sender/internal/app/ui/app.js delete mode 100644 native/sender/internal/app/ui/hardware-status.d.ts delete mode 100644 native/sender/internal/app/ui/hardware-status.js delete mode 100644 native/sender/internal/app/ui/index.html delete mode 100644 native/sender/internal/app/ui/styles.css delete mode 100644 native/sender/internal/media/audio.go delete mode 100644 native/sender/internal/media/audio_test.go delete mode 100644 native/sender/internal/media/envelope.go delete mode 100644 native/sender/internal/media/fanout.go delete mode 100644 native/sender/internal/media/fanout_test.go delete mode 100644 native/sender/internal/media/queue.go delete mode 100644 native/sender/internal/media/queue_test.go delete mode 100644 native/sender/internal/remote/admission.go delete mode 100644 native/sender/internal/remote/admission_test.go delete mode 100644 native/sender/internal/remote/codec.go delete mode 100644 native/sender/internal/remote/codec_test.go delete mode 100644 native/sender/internal/remote/peer.go delete mode 100644 native/sender/internal/remote/peer_test.go delete mode 100644 native/sender/internal/remote/session.go delete mode 100644 native/sender/internal/remote/session_test.go delete mode 100644 native/sender/internal/remote/wire.go delete mode 100644 native/sender/internal/remote/wire_test.go delete mode 100644 native/sender/internal/windowcapture/provider.go delete mode 100644 native/sender/internal/windowcapture/provider_other.go delete mode 100644 native/sender/internal/windowcapture/provider_windows.go delete mode 100644 native/sender/internal/windowcapture/provider_windows_test.go delete mode 100644 native/window-capture-helper/README.md create mode 100644 scripts/assemble-client.mjs create mode 100644 scripts/check-client.mjs create mode 100644 scripts/client-gate-endpoint.ts create mode 100644 scripts/client-link-gate.ts create mode 100644 scripts/client-local-gate.ts create mode 100644 scripts/client-loopback-gate.ts create mode 100644 scripts/client-media-gate.ts create mode 100644 scripts/client-native-host-gate.ts create mode 100644 scripts/client-native-sfu-gate.ts create mode 100644 scripts/client-native-sfu-page.ts create mode 100644 scripts/client-package-targets.mjs delete mode 100644 scripts/native-one-viewer-gate-core.ts delete mode 100644 scripts/native-one-viewer-gate.ts delete mode 100644 scripts/native-one-viewer-ledger.ts create mode 100644 scripts/package-client-candidate.mjs create mode 100644 scripts/release-update.mjs create mode 100644 src/client/components/living/CaptureSourcePicker.tsx create mode 100644 src/client/lib/release-update.ts create mode 100644 src/client/native/capture-selection.ts create mode 100644 src/client/native/client.ts create mode 100644 src/client/native/host-edge.ts create mode 100644 src/client/native/media-bridge.ts create mode 100644 src/client/native/native-host-peer.ts create mode 100644 src/client/native/wire.ts create mode 100644 src/client/pages/ClientLauncherPage.tsx create mode 100644 src/server/local-config.ts create mode 100644 src/server/local-index.ts create mode 100644 tests/client-gate-endpoint.test.ts create mode 100644 tests/local-server-config.test.ts create mode 100644 tests/native-capture-selection.test.ts create mode 100644 tests/native-client.test.ts delete mode 100644 tests/native-hardware-status.test.ts create mode 100644 tests/native-host-edge.test.ts create mode 100644 tests/native-host-peer.test.ts create mode 100644 tests/native-media-bridge.test.ts delete mode 100644 tests/native-one-viewer-gate-core.test.ts delete mode 100644 tests/native-one-viewer-ledger.test.ts create mode 100644 tests/release-update.test.ts diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index 8542692..f5abfa9 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -7,8 +7,8 @@ on: - main workflow_dispatch: inputs: - native_checks: - description: Run the optional Native sender research checks + client_checks: + description: Run the optional Screener Client checks required: false default: false type: boolean @@ -19,6 +19,8 @@ permissions: jobs: validate: runs-on: ubuntu-latest + outputs: + client_scope: ${{ steps.client-scope.outputs.run }} steps: - name: Check out repository uses: actions/checkout@v6 @@ -33,16 +35,16 @@ jobs: run: sh scripts/check-project-state.sh env: SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - - name: Detect Native sender scope - id: native-scope + - name: Detect Screener Client scope + id: client-scope shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - EXPLICIT_NATIVE_CHECKS: ${{ inputs.native_checks || false }} + EXPLICIT_CLIENT_CHECKS: ${{ inputs.client_checks || false }} run: | - run_native=false - if [ "$EXPLICIT_NATIVE_CHECKS" = "true" ]; then - run_native=true + run_client=false + if [ "$EXPLICIT_CLIENT_CHECKS" = "true" ]; then + run_client=true elif [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then base="$BASE_SHA" case "$base" in @@ -50,25 +52,119 @@ jobs: base=$(git rev-parse HEAD^ 2>/dev/null || true) ;; esac - if [ -n "$base" ] && ! git diff --quiet "$base" HEAD -- native/sender; then - run_native=true + if [ -n "$base" ] && ! git diff --quiet "$base" HEAD -- \ + .github/workflows/repository-hygiene.yml \ + .node-version \ + index.html \ + native/client \ + package.json \ + package-lock.json \ + public \ + src \ + tsconfig.json \ + tsconfig.app.json \ + tsconfig.server.json \ + vite.config.ts \ + scripts/assemble-client.mjs \ + scripts/package-app-release.mjs \ + scripts/package-client-candidate.mjs \ + scripts/client-package-targets.mjs \ + scripts/check-client.mjs \ + scripts/client-local-gate.ts \ + scripts/client-loopback-gate.ts \ + scripts/client-media-gate.ts; then + run_client=true fi fi - echo "run=$run_native" >> "$GITHUB_OUTPUT" + echo "run=$run_client" >> "$GITHUB_OUTPUT" - name: Set up Go - if: steps.native-scope.outputs.run == 'true' + if: steps.client-scope.outputs.run == 'true' uses: actions/setup-go@v6 with: go-version: 1.26.6 - cache-dependency-path: native/sender/go.sum - - name: Check native sender - if: steps.native-scope.outputs.run == 'true' - working-directory: native/sender - run: | - go test ./... - go vet ./... - GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/screener-sender.exe" ./cmd/screener-sender + cache-dependency-path: native/client/go.sum + - name: Check Screener Client + if: steps.client-scope.outputs.run == 'true' + run: npm run check:client-core - name: Install dependencies run: npm ci - name: Type-check, test, and build run: npm run check + + release-application: + needs: validate + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + needs.validate.outputs.client_scope == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: npm + - name: Install dependencies + run: npm ci + - name: Package application release + run: node scripts/package-app-release.mjs "$RUNNER_TEMP/app-release" + - name: Upload application release + uses: actions/upload-artifact@v7 + with: + name: screener-app-${{ github.sha }} + path: ${{ runner.temp }}/app-release/* + if-no-files-found: error + retention-days: 14 + + release-clients: + needs: + - validate + - release-application + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + needs.validate.outputs.client_scope == 'true' + strategy: + fail-fast: false + matrix: + include: + - target: windows-amd64 + runner: windows-latest + - target: linux-amd64 + runner: ubuntu-latest + - target: darwin-arm64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 1.26.6 + cache-dependency-path: native/client/go.sum + - name: Download application release + uses: actions/download-artifact@v8 + with: + name: screener-app-${{ github.sha }} + path: ${{ runner.temp }}/app-release + - name: Build and verify Client candidate + run: >- + node scripts/package-client-candidate.mjs + "${{ runner.temp }}/app-release" + "${{ matrix.target }}" + "${{ runner.temp }}/client-release" + - name: Upload Client release candidate + uses: actions/upload-artifact@v7 + with: + name: screener-client-${{ matrix.target }}-${{ github.sha }} + path: ${{ runner.temp }}/client-release/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 61e3e26..cdc35d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,10 +76,16 @@ ## Engineering Defaults +- Prefer the simplest cohesive design that is easy to use and maintain. Reuse + mature components, keep ownership explicit, and remove superseded paths in + the same change instead of retaining speculative flexibility or technical debt. - Judge a change by evidenced user value against its full implementation, maintenance, compatibility, and failure cost. A large measured gain may justify broad or breaking work; a small or speculative gain does not justify material complexity or risk. +- After completing a material module, run one ablation pass and remove every new + component, state, dependency, or branch that is not needed for the verified + result or an accepted contract. - Use primary sources for non-trivial design and bugs. Add focused tests in proportion to risk; batch full browser/network/endurance checks at acceptance. - Keep scripts deterministic, fast, cross-platform, and CI-runnable. Support diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fc01dd..f63ebe3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,22 @@ Clean up worktrees and branches only after semantic review and integration are c - Review licenses before copying implementation code. A useful reference is not automatically a compatible dependency. - Until the project license and distribution model are decided, treat GPL/AGPL implementation sources as study-only. +## Verification Entrypoints + +- `npm run check` owns deterministic Web/Server type-check, unit, and build + acceptance. +- `npm run check:client` owns Go formatting, unit tests, vet, three-platform + builds, and the Windows capture compile/probe when run on Windows. CI invokes + these same package commands rather than rebuilding their steps in YAML. +- `gate:*` commands are explicit physical or network acceptance. They must use + isolated profiles, bounded deadlines, shared cleanup helpers, and a structured + result. A manual diagnostic may locate a failure, but is not retained as pass + evidence. +- A Client-scoped pull request builds and smoke-tests all three platform + candidates before merge. A validated push to `main` uploads those short-lived + Server and Client candidates. Publishing a tag or GitHub Release remains an + explicit release decision rather than a side effect of every merge. + ## Pull Request Scope A pull request should explain the problem, the chosen design, verification performed, user-visible or operational effects, and remaining risks. diff --git a/deploy/check-release.sh b/deploy/check-release.sh new file mode 100644 index 0000000..ca72f06 --- /dev/null +++ b/deploy/check-release.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -Eeuo pipefail + +if [ "$#" -gt 1 ]; then + printf 'usage: %s [current-revision-file]\n' "$0" >&2 + exit 2 +fi + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +node="${SCREENER_NODE:-/usr/local/bin/node}" +current_file="${1:-/opt/screener/current/REVISION}" + +if [ ! -x "$node" ]; then + printf 'Node runtime is unavailable: %s\n' "$node" >&2 + exit 2 +fi +arguments=("$repo_root/scripts/release-update.mjs" "--current-file" "$current_file") +exec "$node" "${arguments[@]}" diff --git a/docs/README.md b/docs/README.md index d7cc571..341254a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,14 +39,16 @@ ordinary implementation and UI detail. experiment. - [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): current automatic route and SFU resource model. -- [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): native sender - canary stop line. +- [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): historical native + sender canary evidence (superseded by ADR-0010). - [ADR-0007](./adr/0007-path-isolated-representation-quality.md): framework- owned media adaptation. - [ADR-0008](./adr/0008-window-scoped-audio-capture.md): Browser/native window audio boundary. - [ADR-0009](./adr/0009-optional-nat-prediction.md): optional connection-local NAT prediction. +- [ADR-0010](./adr/0010-cross-platform-client-runtime.md): cross-platform Client + runtime and self-contained package boundary. ## Evidence @@ -64,7 +66,9 @@ Media and platform: - [Browser screen audio](./research/browser-screen-audio-quality.md) - [Browser NAT traversal](./research/nat-traversal.md) - [Browser platform output](./research/platform-output.md) -- [Native sender and shared encode](./research/native-sender.md) +- [Historical native sender and shared encode evidence](./research/native-sender.md) +- [Native Client media](./research/native-client-media.md) +- [Cross-platform Client runtime](./research/cross-platform-client-runtime.md) Rooms and repository practice: diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index 36a815a..b3e6dd0 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -1,8 +1,8 @@ # ADR-0006: Fixed-HIGH Native Sender Canary -- Status: rejected product candidate; evidence retained for later revalidation +- Status: superseded; historical evidence retained - Date: 2026-08-19 -- Last updated: 2026-08-27 +- Last updated: 2026-09-02 ## Context @@ -13,11 +13,11 @@ current room, signaling, routing, audio, packaging, and Browser Viewer contract. ## Decision -Do not integrate or ship the fixed-HIGH native sender. The current product -surface remains Web Host/Viewer/relay, and stale experimental wires receive no -compatibility reader. +The fixed-HIGH native sender candidate is not a product surface and its private +wire remains deleted. The current native Client boundary is defined by +[ADR-0010](./0010-cross-platform-client-runtime.md). -The retained one-Viewer loopback proved that a fixed 1280x720@30 source could +The deleted one-Viewer loopback proved that a fixed 1280x720@30 source could authenticate, negotiate one independent Pion leg, and deliver decoded/rendered Browser frames. Later Windows WGC/Media Foundation work proved one attributed hardware H.264 path. Those are bounded functional facts, not evidence for two @@ -48,8 +48,8 @@ must remain owned by ADR-0005. ## Consequences - No native candidate constrains the current Browser product or route model. -- Existing native source and fixtures remain research/evaluation code, not a - production runtime. +- The obsolete sender runtime and private wire are deleted. Git owns that + history; only the isolated capture fixture and measured evidence remain. - Revalidation cost is explicit and cannot be hidden by preserving deprecated protocol or packaging surfaces. diff --git a/docs/adr/0007-path-isolated-representation-quality.md b/docs/adr/0007-path-isolated-representation-quality.md index e7a2649..54a11f7 100644 --- a/docs/adr/0007-path-isolated-representation-quality.md +++ b/docs/adr/0007-path-isolated-representation-quality.md @@ -70,7 +70,13 @@ it does not need a second bitrate, resolution, FPS, or layer-control system. native-edge convergence may consume only a persistent categorical limitation from the exact sender and comparative delivery proof from the same Viewer over a real prepared candidate. It does not set bitrate, resolution, FPS, or layer, - infer a physical bottleneck, or combine quality measurements into a score. + infer a physical bottleneck, or combine quality measurements into a score. A + native Client P2P edge obtains that category from Pion GCC: after real + transport feedback and encoded-frame progress, a target payload bitrate below + the source's measured H.264 plus Opus payload is `bandwidth`; sufficient + target payload is `none`; missing feedback or source progress is `unknown`. + Pion observes through an immediate no-queue pacer and does not rewrite the + shared encoder or own another adaptation policy. 9. A healthy decoded route remains sticky. Manual media reconnect rebuilds the current exact P2P parent or current SFU subscription; it does not search for a better parent. If current-route recovery genuinely exhausts, ADR-0005's diff --git a/docs/adr/0008-window-scoped-audio-capture.md b/docs/adr/0008-window-scoped-audio-capture.md index f46e844..41d754c 100644 --- a/docs/adr/0008-window-scoped-audio-capture.md +++ b/docs/adr/0008-window-scoped-audio-capture.md @@ -1,6 +1,6 @@ # ADR-0008: Browser Screen-Audio Scope -- Status: accepted for Browser capture; Native process audio remains research +- Status: accepted for Browser capture and the Windows Client audio paths - Date: 2026-08-21 - Last updated: 2026-08-27 @@ -13,8 +13,9 @@ application. Sharing a system mix can unintentionally include calls or notifications. Windows offers a narrower native primitive through WASAPI application loopback -for one selected process tree, but that capability belongs to the unreleased -native sender candidate rather than the current Web product. +for one selected process tree. The native Client owns that path for selected +windows; a selected display uses the default render-device loopback when the +platform probe and physical gate report it. ## Decision @@ -32,10 +33,11 @@ native sender candidate rather than the current Web product. native media element. 5. Do not describe generic Browser system audio as application-isolated. UI and documentation must leave source scope to the Browser's actual result. -6. Windows process-tree audio, WGC/MF video, helper protocols, and evaluation - packaging remain Native research. Their product stop line is - [ADR-0006](./0006-fixed-high-native-sender-canary.md); measurements are in - [native sender evidence](../research/native-sender.md). +6. Windows process-tree audio for a selected window, and default render-device + loopback for a selected display, are Client capabilities when the platform + probe and physical gate pass. WGC/MF video and the Client media boundary are owned by + [ADR-0010](./0010-cross-platform-client-runtime.md); measurements are in + [Native Client media](../research/native-client-media.md). ## Consequences @@ -49,7 +51,8 @@ Negative: - Browser/system combinations remain inconsistent; - Web cannot promise per-process isolation; and -- strict application-audio capture requires a later native product decision. +- other platform application-audio capture requires separate native platform + gates. ## Primary Sources diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md new file mode 100644 index 0000000..a43bd89 --- /dev/null +++ b/docs/adr/0010-cross-platform-client-runtime.md @@ -0,0 +1,161 @@ +# ADR-0010: Cross-Platform Client Runtime + +- Status: accepted Client/Local foundation and Windows native Host media boundary +- Date: 2026-09-04 + +## Context + +The Browser is the current Screener interaction surface, but it cannot own native +capture, hardware encode, or reusable native sockets. A self-contained package +must add those capabilities without creating another room model, signaling +protocol, route controller, or UI. + +Existing Screener room, admission, signaling, and routing behavior already has +one TypeScript implementation. Rewriting that behavior in the Helper would make +Hosted and Local deployments diverge without improving the media path. + +## Decision + +1. The TypeScript/Node core remains the sole owner of HTTP, room authority, + admission, signaling, and routing in every deployment. A future local package + runs that same server and the same built Browser assets with local + configuration; Go does not reimplement the product core. +2. The packaged product is Screener Client. Its Go process is the one + cross-platform user entry and future native-media owner. + Its current process exposes `/health` and one `/control` WebSocket on IPv4 + loopback within ports `39721` through `39730`. +3. Discovery returns a per-process `instanceToken`. The WebSocket subprotocol + echoes it so the Browser connects to the process it discovered. This value is + public process identity, not authentication. Origin and Host validation plus + the Browser's local-network permission own the current Browser boundary. +4. Loopback v5 starts with a strict `hello` handshake. Health discovery reports + only separately probed native capture booleans. An active control session may + list local screen/window choices, request bounded previews, and own one share's generation-fenced SDP/ICE + edges, including at most one loopback media bridge outside route-copy + capacity; it carries no room password, Host token, Viewer grant, or route + policy. The Browser forwards current Site signaling and remains the + participant. +5. A platform package contains the Go entry, a pinned Node runtime, and the same + server/client build used by Hosted Screener. It may also carry one process- + isolated capture binary and the pinned `cloudflared` sidecar. The Go entry + supervises child processes with bounded lifetime. Platform packaging is + metadata around that entry, not another long-running wrapper or UI. +6. The system Browser remains the UI. Browser extensions, userscripts, Electron, + Tauri, and resident services need new evidence before they can replace this + smaller boundary. +7. A self-contained Local deployment serves reachable LAN peers without a + central Screener service. Its explicit `--link` mode starts one accountless + Cloudflare Quick Tunnel for the same Node HTTP/WebSocket surface, injects the + resulting HTTPS origin before Node starts, and otherwise retains the same + memory RoomStore, Browser UI, Viewer grant, signaling, and route controller. + The Host sends the ordinary invitation link and the Viewer needs only a + Browser. Cloudflare terminates this temporary control path; WebRTC media stays + P2P and uses public STUN. The link ends with the Client and is not a persistent + Site, SFU, or TURN fallback. +8. Native media is selected for an entire Host share generation. One isolated + platform capture feeds one encoded source and bounded independent Pion + transports, with process-loopback audio for windows or system-loopback audio + for screens sharing the same PeerConnection when available. Each Site or one-link share makes one bounded, best-effort PCP, + UPnP, or NAT-PMP mapping for that same Pion UDP socket before its first edge + gathers ICE; pure LAN Local mode does not. Absence or rejection leaves + ordinary ICE/STUN unchanged. Native P2P quality uses Pion's transport-wide + GCC estimate only after feedback and source-frame progress, comparing its + target payload bitrate with the measured encoded payload supplied to that + edge. The existing route evidence windows own persistence; no custom score, + second adaptation policy, or queued pacer is introduced. The one loopback + edge supplies Host preview and the existing Browser LiveKit publisher. It + neither consumes route-copy capacity nor starts port mapping; an assigned SFU + publication still consumes its existing route copy. Native code does not + implement LiveKit or another representation policy. The Client discovers + packaged capture capability at startup. Its Host page offers the Browser's + standard picker and each exact Client-owned screen/window; the user must select one + and the Client never guesses a target. Only a consumed Client-launch marker + travels in the URL fragment. Exact target identity travels over loopback. +9. The Client uses the system Browser as its only UI. A lightweight loopback + launcher selects Local, one-link, or a saved Site before starting that + composition, then navigates into the same application. Command-line mode + selectors remain automation inputs rather than the normal interface. An + embedded shell requires a reproduced product failure and one comparative + decision. +10. Local mode is one explicit server composition: static current assets, + memory-only rooms, peer-assisted media, no SQLite, no SFU, no NAT prediction, + and localhost plus current LAN IPv4 origins. It uses no STUN by default; + `--link` adds its exact temporary HTTPS origin and Cloudflare's public STUN + to existing Browser media edges. It changes no Hosted shutdown or + persistence behavior. +11. One Client configuration owns the optional Site origin and a generated + Local access password. The Client bootstraps its own Host page through a + fragment that is consumed before authentication; friends use the existing + room invitation grant. No Client-specific authorization system is added. +12. Local authority shutdown first ends every in-memory room through the current + `room-closed` path, then closes signaling and HTTP. The Go supervisor closes + Node stdin, waits, and applies one bounded process timeout. It does not + restart a vanished authority. +13. Client assembly consumes the immutable Web/Server application release from + the same full Git revision and an explicit supported target. The Go binary, + matching target Node runtime, and `app/REVISION` form one package; revision + mismatch fails rather than loading a stale private contract. Client-scoped + pull requests build every target and must start the assembled Local authority, + pass `/healthz`, and stop it cleanly before the candidate is accepted. +14. The root package manifest is the single build/runtime dependency contract: + Browser-only libraries stay in `devDependencies`, while release and Client + assembly install the same manifest with `--omit=dev` for the server runtime. + No second Client dependency list or post-install package surgery is used. + +## Consequences + +Hosted and Local operation share one product contract and one route model. The +Go runtime acts as the Client's native-media owner when a Site or Local page +discovers it, but it is not a second room product. It stays small until a +proven native capability needs a protocol field. The local package may contain +two internal processes while presenting one user entry; the supervisor, not a +compatibility protocol, owns their lifetime. + +Windows gates prove loopback discovery, Local static startup, automatic Host +access, LAN invitation construction, a three-Viewer Browser relay tree, two +process-isolated hardware-H.264/Pion edges sharing one encoded source and +decoded by Chrome, and bounded process cleanup. The native Host path uses the +current Browser route; its explicit in-page window picker also passes source +end, same-room reselection, and restored Viewer delivery. A remote Pion gate has +received video over a direct +`srflx`-to-`srflx` pair. A Windows Browser gate also receives process-loopback +Opus on both native edges. A separate remote gate proves that `--link` generates +the ordinary public invitation and carries the unchanged Viewer page and +WebSocket control path, then disappears when the Client exits. An isolated +LiveKit gate also proves native capture through the loopback Browser bridge and +the existing SFU publisher to 1280x720 Viewer playback with complete cleanup. +Physical macOS capture, Linux native capture, and one-link Browser media remain +separate gates. + +The macOS arm64 capture sidecar compiles on GitHub `macos-15` and its +permission-free VideoToolbox self-test produces a constrained-baseline SPS/PPS/ +IDR. Real ScreenCaptureKit permission, source lifecycle, static-screen recovery, +and endurance remain physical acceptance gates. Source previews are best-effort; +the current macOS sidecar falls back to its source glyph until a physical +ScreenCaptureKit preview gate justifies a platform-specific implementation. + +The v5 loopback gate also proves that a real Chrome receiver produces Pion +transport feedback and that a non-unknown native sender-quality window reaches +the existing route controller. Unknown feedback and stopped-source windows stay +ineligible. + +A physical home-router gate created and removed a UPnP mapping for an ephemeral +UDP listener. The integrated native Host gate still passed capture, two-edge +delivery, source restart, and cleanup with mapping enabled. A selected mapped +path across a pair that fails with STUN alone remains field evidence rather than +an architectural claim. + +## Primary Sources + +- [Go child-process lifecycle](https://pkg.go.dev/os/exec) +- [WebRTC peer connections and signaling](https://webrtc.org/getting-started/peer-connections) +- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) +- [Cloudflare Tunnel WebSocket support](https://developers.cloudflare.com/cloudflare-one/faq/cloudflare-tunnels-faq/) +- [RFC 6455 WebSocket Origin model](https://www.rfc-editor.org/rfc/rfc6455.html) +- [Chrome Local Network Access](https://developer.chrome.com/blog/local-network-access) +- [Discord local RPC](https://docs.discord.com/developers/topics/rpc) +- [OBS WebSocket protocol](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md) +- [LocalSend protocol](https://github.com/localsend/protocol/blob/main/README.md) +- [Sunshine local Web UI](https://docs.lizardbyte.dev/projects/sunshine/latest/) +- [Syncthing local GUI/API](https://docs.syncthing.net/users/config.html) +- [Native Client media evidence](../research/native-client-media.md) diff --git a/docs/deployment.md b/docs/deployment.md index 8bdb230..919442e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -43,14 +43,83 @@ full revision, emits a runtime archive plus path/size/SHA-256 manifest and relea extracts its own artifact to verify it. Upload the archive, manifest, and descriptor together to `/opt/screener/uploads`. +The same application descriptor is also the Client assembly input. On each +target platform, provide that platform's Node executable and Go toolchain: + +```sh +SCREENER_GO=/path/to/go node scripts/assemble-client.mjs \ + /outside/repository/app-release/screener-.release.json \ + /path/to/node \ + /outside/repository/Screener-Client \ + --target windows-amd64 \ + --capture /path/to/platform-capture \ + --tunnel /path/to/cloudflared +``` + +Assembly refuses a dirty or different revision and emits one directory with +the Client executable, pinned Node runtime, application release, selected +sidecars, production dependencies, and matching `REVISION`. Its required target +is one of `windows-amd64`, `linux-amd64`, or `darwin-arm64`; every supplied +runtime must match it. It does not create an installer, auto-updater, release +tag, or compatibility bundle. + +CI and local release-candidate builds use the same wrapper on the target's +native operating system: + +```sh +node scripts/package-client-candidate.mjs \ + /outside/repository/app-release \ + windows-amd64 \ + /outside/repository/client-candidate +``` + +The wrapper downloads the pinned public-link sidecar, verifies its digest, +builds the target Client and available capture process, executes every packaged +runtime, and emits one `tar.gz` plus its SHA-256 file. + Retain the descriptor and successful deployment output as release metadata. Do not create a follow-up source commit solely to duplicate their revision, asset, or hashes. +After `validate` succeeds on a push to `main`, CI packages this application +release once and uses it to assemble Windows amd64, Linux amd64, and macOS arm64 +Client candidates on native runners. Each Client artifact contains one native +archive and its SHA-256 file; Actions retains candidates for 14 days. This is +automatic build output, not a tag, public GitHub Release, or deployment. + Do not build or run the full repository check on a constrained production host. The release wrapper installs only production dependencies in a transient, CPU/memory/time-bounded unit. +## Update Check + +The application, Server deployment, and platform Client all use the full Git +revision as their release identity. A formal GitHub Release must use that exact +40-character revision as its tag and keep the corresponding release URL. The +current CI workflow produces short-lived candidates but does not publish a +GitHub Release; publication remains an explicit distribution decision. + +The default Client launcher starts immediately, then performs one background +request to the official Screener GitHub Releases API. It shows a link only when +the latest release has a valid full revision different from the packaged one. +The request sends no current revision, credentials, room data, or media data; +network errors, private-repository responses, and missing releases are treated +as no notice. It never downloads, replaces, or interrupts a running share. + +An operator can perform the corresponding read-only Server check: + +```sh +SCREENER_NODE=/usr/local/bin/node bash deploy/check-release.sh +``` + +The command reads `/opt/screener/current/REVISION` and prints one JSON result. +Exit status `0` means the deployed revision is current, `10` means a newer +release is available, and `20` means the check could not establish a valid +release identity. For a private repository, inject a short-lived `GITHUB_TOKEN` +through the operator environment; never place it in the repository or command +line. The command does not mutate files, services, containers, or persistent +state. A different current-revision file may be supplied as its only argument. + ## Atomic Cutover Run the tracked server entry with the uploaded descriptor: diff --git a/docs/product/media-quality.md b/docs/product/media-quality.md index 9bd033e..3f49409 100644 --- a/docs/product/media-quality.md +++ b/docs/product/media-quality.md @@ -10,9 +10,14 @@ adaptation decision. - The Web Host may share a display, application window, or Browser tab and may stop, synchronously pause/resume audio and video, or switch source. +- A Client-launched Host explicitly chooses either that Browser capture path or + one native screen/window enumerated by the packaged platform capture boundary. The + latter uses one available hardware H.264 path and never infers a target from + a title. An ordinary Web Host does not probe localhost. - Share and source-switch requests ask the Browser for available audio by default. Missing audio is reported clearly but does not block video-only - sharing. + sharing. Native screen capture can include system playback audio and native + window capture can include selected-process audio when the platform exposes it. - Authoritative pause disables the current source and sender-owned tracks while retaining the room and established routes. Black frames, track mute, or network failure are not interpreted as a user pause. @@ -85,6 +90,11 @@ representations; Dynacast aggregates demand and server send-side BWE selects subscriber forwarding. AdaptiveStream stays disabled because any Viewer may relay its received track. +Tracks received from a PeerConnection do not own capture constraints. Native +capture therefore retains source resolution and frame-rate ownership across its +loopback Browser bridge; the existing Browser SFU publisher applies only sender +parameters and LiveKit's representation policy to that remote source. + When a live native sender remains persistently limited after capacity recovers, the route controller may rebuild that exact edge with a fresh connection and clone; media adaptation still stays entirely framework-owned. diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md index ac137f0..6006e76 100644 --- a/docs/product/presentation-lifecycle.md +++ b/docs/product/presentation-lifecycle.md @@ -17,6 +17,13 @@ room identity, current state, roster, and one manual media-reconnect action. Controls and text must remain usable without overlap or horizontal overflow on desktop and mobile viewports. +Screener Client opens this same application in the system Browser. Its small +startup surface selects Local, temporary public invitation, or a configured +Site before entering the Host workspace. Starting a share there offers the +Browser picker and any exact Client-owned native windows. It never chooses a +window automatically. A reproduced Browser-window, capture, or background +failure is required before introducing an embedded Web runtime. + ## Visual Language The interface presents one small shared living room rather than an operations @@ -118,7 +125,8 @@ button or a raw stats dump. Current Browser scope includes Web Host, Web Viewer, and Browser relay. Desktop and mobile Browsers are Viewer targets; mobile Web capture, reliable background -relay, native sender, and packaged capture helpers remain later platform work. +relay, native Client audio/SFU, and non-Windows capture remain later platform +work. ## Primary References diff --git a/docs/product/rooms-access.md b/docs/product/rooms-access.md index 1fabe70..f937b30 100644 --- a/docs/product/rooms-access.md +++ b/docs/product/rooms-access.md @@ -72,6 +72,17 @@ code-only attempts and does not silently revoke invitations. memory and all rooms disappear on restart. - **Stable mode:** with an explicit SQLite path, one exact-schema owner persists room authority across application restart. +- **Client Local mode:** the packaged Client composes the same memory RoomStore + and ends every room when its local authority exits. It persists only its Site + choice and generated Local site-access password, not rooms or media state. + +The Client opens its own localhost Host page with that password in a fragment. +The page removes the fragment and uses the existing SiteAccess endpoint; this +does not create a fourth admission authority. LAN and one-link Internet Viewers +use the same room-scoped invitation grant; its fragment never enters the public +tunnel request, and the Host's Local RoomStore remains the only authority. +Selecting a Site moves room authority wholly to that Site rather than +synchronizing two stores. Stable mode does not persist participants, signaling sessions, sharing state, routes, codec decisions, quality evidence, or SFU state. After restart, clients diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index 8eb34c2..f2cb40b 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -162,3 +162,18 @@ Direct P2P exposes endpoint network addresses to the trusted peer. SFU media is encrypted hop-by-hop with DTLS-SRTP but terminates at the SFU; the product does not claim operator-blind media without a separately accepted application E2EE design. All-UDP-blocked networks currently end in bounded failure. + +Client Local mode runs this same graph with peer-assisted media enabled and no +SFU or NAT prediction. It uses no STUN by default. Explicit one-link mode carries +only HTTP/WebSocket control through a temporary public tunnel and adds public +STUN to the same ordinary Browser media edges. Reachable peers may form the +ordinary Browser relay tree; an unreachable media path fails without adding a +route type, score, or fallback. Selecting a Site uses that deployment's current +transport configuration instead. + +A native Host reserves one additional loopback media edge for its system- +Browser preview and existing Browser SFU publisher. That local edge neither +consumes endpoint route-copy capacity nor starts gateway port mapping. Direct +children still use the native shared encode; if the controller assigns SFU, +the ordinary SFU publication consumes its existing route copy and retains the +same LiveKit representation and recovery policy as Browser capture. diff --git a/docs/project-memory.md b/docs/project-memory.md index e749333..41105d2 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,12 +1,25 @@ # Project Memory -Last updated: 2026-09-02 +Last updated: 2026-09-04 Screener is private, low-latency game screen sharing for one Host and up to 20 authenticated friends. The current product surface is Web Host, Web Viewer, and Browser relay; desktop and mobile Browsers are Viewer targets. It is not a -public broadcast service. Native capture, shared encoding, distributable -packages, and broader platform output remain later work. +public broadcast service. Source includes a cross-platform Client that reuses +the TypeScript product core in a self-contained Local deployment or opens one +configured Site through the system Browser. Its Go entry presents Local, +temporary public-link, and Site startup choices, supervises Local Node, and owns +the explicit native Host media path selected in the same Host UI. Local remains +a serverless-LAN mode by default; explicit one-link mode exposes that same +authority through a session-scoped public control tunnel while media remains +P2P. Windows capture, +hardware H.264, direct cross-NAT video, Windows process/system audio, and best-effort +mapping of the native edge's sole UDP socket are implemented through the same +media boundary. Native P2P edges feed Pion GCC's categorical payload-capacity +result into the existing route evidence windows. One reserved loopback edge +gives the system Browser a preview and lets native Host media reuse the existing +LiveKit publisher when SFU fallback is assigned; other platform capture and +broader package acceptance remain later gates. ## Product Map diff --git a/docs/research/cross-platform-client-runtime.md b/docs/research/cross-platform-client-runtime.md new file mode 100644 index 0000000..68c9963 --- /dev/null +++ b/docs/research/cross-platform-client-runtime.md @@ -0,0 +1,190 @@ +# Cross-Platform Client Runtime Research + +- Reviewed: 2026-09-04 +- Scope: loopback control and self-contained packaging boundary +- Status: Client and Local composition implemented; Windows native Host video + and a cross-NAT media gate proved + +## Reusable Evidence + +| Project | Observed boundary | Applicable lesson | +| --- | --- | --- | +| Discord RPC | Bounded local endpoints and an explicit handshake | Discovery and session negotiation can stay small | +| OBS WebSocket | Versioned `Hello`/`Identify` before commands | Reject unknown protocol shapes instead of adding compatibility readers | +| Syncthing | Go process with a loopback Browser GUI by default | A native entry and system-Browser UI are a practical cross-platform pair | +| Sunshine | Native streaming host configured through a localhost Browser UI | Native media does not require a second desktop UI | +| LocalSend | One peer exposes a local HTTP service without an external server | LAN operation can be self-contained, while network reachability remains physical | + +These are architecture references only. No implementation or copyleft source is +copied. + +A read-only inventory of locally installed streaming clients adds one practical +constraint. GameViewer separates its UI, supervised media service, codec +capability detector, streamer, health process, and updater. Discord keeps its +Chromium UI separate from a native voice/media module and updater. Oopz ships +Flutter, a second WebView, FFmpeg, Agora RTC, and Tencent LiteAV together; that +stack is substantially larger and duplicates media ownership. Screener follows +the first two products' process separation but not their UI runtimes, and avoids +Oopz's parallel RTC stacks: one Go supervisor, one Pion media core, and one thin +system capture sidecar per platform remain sufficient. No binary code or private +application data was copied or inspected. + +## Current Control Boundary + +The Client binds IPv4 loopback on a bounded range (`39721`-`39730`). `/health` +returns the protocol version, service identity, selected port, and an +`instanceToken`. `/control` admits one WebSocket session whose subprotocol +contains that same value. The first frame must be `hello`; subsequent requests +are limited to capability discovery, source preview, and one generation-fenced +share's media controls. + +The instance token distinguishes a newly discovered process from a stale or +unrelated listener. It is visible through health and stdout and is not an +authentication secret. Host and Origin checks plus Chromium's Local Network +Access permission form the Browser boundary. + +The current protocol advertises only the native capabilities and +generation-fenced media commands that have consumers. It carries no room +credentials or route policy; those stay in the Browser/server protocol. + +## Client Composition + +The existing TypeScript server owns the room and route contract. A local package +therefore composes three artifacts behind one user entry: + +```text +screener-client +runtime/node +app/dist +``` + +The Client's Go entry starts and supervises the pinned Node runtime in Local +mode; Node serves the same Screener application with explicit local +configuration. In Site mode the system Browser opens the saved Site while the +same Go process remains its loopback native-media owner. The system Browser +remains the UI. + +Official Go process APIs require every started child to be waited and provide a +bounded `WaitDelay` for cancellation and stuck I/O. The current supervisor uses +stdin EOF for graceful Node shutdown, waits for the child, and does not restart +it. Windows is physically exercised; macOS and Linux remain package gates. + +## Local And Hosted Reachability + +A local page completed discovery and the full control exchange in Chromium +151.0.7922.138. The hosted Screener origin completed it after Chromium received +its `loopback-network` permission; without that permission the cross-origin +request was blocked. The gate checks both expected outcomes. + +A self-contained LAN room uses a localhost Host URL and a selected LAN IPv4 +Viewer URL backed by the same local TypeScript process. Its generated access +password is persisted once and passed only to the Client's Host page in a +consumed fragment. Explicit `--link` mode launches the packaged Cloudflare Quick +Tunnel against that same local HTTP server and supplies its random HTTPS origin +to Node before startup. The public path therefore reuses the exact frontend, +WebSocket signaling, RoomStore, and Viewer grant instead of adding a rendezvous +or second client protocol. Cloudflare carries control traffic; WebRTC media uses +public STUN and remains P2P-only. + +## UI Runtime Boundary + +The current Client opens the system Browser rather than embedding another Web +runtime. A lightweight launcher chooses the authority mode, then navigates to +the same Host page, where a Client-launched Host can select an exact native +window. This keeps the existing UI, Site cookies, WebRTC behavior, Browser +updates, and permission model intact. Syncthing and Sunshine establish this as a +practical native-process plus Browser-UI deployment shape. + +An embedded shell remains a decision gate rather than a rejected category. +Current WebView2 exposes `getDisplayMedia` screen-capture events and its +Evergreen runtime updates independently; WebKitGTK exposes display capture and +uses GStreamer for WebRTC. Electron instead bundles one Chromium and Node but +makes the Client responsible for shipping those updates and adds substantial +package size. These facts do not prove equivalent Screener media behavior. + +Only a reproduced Browser-launch failure should open a comparative physical +gate for Electron, Tauri, Wails, Neutralino, or another shell. That gate must +compare actual capture, audio, H.264/VP8 sender implementation, 1080p cadence, +relay, background behavior, and resource use on every target platform. The +result selects one UI runtime; it does not create parallel products. + +## Current Evidence + +On Windows with Chrome for Testing 151.0.7922.138: + +- the Local Client started its built static application and reached `/healthz`; +- the Host fragment was removed, existing SiteAccess became authenticated, and + the ordinary Host page loaded; +- the room API generated an invitation from the selected LAN origin; +- one synthetic Host and three Viewers formed a P2P-only tree with one relay, + every Viewer advanced decoded frames, and no SFU publication appeared; and +- Enter-driven Client shutdown released the Browser, Client, Node, loopback and + application ports, and disposable profile. +- the immutable App release was assembled with Node 24.19.0 and the Go Client + from the same full revision; that packaged directory passed the Local and Site + gates, while a deliberately mismatched `app/REVISION` was rejected before a + listener started. +- the explicit target assembler produced Windows amd64, Linux amd64, and macOS + arm64 outputs from one clean revision. The Linux package ran its bundled Node + Local authority on Ubuntu, and its full package exposed and then closed a + public link reachable from another network. The macOS Client and Node inputs + are both Mach-O arm64; execution there remains unproved. +- the native Host path created a Local room and a remote Pion Viewer received + 30 packets over a selected `srflx`-to-`srflx` pair; the reverse SSH link in + that gate carried signaling only. +- a Windows Client created an accountless Quick Tunnel, generated its ordinary + invitation on that origin, and served the Viewer page plus `/signal` WebSocket + upgrade to an independent Linux host; a native Host then repeatedly delivered + 30+ H.264 RTP packets to a Linux Pion Viewer over selected direct paths using + a reflexive candidate. Stopping the Client closed the URL. + +The one-link gate used the official Windows amd64 `cloudflared` 2026.8.3 asset +with SHA-256 `83e726ed18ea78c5ad5213c4c3a3a27051393950d2bc8ed4de69bec12d14eaae`. +The sidecar remains an explicit package input rather than a linked Client +dependency. + +The peer topology gate now treats native `getStats()` RTP identity and frame +totals as its portable core proof. Detailed Screener quality metrics enrich a +development run when its source module is served, but their absence on a static +deployment no longer erases valid native RTCStats. + +## Remaining Gates + +- Run the exact clean-revision package on macOS; Linux amd64 is proved. +- Prove a physical second-device LAN Viewer on desktop, Android, and iOS. +- Prove first-run Browser local-network permission outside CDP automation. +- Record no-STUN mDNS behavior on ordinary and AP-isolated LANs. +- Exercise the native Host on a configured Site and representative Browsers; + the local SFU bridge is proved in isolation, while full Site recovery, + representative audio, and non-Windows capture remain undefined. +- Exercise one-link Browser media on a physical second device; the current gate + proves the complete public control path and independent Pion media, not + decoded Browser frames. + +## Sources + +- [Go `os/exec`](https://pkg.go.dev/os/exec) +- [WebRTC peer connections](https://webrtc.org/getting-started/peer-connections) +- [Cloudflare public STUN endpoint](https://developers.cloudflare.com/realtime/turn/) +- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) +- [Cloudflare Tunnel WebSockets](https://developers.cloudflare.com/cloudflare-one/faq/cloudflare-tunnels-faq/) +- [cloudflared releases](https://github.com/cloudflare/cloudflared/releases/tag/2026.8.3) +- [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455.html) +- [W3C Screen Capture](https://www.w3.org/TR/screen-capture/) +- [Microsoft Windows Graphics Capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture) +- [Apple SCContentSharingPicker](https://developer.apple.com/documentation/screencapturekit/sccontentsharingpicker) +- [XDG ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html) +- [OBS window-capture source](https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/window-capture.c) +- [Chrome Local Network Access](https://developer.chrome.com/blog/local-network-access) +- [Discord RPC](https://docs.discord.com/developers/topics/rpc) +- [OBS WebSocket](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md) +- [LocalSend protocol](https://github.com/localsend/protocol/blob/main/README.md) +- [Sunshine](https://docs.lizardbyte.dev/projects/sunshine/latest/) +- [Syncthing GUI configuration](https://docs.syncthing.net/users/config.html) +- [GameViewer product](https://uuyc.163.com/) +- [Discord desktop architecture](https://discord.com/blog/how-discord-maintains-performance-while-adding-features) +- [WebView2 Evergreen runtime](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/evergreen-vs-fixed-version) +- [WebView2 screen capture](https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2screencapturestartingeventargs) +- [WebKitGTK multimedia](https://docs.webkit.org/Ports/WebKitGTK%20and%20WPE%20WebKit/Multimedia.html) +- [WebKitGTK display capture](https://webkitgtk.org/reference/webkit2gtk/stable/class.WebView.html) +- [Electron architecture](https://www.electronjs.org/docs/latest/why-electron) diff --git a/docs/research/native-client-media.md b/docs/research/native-client-media.md new file mode 100644 index 0000000..4f4feb7 --- /dev/null +++ b/docs/research/native-client-media.md @@ -0,0 +1,180 @@ +# Native Client Media Evidence + +- Reviewed: 2026-09-04 +- Scope: Windows capture, one shared H.264/Opus source, Pion transport, Browser + decode, and Browser-mediated SFU fallback +- Status: native Host, cross-NAT video, Windows process/system audio, capture-failure + restart, native P2P quality-evidence, and native-source SFU gates passed; + other platform media remain outside the boundary + +## Result + +Screener Client can own one process-isolated Windows capture and hardware H.264 +encoder, feed its Annex-B access units into one Pion source, and deliver that +source through independent WebRTC transports to unmodified Chrome receivers. +The Browser remains the room, route, and signaling authority. + +The same Client can run the Local authority, open the normal Host page, and +attach that native source to the current route. A remote Pion Viewer then +received 30 packets over a selected `srflx`-to-`srflx` pair. The validation +session carried signaling through a temporary reverse SSH path only; media was +negotiated directly by ICE. + +The one-link form carried the current signaling protocol through its temporary +public WSS origin to an independent Linux Pion Viewer. Repeated runs delivered +30+ packets over selected direct paths using a reflexive candidate; the Quick +Tunnel carried no media. + +An isolated LiveKit 1.13.6 gate carried the same native source through one +reserved local Pion edge into the existing Browser `SfuPublisher`; an ordinary +LiveKit Viewer then received 30 consecutive 1280x720 frames. The direct native +P2P path, Browser, Client, LiveKit process, ports, and profiles all cleaned up. + +The opt-in `gate:client-media` run proved, in order: + +- WGC capture and adapter-bound Media Foundation H.264 became active; +- 30+ encoded 1280x720 frames crossed the bounded process protocol; +- a PLI caused a later recovery unit; +- the packaged Client exposed only separately probed video, process-audio, + system-audio, and hardware-H.264 capability booleans before control connected; +- two Pion ICE/DTLS/SRTP edges each delivered 30+ decoded 1280x720 frames + to Chrome from that one encoded source; +- the self-hosted STUN endpoint produced ordinary `host` and `srflx` + candidates; +- process-loopback audio produced non-zero PCM, Opus RTP, and non-zero decoded + audio energy on both native edges; the same checks pass for a display using + system loopback; +- closing the captured source ended its share, released the old media path, and + a second capture generation in the same room delivered a different media + object plus 30 new frames to the existing Viewer; +- Client, capture, Browser, ports, and the isolated profile all closed. + +Unit coverage separately enforces the supplied edge capacity, forwards PLI/FIR +to the shared video source, validates the same PeerConnection's Opus section, +and bounds the pure-Go Opus encoder's steady-state allocations. The native Host +session starts the target-appropriate loopback audio only when the capture probe +advertises it; an audio start failure leaves the video session alive. The +cross-NAT result proves reachability and RTP delivery, not heterogeneous +congestion behavior or a paired no-Site Browser media session. + +The audio adapter uses `github.com/thesyncim/gopus` behind the private +`nativeaudio` boundary. It accepts fixed 48 kHz stereo PCM16/20 ms frames, +encodes into a caller-owned Opus buffer, and can be replaced without changing +the media edge or control wire. The dependency is BSD-3-Clause licensed and +requires Go 1.25; Client CI uses Go 1.26.6. + +## Current Boundary + +The result does not yet prove live native quality-profile changes, macOS/Linux +capture, or endurance. Native Host media is exposed only through the explicit +Client-launched Host selection; these other capabilities remain unavailable there. + +Native code does not publish directly to LiveKit. One local Pion edge gives the +system Browser a remote `MediaStreamTrack`; WebRTC requires that remote track to +reject capture constraints, so native capture keeps source ownership while the +existing SFU publisher owns sender parameters, simulcast, Dynacast, and recovery. +The bridge adds one local decode for Host preview and a Browser encode only when +SFU publication is active. Direct P2P children continue to reuse the one native +H.264 encode. This preserves the accepted SFU behavior without another LiveKit +SDK or media policy. +Native P2P edges negotiate transport-wide feedback and use Pion's send-side GCC +with its immediate no-op pacer. The pacer neither queues nor applies one edge's +estimate to the shared encoder. Once real feedback and source frames exist, the +edge compares GCC's target payload bitrate with the H.264 plus Opus payload +actually produced in the same window. A lower target is `degraded/bandwidth`, a +sufficient target is `healthy/none`, and absent feedback or source progress is +`unknown`. The existing two-second evidence cadence and three-window route rule +own persistence. This is a direct capacity relation, not a loss/RTT score or a +new adaptation ladder. The known Pion no-op-pacer issue concerns separately +negotiated RTX SSRCs; the current native H.264 contract has no RTX codec and must +reopen that choice before adding one. + +No-Site Internet control can use the one-link mode owned by ADR-0010. Its remote +Pion gate proves public signaling plus direct media, but not decoded Browser +media on a physical second device. Cloudflare carries HTTPS/WebSocket control +and provides public STUN, while DTLS-SRTP media remains direct. STUN alone +cannot replace SFU/TURN on a restricted pair. + +Native shares use the same Pion UDP socket for all edges. Site and one-link +shares make one bounded, best-effort PCP, UPnP, or NAT-PMP mapping through +NetBird's standalone Go NAT package; pure LAN Local mode does not. A physical +router created and removed an ephemeral UPnP mapping; the Apache-2.0 dependency +added about 0.38 MiB to the stripped Windows Client. Mapping begins +with the share, is awaited before the first PeerConnection, refreshes only when +a later edge arrives after half the requested lease, and is removed with the +engine. Failure is cached for that share and leaves ordinary ICE/STUN unchanged. +This proves lifecycle and non-regression, not that a mapped candidate has yet +rescued a pair that public STUN alone could not connect. + +The retained hardware fixture also changed one live NVIDIA MFT through +`3 Mbps -> 1.5 Mbps -> 3 Mbps` without recreating it. Equal 120-frame phases +produced about `1.00 MB -> 0.68 MB -> 0.97 MB`, while ordered 30 fps output and +the pinned profile remained intact. Both AMD MFT candidates failed activation +before this probe, so the evidence is not a cross-vendor dynamic-rate contract. +The product therefore observes GCC for routing but does not yet apply one edge's +target globally to the shared encoder. + +The Windows source boundary now enumerates displays and windows and returns a +bounded best-effort preview. A display uses WGC plus default render-device +loopback audio; a window uses WGC plus process-tree loopback when that actual +capability probe succeeds. Preview failure is advisory and cannot tear down +the control session. + +Non-Windows capture keeps the existing process/frame boundary and replaces only +the platform sidecar. The macOS candidate enumerates `SCShareableContent`, fences +the selected process/window generation, receives change-driven +`CMSampleBuffer`s, and requires VideoToolbox constrained-baseline hardware H.264. +It retains one latest pixel buffer so an existing PLI/FIR can encode a fresh IDR +even while the screen is unchanged; capture timestamps drive the shared Pion RTP +clock. A GitHub `macos-15` arm64 runner compiled the sidecar, created the same +hardware-only encoder, and encoded an in-memory 420v frame into a validated +`42c01f` SPS/PPS/IDR. ScreenCaptureKit permission, real capture, static-frame +recovery, and endurance still require a physical Mac. + +Linux native capture remains no-go for the current stage. The XDG ScreenCast +Portal owns a user-selected session and restricted PipeWire file descriptor, not +the Windows-style pre-enumerated window target or an encoded stream. A thin +future Wayland-only gate may dynamically use the distribution's GStreamer for +`pipewiresrc`, one bounded queue, one proved hardware H.264 element, `h264parse`, +and `appsink`; it must not ship GStreamer, add `webrtcbin`, implement X11 capture, +or pretend that the portal provides target-process audio. Without that system +runtime, direct DMA-BUF import plus VAAPI/Vulkan encoding is not a small adapter. + +## Implementation Boundary + +- `nativecapture` owns the child process, source identity, and bounded frame protocol. +- `mediaedge` owns the stable Pion API, one UDP mux, shared H.264/Opus sources, + and independent PeerConnections. +- `nativehost` composes one capture generation with its bounded edges. +- `nativecontrol` maps only local source/share/edge commands and exact native + sender quality windows to the loopback v5 wire. + +The deleted sender application, UI, room client, and old wire are not +compatibility inputs. Historical measurements remain in the separately marked +[native sender evidence](./native-sender.md). + +## Primary Sources + +- [Windows Graphics Capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture) +- [WGC `CreateForWindow`](https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow) +- [WASAPI process loopback](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/) +- [OBS application-audio capture guide](https://obsproject.com/kb/application-audio-capture-guide/) +- [Media Foundation hardware MFTs](https://learn.microsoft.com/en-us/windows/win32/medfound/hardware-mfts) +- [Pion v4.2.18 stats implementation](https://github.com/pion/webrtc/blob/v4.2.18/stats.go) +- [Pion Google congestion control](https://github.com/pion/interceptor/tree/v0.1.47/pkg/gcc) +- [Pion bandwidth-estimation example](https://github.com/pion/webrtc/tree/v4.2.18/examples/bandwidth-estimation-from-disk) +- [WebRTC Stats target bitrate and limitation semantics](https://www.w3.org/TR/webrtc-stats/) +- [Pion no-op pacer RTX issue](https://github.com/pion/interceptor/issues/406) +- [WebRTC remote-track constraints](https://www.w3.org/TR/webrtc/#mediastreamtrack-network-use) +- [Pion single-port ICE](https://github.com/pion/webrtc/tree/master/examples/ice-single-port) +- [gopus pure-Go Opus codec](https://github.com/thesyncim/gopus) +- [Tailscale port mapper](https://github.com/tailscale/tailscale/tree/main/net/portmapper) +- [libp2p NAT port mapping](https://github.com/libp2p/go-libp2p/blob/master/options.go) +- [NetBird standalone Go NAT](https://github.com/netbirdio/go-nat) +- [WebRTC signaling and ICE](https://webrtc.org/getting-started/peer-connections) +- [Apple ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit) +- [ScreenCaptureKit idle frames](https://developer.apple.com/documentation/screencapturekit/scframestatus/idle) +- [Apple VideoToolbox hardware encoder requirement](https://developer.apple.com/documentation/videotoolbox/kvtvideoencoderspecification_requirehardwareacceleratedvideoencoder) +- [XDG ScreenCast Portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html) +- [PipeWire DMA-BUF contract](https://docs.pipewire.org/devel/page_dma_buf.html) +- [GStreamer PipeWire source](https://gstreamer.freedesktop.org/documentation/pipewire/pipewiresrc.html) diff --git a/docs/research/native-sender.md b/docs/research/native-sender.md index 7315f35..65bc197 100644 --- a/docs/research/native-sender.md +++ b/docs/research/native-sender.md @@ -2,7 +2,7 @@ - Reviewed: 2026-08-27 - Scope: Windows capture, one hardware H.264 encoder, bounded WebRTC fanout -- Status: later product candidate; one-Viewer functional/hardware evidence only +- Status: historical evidence; obsolete sender implementation deleted Current product scope is owned by [media quality](../product/media-quality.md). [ADR-0006](../adr/0006-fixed-high-native-sender-canary.md) owns the stop line. @@ -14,7 +14,7 @@ independent WebRTC transports. Shared encoding can reduce capture/encode work; it cannot remove each child's RTP, congestion, retransmission, encryption, and network copy. -The retained evidence proves: +The retained measurements prove: - Pion can fan one encoded source into two independent transport bindings; - one WebCodecs encoder object fed two Browser sessions in a bounded fixture; @@ -30,7 +30,7 @@ or distribution licensing. Native remains outside the current release. ## Candidate Shape -The smallest retained Windows architecture is: +The smallest measured Windows pipeline was: ```text opaque window target diff --git a/docs/status.md b/docs/status.md index bd18359..cf09872 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Current Status -Last updated: 2026-09-02 +Last updated: 2026-09-04 This is the current execution index. Git history owns completed timelines; [verification status](./verification-status.md) owns evidence boundaries. @@ -120,8 +120,35 @@ This is the current execution index. Git history owns completed timelines; - Current-path quality can drive local convergence in source and production, including bounded same-edge connection regeneration. Both default it on with a pre-share Host opt-out. Weighted/global optimization remains parked. -- Native/executable senders, shared encode, and distribution packages remain - outside the current release. +- Source contains the cross-platform Client outside the current Web release. + One Go entry opens a lightweight system-Browser launcher for Local, temporary + public-link, or Site operation, supervises the same Node application when + needed, and owns loopback native media. The same Host UI explicitly selects + Browser capture or an exact native screen/window. Windows gates prove + packaged Local/Site operation, WGC hardware-H.264 plus process/system audio, bounded + shared-encode P2P, and cleanup. The window arm also proves source end and + same-room reselection; display-source lifecycle remains a separate physical + gate. An explicit target assembler also produces a + Linux amd64 package with proved Local/one-link runtime and a structurally + verified but unexecuted macOS arm64 package; its capture sidecar now passes an + arm64 compile plus hardware-H.264 IDR self-test. Client-scoped pull requests + build and Local-smoke all three candidates before merge; a validated `main` + push emits them as short-lived Actions artifacts. One-link mode preserves the Host Local + authority while exposing its ordinary invitation and HTTP/WebSocket control + path through a session-scoped Quick Tunnel. An independent Linux Pion Viewer + has received that native media over a direct ICE pair; remote Browser media + remains unproved. Site and one-link native shares also make one bounded + best-effort PCP/UPnP/NAT-PMP mapping for their sole UDP socket without + changing ICE when no gateway accepts it; pure LAN Local mode does not. + Native P2P edges now negotiate TWCC and send Pion GCC's target-versus-source + payload category through the existing sender-quality evidence path without + pacing or changing the shared encode. A local bridge now supplies native Host + media to the existing Browser LiveKit publisher; an isolated LiveKit gate + proves 1280x720 delivery and complete cleanup without a second SFU client. + Non-Windows capture remains outside the release. The default packaged Client + launcher performs a non-blocking official GitHub Release check and the + deployment tree provides a read-only operator check against the same full-SHA + release identity; neither installs or interrupts a running share. ## Current Hold diff --git a/docs/todo.md b/docs/todo.md index 080095a..94dccff 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,14 +1,44 @@ # Current TODO Ledger -Last reviewed: 2026-09-02 +Last reviewed: 2026-09-04 Only **Now** is executable. Observations, old branches, experiments, and parked topics are not implementation authority. ## Now -No executable product work is currently accepted. Items below remain parked -until their stated boundary is met. +1. **Native Client media boundary.** Windows native Host video now runs through + the current Browser-owned room and route contract. Local Client startup, + hardware-H.264 capture, bounded Pion fanout, and a remote `srflx`-to-`srflx` + video gate now pass. Process-loopback audio passes the Windows Browser gate + on the same PeerConnection with a video-only fallback. Pion TWCC/GCC now + supplies exact native P2P sender-quality evidence through the existing route + windows without a custom score. A reserved local bridge also passes the + native-source SFU gate through the existing Browser LiveKit publisher and + its existing representation policy. + Capture-source failure now has a physical end/restart gate through the same + room and Viewer. Clean-revision Windows and Linux packages pass their Local + runtime gates; Linux also passes one-link startup and shutdown. The macOS + arm64 sidecar passes hosted compilation and a synthetic VideoToolbox hardware- + H.264 IDR gate; physically run ScreenCaptureKit capture and recovery on macOS. + Linux retains Browser capture. Reopen a + Wayland-only Portal/PipeWire sidecar only with a real desktop/GPU gate and a + decision to reuse system GStreamer without bundling it; do not add a second + RTC, X11 capture stack, or hand-built DMA-BUF/encoder matrix. + The system-Browser launcher now owns Local, temporary public-link, and Site + selection, and the Host explicitly chooses Browser capture or an exact + Client-owned screen/window without command-line target input. Its Windows + physical gates pass window and display capture, bounded source previews, + process/system audio, and restored Viewer delivery. The window arm also + proves source end and same-room reselection; display-source lifecycle remains + a separate physical gate. + No-Site Internet mode now has one ordinary public invitation link that retains + the Host's Local authority and passes remote control-path and independent + Linux Pion media gates. Prove decoded one-link Browser media on a physical + second device and a selected mapped path on a pair that public STUN alone + cannot connect. Port mapping is implemented and physically creates/releases + a router mapping, but it cannot replace an SFU/TURN fallback for a restricted + pair. ## Parked Product Work @@ -70,18 +100,16 @@ until their stated boundary is met. adding a parallel security framework. 10. **Public-server package.** Package the exact Web/signaling, STUN/SFU, proxy, secrets, health, and recovery contract for a user-owned server. -11. **Fully local package.** Package Host capture, application server, and local - state for Windows/macOS/Linux with honest TLS, gateway, NAT, and firewall - limits. -12. **Native Host and shared encode.** Revisit Windows first only after capture, - hardware encode, audio, RTP feedback, resources, packaging, licensing, and - Browser interoperability are proved. -13. **Platform output.** System/tab mirroring needs no product adapter, while +11. **Native media endurance.** Exercise long-lived native P2P and Browser- + mediated SFU recovery after the platform capture boundary passes. Preserve + Browser/server authority and the existing LiveKit representation policy; do + not create a second room or media policy. +12. **Platform output.** System/tab mirroring needs no product adapter, while Remote Playback, default Cast and AirPlay do not provide a portable live `MediaStream` output contract. Reopen only for a registered custom receiver acting as an ordinary Viewer after a named Browser and physical receiver pass the [platform-output gate](./research/platform-output.md). -14. **NAT inference and predictive candidates.** The deployment-gated, +13. **NAT inference and predictive candidates.** The deployment-gated, connection-local capability is accepted under [ADR-0009](./adr/0009-optional-nat-prediction.md). Production enables its self-hosted field rollout; the general configuration default stays off. @@ -92,11 +120,28 @@ until their stated boundary is met. convergence currently consumes each deferred Peer parent once; add no cross-generation retry budget until field evidence proves that fresh ICE generations expose a repeatable prediction opportunity. -15. **Unresolved route-state ownership claims.** Reopen lower-revision +14. **Unresolved route-state ownership claims.** Reopen lower-revision reauthentication, active SFU failure during an unrelated prepare, and multi-child relay-evidence ownership only from an exact current-wire reproduction. Do not add a second revision namespace, parallel failure state, or generalized evidence map from static possibility alone. +15. **Unified release and update surface.** Build Client packages, Server + runtime, future OCI images, and deployment bundles from one intentionally + triggered immutable application release identified by its full commit SHA. + A validated `main` push now produces that application release and three + native-runner Client candidates with SHA-256 metadata. The default Client + launcher now performs a non-blocking GitHub Releases check and the tracked + deployment tree provides an operator-invoked read-only Server check. Formal + GitHub Release publication, future OCI images, and deployment bundles remain + explicit distribution work; do not add automatic install, container + self-update, Watchtower, compatibility ranges, or active-share interruption + before distribution and rollback evidence requires them. +16. **Go server consolidation.** Revisit replacing the sole TypeScript/Node + server owner with Go only after the Client feature and physical acceptance + boundaries are complete. First measure package, startup, and maintenance + gains. If accepted, migrate Hosted and Local together and delete the Node + server in the same boundary; do not create or retain two room, signaling, + persistence, or route-controller implementations. ## Decision Needed diff --git a/docs/verification-status.md b/docs/verification-status.md index 5cf927e..b09260d 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -1,6 +1,6 @@ # Verification Status -Last updated: 2026-08-30 +Last updated: 2026-09-04 This file owns only cross-module physical evidence that still changes how the current product may be described. [Status](./status.md) owns exact source and @@ -70,6 +70,59 @@ page retention, or capture keepalive. See [presentation and lifecycle](./product/presentation-lifecycle.md) and [background capture research](./research/browser-background-capture.md). +## Screener Client + +The Go Client has unit coverage for persistent Local/Site configuration, LAN +address selection, Browser launch commands, bounded Node supervision, its +loopback port/Origin/Host/session contract, and exact package revision. The same +source cross-builds for Windows amd64, macOS arm64, and Linux amd64. + +On Windows, Chrome for Testing 151.0.7922.138 completed the built Local page's +fragment access, Host surface, LAN invitation, and clean Client/Node/Browser +shutdown. A separate synthetic run formed a P2P-only Host-plus-three-Viewer tree +with one Browser relay and advancing frames at every Viewer. The Hosted Site +completed loopback `hello`/`ping` after CDP granted `loopback-network`; without +that permission Chrome blocked it as expected. +The exact clean-revision Windows package assembled with Node 24.19.0 passed both +Client gates; changing `app/REVISION` made it fail before opening a listener. +The same explicit-target assembly produced a Linux amd64 package that ran its +bundled Node application and loopback runtime on an independent Ubuntu host. +Its full package also created a public link reachable from another network and +closed that link and all local ports on exit. The macOS arm64 output contains +matching Mach-O arm64 Client and Node binaries; its capture sidecar compiles on +the macOS runner and passes the synthetic hardware-H.264 IDR self-test, but the +full package and ScreenCaptureKit path have not run on a physical Mac. + +Still required are physical second-device LAN playback, first-run LNA prompt, +no-STUN mDNS behavior, macOS package execution, and equivalent non-Windows +capture, audio, firewall, and process-lifecycle tests. Windows native capture, hardware H.264, +one shared Pion source feeding two Chrome transports, STUN candidate gathering, +and a Local Client Host path have bounded physical gates. A remote Pion gate +also received 30 video packets over a selected `srflx`-to-`srflx` pair; its +signaling used a temporary reverse SSH test path. The Windows media gate now +also receives non-zero process/system-loopback Opus on both native edges and +verifies decoded audio energy. The native Host gate passes display and window +source selection and bounded previews; its window arm closes the captured +source, observes the current share end, restarts capture in the same room, and +requires the existing Viewer to receive a different media object plus 30 new +frames. Display-source lifecycle is not claimed by that arm. +Native P2P quality evidence and the Browser-mediated native-source SFU happy +path now have bounded gates. Production package integration, SFU recovery and +endurance, and physical non-Windows capture remain unproved. + +The one-link gate started a session-scoped public origin from a Windows Client, +confirmed that its ordinary invitation used that origin, and served the Viewer +page plus the existing `/signal` WebSocket upgrade to an independent Linux host. +A native Host then used that public signaling path with an independent Linux +Pion Viewer; repeated runs delivered 30+ H.264 RTP packets over selected direct +paths using a reflexive candidate. Client exit stopped Node and the public link. +This still does not prove decoded Browser media on a physical second device. + +The same clean revision assembled into a self-contained Windows package with a +matching Node runtime, application tree, and native capture process. Chrome 151 +passed the package's Local startup gate and both Site loopback-permission arms; +revision mismatch remains fail-closed before a listener starts. + ## Interpretation Rules - Configuration, unit tests, loopback, and synthetic signaling prove invariants, diff --git a/native/client/README.md b/native/client/README.md new file mode 100644 index 0000000..d147b47 --- /dev/null +++ b/native/client/README.md @@ -0,0 +1,221 @@ +# Screener Client + +Screener Client is the self-contained and native-capability runtime for the same +Browser application used by Hosted Screener. It does not implement another UI, +room store, signaling protocol, or route controller. + +## Modes + +- With no mode argument, the Client opens a small launcher in the system + Browser. It selects Local, a temporary public HTTPS invitation, or a saved + Site, then enters the normal Host page. +- The launcher remembers the Site address but keeps the per-run mode choice + separate. Local and one-link modes retain the self-contained authority; a + configured Site owns rooms, persistence, routing, and SFU. +- `--site`, `--local`, and `--link` remain deterministic automation inputs for + CI and development. They are not required for normal use. +- The default launcher checks the official GitHub Releases metadata after it + opens and shows a notice when a newer full-SHA release exists. The check is + best-effort and never installs or replaces the Client. + +Local mode uses memory-only rooms, Browser P2P relay, no LiveKit, and no NAT +prediction. Ordinary Local works on a reachable LAN. The **Public invite** mode +runs the packaged Cloudflare Tunnel sidecar for the existing HTTP/WebSocket +control surface and +uses Cloudflare's public STUN for ordinary Browser media edges. Media does not +travel through the HTTP tunnel, and a difficult media path still has no SFU or +TURN fallback. The Client chooses a sole private LAN IPv4 automatically. Use +`--lan-address
` only when multiple real LAN interfaces are active. + +The system Browser remains the Host UI. A Client-launched Host offers the +Browser's standard capture picker and a list of exact platform capture targets; +the user selects one explicitly. The Client selects one available hardware +H.264 path. The current Windows sidecar uses Graphics Capture and, on supported +builds, captures that process's audio with WASAPI. Video and audio share the +same room route and PeerConnection. Native media in Site or one-link mode also +attempts one bounded PCP, UPnP, or NAT-PMP +mapping for its sole Pion UDP socket; pure LAN Local mode does not. Routers +without a mapping service continue with ordinary ICE/STUN. The mapping does not +create a relay or carry media through the Client control link. A configured Site +may route the native source through its existing Browser LiveKit publisher; +Local and one-link modes remain P2P-only. An ordinary Web Host keeps the +Browser capture path without probing the Client. + +Native P2P edges negotiate transport-wide feedback. Once Pion GCC has real +feedback and the source has produced frames, the Client reports whether that +edge's target payload bitrate can carry the measured shared H.264 plus Opus +payload. The existing route controller owns persistence and any replacement; +the Client does not pace, score, or globally lower the shared encoder. + +The first Local launch creates one random access password in the user +configuration directory. The Client passes it to its own Host page in a URL +fragment; the page uses the existing SiteAccess endpoint and removes the +fragment before continuing. Viewer invitations keep using the existing +room-scoped grant. + +Press Enter in the Client console to end Local rooms and stop the bundled +server and any temporary public link. A Site-loaded Browser tab does not own the +Client process. + +For one-link Internet sharing, open the Client launcher, choose **Public invite**, +create a room in the opened Browser, and send its normal invitation link. The +random `trycloudflare.com` origin lasts only for that Client run. Cloudflare Quick +Tunnels provide no uptime guarantee; use a configured Site when persistent +control availability or SFU fallback matters. + +## Development + +```sh +cd native/client +go test ./... +go vet ./... +go run ./cmd/screener-client \ + --node /path/to/node \ + --app /path/to/Screener +``` + +The repository-level entry used locally and by CI is: + +```sh +npm run check:client +``` + +On Windows, its generated Client, capture, and media-test executables are +written to the ignored repository `build/client-check` directory and reused on +the next run. This keeps the executable identity stable for the system firewall; +the files are local build output and are never packaged or committed. + +It runs Go formatting, unit tests, vet, and the three supported cross-builds. +On Windows and macOS it also compiles the matching isolated capture process and +validates its bounded capability response; macOS additionally encodes one +in-memory hardware H.264 IDR. Real capture, GPU attribution, Browser decode, and +public-network paths remain explicit physical gates rather than environment- +dependent unit tests. + +The loopback service binds IPv4 loopback on the first available port from +`39721` through `39730`. `/health` discovers the current process; `/control` +accepts one strict v5 session. After `hello`, an available Client may list local +capture choices and own one generation-fenced share's SDP/ICE edges, including +one reserved local Browser bridge. Its public `instanceToken` distinguishes the +discovered process but is not authentication; room authority and remote +signaling remain in the Browser. + +## Packaging + +Build one application release, then assemble a platform Client from that exact +descriptor and a matching platform Node executable. Native capture and the +public-link sidecar are explicit package inputs: + +```sh +node scripts/package-app-release.mjs /outside/repository/app-release +SCREENER_GO=/path/to/go \ + node scripts/assemble-client.mjs \ + /outside/repository/app-release/screener-.release.json \ + /path/to/node \ + /outside/repository/Screener-Client \ + --target windows-amd64 \ + --capture /outside/repository/screener-client-capture.exe \ + --tunnel /outside/repository/cloudflared.exe +``` + +Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`. +`--target` controls the Go cross-build and packaged executable names; the Node, +capture, and tunnel inputs must already match that target. Windows and macOS +accept their matching native-capture input; Linux currently retains Browser +capture. + +The result contains: + +```text +screener-client[.exe] +REVISION +runtime/node/node[.exe] +runtime/native/screener-client-capture[.exe] # supported native-media packages +runtime/tunnel/cloudflared[.exe] # packages that support --link +app/REVISION +app/dist +app/node_modules +``` + +The Client executable and application must contain the same full Git revision. +No compatibility reader accepts a mismatched private build. + +For a native Host smoke run, launch the Client and select a window in the Host +page. The Client never guesses among multiple targets. The page still creates +the room and sends the current SDP/ICE through the selected authority. A +configured Site supplies its normal Internet routing and SFU fallback. Without +a Site, the **Public invite** choice exposes the Local control surface while +media remains P2P-only. + +## Gates + +The Local gate starts the packaged process, loads a real built page in Chromium, +proves bootstrap access and LAN invitation construction, and verifies complete +process/profile cleanup. The loopback gate separately proves Site access with +and without Chromium Local Network Access permission. Environment syntax below +is POSIX; use equivalent variables on Windows. + +```sh +SCREENER_CLIENT_LOCAL_GATE=true \ +CHROME_PATH=/path/to/chrome \ +SCREENER_CLIENT_EXE=/path/to/screener-client \ +SCREENER_CLIENT_NODE=/path/to/node \ +SCREENER_CLIENT_APP=/path/to/app \ +SCREENER_CLIENT_GATE_LAN_ADDRESS=192.168.1.10 \ +npm run gate:client-local + +SCREENER_CLIENT_LOOPBACK_GATE=true \ +CHROME_PATH=/path/to/chrome \ +SCREENER_CLIENT_EXE=/path/to/screener-client \ +npm run probe:client-loopback + +SCREENER_CLIENT_MEDIA_GATE=true \ +CHROME_PATH=/path/to/chrome \ +npm run gate:client-media + +SCREENER_CLIENT_NATIVE_HOST_GATE=true \ +CHROME_PATH=/path/to/chrome \ +SCREENER_GO=/path/to/go \ +npm run gate:client-native-host + +SCREENER_CLIENT_NATIVE_HOST_GATE=true \ +SCREENER_CLIENT_CROSS_NAT_GATE=true \ +CHROME_PATH=/path/to/chrome \ +SCREENER_GO=/path/to/go \ +SCREENER_REMOTE_HOST= \ +SCREENER_REMOTE_USER= \ +SCREENER_REMOTE_SSH_KEY=/path/to/key \ +SCREENER_CLIENT_GATE_STUN_URLS=stun::3478 \ +npm run gate:client-cross-nat + +SCREENER_CLIENT_NATIVE_HOST_GATE=true \ +SCREENER_CLIENT_LINK_MEDIA_GATE=true \ +CHROME_PATH=/path/to/chrome \ +SCREENER_GO=/path/to/go \ +SCREENER_CLOUDFLARED=/path/to/cloudflared \ +SCREENER_REMOTE_HOST= \ +SCREENER_REMOTE_USER= \ +SCREENER_REMOTE_SSH_KEY=/path/to/key \ +npm run gate:client-link-media + +SCREENER_CLIENT_LINK_GATE=true \ +SCREENER_GO=/path/to/go \ +SCREENER_CLOUDFLARED=/path/to/cloudflared \ +SCREENER_REMOTE_HOST= \ +SCREENER_REMOTE_USER= \ +SCREENER_REMOTE_SSH_KEY=/path/to/key \ +npm run gate:client-link +``` + +The loopback health response reports video, process-audio, system-audio, and +hardware H.264 availability separately. The Windows media gate proves one hardware-H.264 +capture generation, shared Pion source, Browser decode, PLI recovery, and STUN +candidate gathering. The native Host gate proves room creation and native video +delivery through the current route; native audio is included when the capability +probe and target OS support it. The cross-NAT variant uses a temporary reverse +SSH path for signaling only and requires a selected `srflx` or `prflx` media pair; +media never travels through SSH. The one-link media variant instead carries the +same signaling through the Client's temporary public origin and requires direct +media delivery to an independent Linux peer. Native P2P quality evidence and the +Browser-mediated SFU path have dedicated gates. macOS capture still requires a +native runner gate; Linux native capture remains unaccepted. diff --git a/native/client/cmd/screener-client/main.go b/native/client/cmd/screener-client/main.go new file mode 100644 index 0000000..bc25e99 --- /dev/null +++ b/native/client/cmd/screener-client/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/TNTcraftHIM/Screener/native/client/internal/clientapp" +) + +func main() { + var options clientapp.Options + flag.Func("site", "save and open a Screener Site origin", func(value string) error { + options.Site = value + options.SiteSet = true + return nil + }) + flag.BoolVar(&options.Local, "local", false, "use the self-contained Local room authority") + flag.BoolVar(&options.Link, "link", false, "create one public Viewer invitation link") + flag.StringVar(&options.NodePath, "node", "", "path to the bundled Node runtime") + flag.StringVar(&options.AppDirectory, "app", "", "path to the bundled Screener application") + flag.StringVar(&options.CaptureProcess, "capture-process", "", "path to the platform native capture process") + flag.StringVar(&options.TunnelProcess, "tunnel-process", "", "path to the packaged public tunnel process") + flag.StringVar(&options.ConfigPath, "config", "", "path to the Client configuration file") + flag.StringVar(&options.LANAddress, "lan-address", "", "LAN IPv4 address used in Local invitations") + flag.IntVar(&options.Port, "port", clientapp.DefaultLocalPort, "Local Screener server port") + flag.Parse() + options.DisableBrowser = os.Getenv("SCREENER_CLIENT_GATE_NO_BROWSER") == "true" + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + var input [1]byte + if count, _ := os.Stdin.Read(input[:]); count > 0 { + stop() + } + }() + if err := clientapp.Run(ctx, options); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/native/client/cmd/screener-peer-gate/main.go b/native/client/cmd/screener-peer-gate/main.go new file mode 100644 index 0000000..606fa7c --- /dev/null +++ b/native/client/cmd/screener-peer-gate/main.go @@ -0,0 +1,591 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/pion/interceptor" + "github.com/pion/webrtc/v4" +) + +const ( + signalingProtocol = "screener-v19" + maxPendingCandidates = 64 +) + +type gateConfig struct { + SignalURL string `json:"signalUrl"` + Origin string `json:"origin"` + RoomID string `json:"roomId"` + ViewerGrant string `json:"viewerGrant"` + ClientID string `json:"clientId"` + TimeoutSeconds int `json:"timeoutSeconds"` + MinPackets int `json:"minPackets"` +} + +type signal struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Description *webrtc.SessionDescription `json:"description,omitempty"` + Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"` +} + +type wireICEServer struct { + URLs json.RawMessage `json:"urls"` +} + +type incoming struct { + Type string `json:"type"` + Sequence uint64 `json:"sequence"` + FromPeerID string `json:"fromPeerId"` + Payload *signal `json:"payload"` + MediaMode string `json:"mediaMode"` + RouteRevision int `json:"routeRevision"` + RouteAssignment struct { + Upstream *struct { + Kind string `json:"kind"` + PeerID string `json:"peerId"` + } `json:"upstream"` + } `json:"routeAssignment"` + Assignment struct { + Upstream *struct { + Kind string `json:"kind"` + PeerID string `json:"peerId"` + } `json:"upstream"` + } `json:"assignment"` + ICEConfig struct { + ICEServers []wireICEServer `json:"iceServers"` + } `json:"iceConfig"` + Revision int `json:"revision"` + ConnectionID string `json:"connectionId"` + Phase string `json:"phase"` + Candidate struct { + ConnectionID string `json:"connectionId"` + } `json:"candidate"` +} + +type gateResult struct { + Passed bool + Connected bool + Packets int + RouteRevision int + ConnectionID string + LocalCandidateType string + RemoteCandidateType string + Error string +} + +type receiver struct { + ctx context.Context + conn *websocket.Conn + writeMu sync.Mutex + pc *webrtc.PeerConnection + pcMu sync.Mutex + stateMu sync.Mutex + parentPeerID string + routeRevision int + connectionID string + remoteDescription bool + pending []webrtc.ICECandidateInit + pendingSignals []struct { + from string + payload signal + } + packetReady chan struct{} + minPackets int + closeOnce sync.Once + packetMu sync.Mutex + packets int + localType string + remoteType string +} + +func main() { + configPath := flag.String("config", "", "JSON configuration path; use - for stdin") + flag.Parse() + if *configPath == "" { + writeResult(gateResult{Error: "config is required"}) + os.Exit(2) + } + payload, err := readConfig(*configPath) + if err != nil { + writeResult(gateResult{Error: "config could not be read"}) + os.Exit(1) + } + var config gateConfig + if err = json.Unmarshal(payload, &config); err != nil || + config.SignalURL == "" || config.Origin == "" || config.RoomID == "" || config.ViewerGrant == "" { + writeResult(gateResult{Error: "config is invalid"}) + os.Exit(1) + } + if config.ClientID == "" { + config.ClientID = randomID() + } + if config.TimeoutSeconds < 1 || config.TimeoutSeconds > 120 { + config.TimeoutSeconds = 45 + } + if config.MinPackets < 1 || config.MinPackets > 300 { + config.MinPackets = 30 + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.TimeoutSeconds)*time.Second) + defer cancel() + output := run(ctx, config) + writeResult(output) + if !output.Passed { + os.Exit(1) + } +} + +func readConfig(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(io.LimitReader(os.Stdin, 16<<10)) + } + return os.ReadFile(path) +} + +func run(ctx context.Context, config gateConfig) gateResult { + conn, _, err := websocket.Dial(ctx, config.SignalURL, &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{config.Origin}}, + }) + if err != nil { + return gateResult{Error: "signaling connection failed"} + } + receiver := &receiver{ + ctx: ctx, conn: conn, + packetReady: make(chan struct{}), + minPackets: config.MinPackets, + } + defer receiver.close() + if err = receiver.write(map[string]any{ + "type": "authenticate", "protocol": signalingProtocol, + "roomId": config.RoomID, "role": "viewer", "clientId": config.ClientID, + "viewerGrant": config.ViewerGrant, "viewerPresence": true, + "displayName": "remote-gate", + }); err != nil { + return gateResult{Error: "authentication send failed"} + } + var authenticatedICE []wireICEServer + authenticated := false + var pendingRoute *struct { + revision int + connectionID string + parentPeerID string + } + activateRoute := func(revision int, connectionID string) error { + parentPeerID, currentConnectionID, _ := receiver.routeIdentity() + if connectionID == "" || parentPeerID == "" { + return errors.New("receiver route is incomplete") + } + if currentConnectionID != "" && currentConnectionID != connectionID { + return errors.New("receiver received a second route generation") + } + receiver.setRoute(parentPeerID, revision, connectionID) + if !receiver.hasPeer() { + if err = receiver.createPeer(authenticatedICE); err != nil { + return err + } + } + pending := receiver.pendingSignals + receiver.pendingSignals = nil + for _, item := range pending { + if err = receiver.acceptSignal(item.from, item.payload); err != nil { + return err + } + } + return nil + } + messages := make(chan json.RawMessage, 1) + readErrors := make(chan error, 1) + go func() { + for { + var raw json.RawMessage + readErr := wsjson.Read(ctx, conn, &raw) + if readErr != nil { + select { + case readErrors <- readErr: + case <-ctx.Done(): + } + return + } + select { + case messages <- raw: + case <-ctx.Done(): + return + } + } + }() + for { + select { + case <-receiver.packetReady: + localType, remoteType := receiver.candidateTypes() + _, connectionID, revision := receiver.routeIdentity() + return gateResult{ + Passed: true, Connected: true, Packets: receiver.packetCount(), + RouteRevision: revision, ConnectionID: connectionID, + LocalCandidateType: localType, RemoteCandidateType: remoteType, + } + case err = <-readErrors: + return gateResult{Error: "signaling ended before media"} + case <-ctx.Done(): + return gateResult{Error: "timed out waiting for remote media"} + case raw := <-messages: + var message incoming + if err = json.Unmarshal(raw, &message); err != nil { + return gateResult{Error: "signaling message is invalid"} + } + switch message.Type { + case "signaling-challenge": + if err = receiver.write(map[string]any{ + "type": "signaling-challenge", "sequence": message.Sequence, + }); err != nil { + return gateResult{Error: "challenge response failed"} + } + case "authenticated": + if message.MediaMode != "peer-assisted" { + return gateResult{Error: "room did not assign a direct peer route"} + } + if message.RouteAssignment.Upstream != nil && + message.RouteAssignment.Upstream.Kind == "peer" { + receiver.setRoute(message.RouteAssignment.Upstream.PeerID, message.RouteRevision, "") + } else { + receiver.setRoute("", message.RouteRevision, "") + } + if err = receiver.write(map[string]any{ + "type": "relay-capacity", "downstreamEdges": 0, + }); err != nil { + return gateResult{Error: "capacity send failed"} + } + authenticatedICE = message.ICEConfig.ICEServers + authenticated = true + if pendingRoute != nil { + receiver.setRoute(pendingRoute.parentPeerID, pendingRoute.revision, pendingRoute.connectionID) + if err = activateRoute(pendingRoute.revision, pendingRoute.connectionID); err != nil { + return gateResult{Error: "receiver signaling failed"} + } + pendingRoute = nil + } + case "route-update": + if message.Phase == "prepare" && message.Candidate.ConnectionID != "" { + if message.Assignment.Upstream == nil || + message.Assignment.Upstream.Kind != "peer" || + message.Assignment.Upstream.PeerID == "" { + return gateResult{Error: "room did not assign a direct peer route"} + } + receiver.setRoute(message.Assignment.Upstream.PeerID, message.Revision, message.Candidate.ConnectionID) + if !authenticated { + pendingRoute = &struct { + revision int + connectionID string + parentPeerID string + }{ + revision: message.Revision, + connectionID: message.Candidate.ConnectionID, + parentPeerID: message.Assignment.Upstream.PeerID, + } + break + } + if err = activateRoute(message.Revision, message.Candidate.ConnectionID); err != nil { + return gateResult{Error: "receiver setup failed"} + } + } + case "signal": + if message.Payload == nil { + continue + } + _, connectionID, _ := receiver.routeIdentity() + if !authenticated || connectionID == "" { + if len(receiver.pendingSignals) >= maxPendingCandidates { + return gateResult{Error: "receiver signaling queue is full"} + } + receiver.pendingSignals = append(receiver.pendingSignals, struct { + from string + payload signal + }{from: message.FromPeerID, payload: *message.Payload}) + continue + } + if err = receiver.acceptSignal(message.FromPeerID, *message.Payload); err != nil { + return gateResult{Error: "receiver signaling failed"} + } + case "route-status": + if message.Phase == "failed" { + return gateResult{Error: "route assignment failed"} + } + } + } + } +} + +func (receiver *receiver) createPeer(raw []wireICEServer) error { + media := &webrtc.MediaEngine{} + if err := media.RegisterDefaultCodecs(); err != nil { + return err + } + interceptors := &interceptor.Registry{} + if err := webrtc.RegisterDefaultInterceptors(media, interceptors); err != nil { + return err + } + servers, err := parseICEServers(raw) + if err != nil { + return err + } + api := webrtc.NewAPI( + webrtc.WithMediaEngine(media), + webrtc.WithInterceptorRegistry(interceptors), + ) + pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: servers}) + if err != nil { + return err + } + receiver.pcMu.Lock() + receiver.pc = pc + receiver.pcMu.Unlock() + if _, err = pc.AddTransceiverFromKind( + webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}, + ); err != nil { + return err + } + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + var value *webrtc.ICECandidateInit + if candidate != nil { + converted := candidate.ToJSON() + value = &converted + } + parentPeerID, connectionID, _ := receiver.routeIdentity() + _ = receiver.write(signalMessage( + "candidate", parentPeerID, connectionID, nil, value, + )) + }) + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + if state == webrtc.PeerConnectionStateConnected { + for _, transceiver := range pc.GetTransceivers() { + if rtpReceiver := transceiver.Receiver(); rtpReceiver != nil { + receiver.recordSelectedPair(rtpReceiver) + break + } + } + _, connectionID, revision := receiver.routeIdentity() + _ = receiver.write(map[string]any{ + "type": "route-transport-connected", + "revision": revision, + "connectionId": connectionID, + }) + } + }) + pc.OnTrack(func(track *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) { + receiver.recordSelectedPair(rtpReceiver) + for { + _, _, readErr := track.ReadRTP() + if readErr != nil { + return + } + receiver.packetMu.Lock() + receiver.packets++ + count := receiver.packets + receiver.packetMu.Unlock() + if count == 1 { + _, _, revision := receiver.routeIdentity() + _ = receiver.write(map[string]any{ + "type": "route-ready", "revision": revision, + "phase": "prepare", + }) + } + if count >= receiver.minPackets { + select { + case <-receiver.packetReady: + default: + close(receiver.packetReady) + } + } + } + }) + return nil +} + +func (receiver *receiver) recordSelectedPair(rtpReceiver *webrtc.RTPReceiver) { + if rtpReceiver == nil || rtpReceiver.Transport() == nil || + rtpReceiver.Transport().ICETransport() == nil { + return + } + pair, err := rtpReceiver.Transport().ICETransport().GetSelectedCandidatePair() + if err != nil || pair == nil { + return + } + receiver.packetMu.Lock() + receiver.localType = pair.Local.Typ.String() + receiver.remoteType = pair.Remote.Typ.String() + receiver.packetMu.Unlock() +} + +func (receiver *receiver) setRoute(parentPeerID string, revision int, connectionID string) { + receiver.stateMu.Lock() + receiver.parentPeerID = parentPeerID + receiver.routeRevision = revision + receiver.connectionID = connectionID + receiver.stateMu.Unlock() +} + +func (receiver *receiver) routeIdentity() (string, string, int) { + receiver.stateMu.Lock() + defer receiver.stateMu.Unlock() + return receiver.parentPeerID, receiver.connectionID, receiver.routeRevision +} + +func (receiver *receiver) hasPeer() bool { + receiver.pcMu.Lock() + defer receiver.pcMu.Unlock() + return receiver.pc != nil +} + +func (receiver *receiver) acceptSignal(from string, payload signal) error { + parentPeerID, connectionID, _ := receiver.routeIdentity() + if from != parentPeerID || payload.ConnectionID != connectionID { + return nil + } + receiver.pcMu.Lock() + pc := receiver.pc + receiver.pcMu.Unlock() + if pc == nil { + return errors.New("receiver peer is not initialized") + } + if payload.Kind == "description" && payload.Description != nil { + if err := pc.SetRemoteDescription(*payload.Description); err != nil { + return err + } + receiver.remoteDescription = true + for _, candidate := range receiver.pending { + if err := pc.AddICECandidate(candidate); err != nil { + return err + } + } + receiver.pending = nil + answer, err := pc.CreateAnswer(nil) + if err != nil { + return err + } + if err = pc.SetLocalDescription(answer); err != nil { + return err + } + local := pc.LocalDescription() + if local == nil { + return errors.New("receiver answer is unavailable") + } + return receiver.write(signalMessage( + "description", parentPeerID, connectionID, local, nil, + )) + } + if payload.Kind == "candidate" && payload.Candidate != nil { + if !receiver.remoteDescription { + if len(receiver.pending) >= maxPendingCandidates { + return errors.New("receiver ICE queue is full") + } + receiver.pending = append(receiver.pending, *payload.Candidate) + return nil + } + return pc.AddICECandidate(*payload.Candidate) + } + return nil +} + +func (receiver *receiver) write(value any) error { + receiver.writeMu.Lock() + defer receiver.writeMu.Unlock() + return wsjson.Write(receiver.ctx, receiver.conn, value) +} + +func (receiver *receiver) packetCount() int { + receiver.packetMu.Lock() + defer receiver.packetMu.Unlock() + return receiver.packets +} + +func (receiver *receiver) candidateTypes() (string, string) { + receiver.packetMu.Lock() + defer receiver.packetMu.Unlock() + return receiver.localType, receiver.remoteType +} + +func (receiver *receiver) close() { + receiver.closeOnce.Do(func() { + receiver.pcMu.Lock() + pc := receiver.pc + receiver.pcMu.Unlock() + if pc != nil { + _ = pc.Close() + } + _ = receiver.conn.Close(websocket.StatusNormalClosure, "gate complete") + }) +} + +func signalMessage( + kind string, target string, connectionID string, + description *webrtc.SessionDescription, + candidate *webrtc.ICECandidateInit, +) map[string]any { + payload := map[string]any{"kind": kind, "connectionId": connectionID} + if description != nil { + payload["description"] = map[string]any{ + "type": description.Type.String(), "sdp": description.SDP, + } + } + if kind == "candidate" { + payload["candidate"] = candidate + } + return map[string]any{"type": "signal", "targetPeerId": target, "payload": payload} +} + +func parseICEServers(raw []wireICEServer) ([]webrtc.ICEServer, error) { + servers := make([]webrtc.ICEServer, 0, len(raw)) + for _, entry := range raw { + if len(entry.URLs) == 0 { + continue + } + var values []string + if entry.URLs[0] == '[' { + if err := json.Unmarshal(entry.URLs, &values); err != nil { + return nil, err + } + } else { + var value string + if err := json.Unmarshal(entry.URLs, &value); err != nil { + return nil, err + } + values = []string{value} + } + servers = append(servers, webrtc.ICEServer{URLs: values}) + } + return servers, nil +} + +func randomID() string { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return "remote_gate_123456" + } + return fmt.Sprintf("remote_%x", buffer) +} + +func writeResult(value gateResult) { + encoded, _ := json.Marshal(map[string]any{ + "passed": value.Passed, + "connected": value.Connected, + "packets": value.Packets, + "routeRevision": value.RouteRevision, + "connectionId": value.ConnectionID, + "localCandidateType": value.LocalCandidateType, + "remoteCandidateType": value.RemoteCandidateType, + "error": value.Error, + }) + fmt.Println(string(encoded)) +} diff --git a/native/client/cmd/screener-peer-gate/main_test.go b/native/client/cmd/screener-peer-gate/main_test.go new file mode 100644 index 0000000..26e4e39 --- /dev/null +++ b/native/client/cmd/screener-peer-gate/main_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestIncomingUsesCurrentWireFieldNames(t *testing.T) { + payload := []byte(`{ + "type":"route-update", + "revision":7, + "phase":"prepare", + "assignment":{"upstream":{"kind":"peer","peerId":"peer_12345678"}}, + "candidate":{"connectionId":"conn_12345678"} + }`) + var message incoming + if err := json.Unmarshal(payload, &message); err != nil { + t.Fatal(err) + } + if message.Type != "route-update" || message.Revision != 7 || + message.Phase != "prepare" || message.Assignment.Upstream == nil || + message.Assignment.Upstream.Kind != "peer" || + message.Assignment.Upstream.PeerID != "peer_12345678" || + message.Candidate.ConnectionID != "conn_12345678" { + t.Fatalf("decoded route update = %+v", message) + } +} + +func TestParseICEServersAcceptsWireStringAndArray(t *testing.T) { + servers, err := parseICEServers([]wireICEServer{ + {URLs: json.RawMessage(`"stun:example.test:3478"`)}, + {URLs: json.RawMessage(`["stun:example.test:3479","stun:example.test:3480"]`)}, + }) + if err != nil { + t.Fatal(err) + } + if len(servers) != 2 || len(servers[0].URLs) != 1 || + servers[0].URLs[0] != "stun:example.test:3478" || + len(servers[1].URLs) != 2 { + t.Fatalf("parsed ICE servers = %+v", servers) + } +} + +func TestParseICEServersRejectsMalformedWireValue(t *testing.T) { + if _, err := parseICEServers([]wireICEServer{ + {URLs: json.RawMessage(`{"unexpected":true}`)}, + }); err == nil { + t.Fatal("malformed ICE server was accepted") + } +} diff --git a/native/sender/go.mod b/native/client/go.mod similarity index 61% rename from native/sender/go.mod rename to native/client/go.mod index dc136cf..480b3ef 100644 --- a/native/sender/go.mod +++ b/native/client/go.mod @@ -1,22 +1,28 @@ -module github.com/TNTcraftHIM/Screener/native/sender +module github.com/TNTcraftHIM/Screener/native/client -go 1.24.0 +go 1.25.0 require ( github.com/coder/websocket v1.8.15 + github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 + github.com/pion/interceptor v0.1.47 + github.com/pion/logging v0.2.4 github.com/pion/rtcp v1.2.17 github.com/pion/rtp v1.10.5 github.com/pion/webrtc/v4 v4.2.18 - golang.org/x/sys v0.41.0 + github.com/thesyncim/gopus v0.1.1 ) require ( + github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/huin/goupnp v1.2.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/pion/datachannel v1.6.2 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/ice/v4 v4.4.0 // indirect - github.com/pion/interceptor v0.1.47 // indirect - github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/sctp v1.11.1 // indirect @@ -28,5 +34,7 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect ) diff --git a/native/sender/go.sum b/native/client/go.sum similarity index 62% rename from native/sender/go.sum rename to native/client/go.sum index 409adbe..d159944 100644 --- a/native/sender/go.sum +++ b/native/client/go.sum @@ -2,8 +2,20 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= @@ -42,15 +54,32 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thesyncim/gopus v0.1.1 h1:SM58S7VZGnEZ3ZR0pX5AoC33//LBxJhKkvm15MbBmik= +github.com/thesyncim/gopus v0.1.1/go.mod h1:orRqwrGs5gqYRRnhqwI0Y3liqQTeDkreUpra+Kv9bQc= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/native/client/internal/browser/open.go b/native/client/internal/browser/open.go new file mode 100644 index 0000000..7634c42 --- /dev/null +++ b/native/client/internal/browser/open.go @@ -0,0 +1,33 @@ +package browser + +import ( + "errors" + "net/url" + "os/exec" + "runtime" +) + +func Open(target string) error { + command, arguments, err := command(runtime.GOOS, target) + if err != nil { + return err + } + return exec.Command(command, arguments...).Run() +} + +func command(platform, target string) (string, []string, error) { + parsed, err := url.Parse(target) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", nil, errors.New("Browser URL is invalid") + } + switch platform { + case "windows": + return "rundll32.exe", []string{"url.dll,FileProtocolHandler", parsed.String()}, nil + case "darwin": + return "open", []string{parsed.String()}, nil + case "linux": + return "xdg-open", []string{parsed.String()}, nil + default: + return "", nil, errors.New("Browser launch is unsupported on this platform") + } +} diff --git a/native/client/internal/browser/open_test.go b/native/client/internal/browser/open_test.go new file mode 100644 index 0000000..4a4eb3f --- /dev/null +++ b/native/client/internal/browser/open_test.go @@ -0,0 +1,33 @@ +package browser + +import ( + "reflect" + "testing" +) + +func TestCommandUsesThePlatformURLLauncher(t *testing.T) { + tests := []struct { + platform string + command string + args []string + }{ + {"windows", "rundll32.exe", []string{"url.dll,FileProtocolHandler", "https://share.example/room"}}, + {"darwin", "open", []string{"https://share.example/room"}}, + {"linux", "xdg-open", []string{"https://share.example/room"}}, + } + for _, test := range tests { + commandName, args, err := command(test.platform, "https://share.example/room") + if err != nil || commandName != test.command || !reflect.DeepEqual(args, test.args) { + t.Fatalf("command(%s) = %q, %#v, %v", test.platform, commandName, args, err) + } + } +} + +func TestCommandRejectsNonWebTargetsAndUnknownPlatforms(t *testing.T) { + if _, _, err := command("windows", "file:///tmp/index.html"); err == nil { + t.Fatal("file URL was accepted") + } + if _, _, err := command("plan9", "https://share.example"); err == nil { + t.Fatal("unknown platform was accepted") + } +} diff --git a/native/client/internal/clientapp/app.go b/native/client/internal/clientapp/app.go new file mode 100644 index 0000000..52257fa --- /dev/null +++ b/native/client/internal/clientapp/app.go @@ -0,0 +1,476 @@ +package clientapp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/TNTcraftHIM/Screener/native/client/internal/browser" + "github.com/TNTcraftHIM/Screener/native/client/internal/clientconfig" + "github.com/TNTcraftHIM/Screener/native/client/internal/lan" + "github.com/TNTcraftHIM/Screener/native/client/internal/launcher" + "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecontrol" + "github.com/TNTcraftHIM/Screener/native/client/internal/publictunnel" + "github.com/TNTcraftHIM/Screener/native/client/internal/supervisor" +) + +const ( + DefaultLocalPort = 8787 + publicSTUNURL = "stun:stun.cloudflare.com:3478" +) + +var BuildRevision = "development" + +type Options struct { + Site string + SiteSet bool + Local bool + Link bool + NodePath string + AppDirectory string + ConfigPath string + LANAddress string + Port int + DisableBrowser bool + CaptureProcess string + TunnelProcess string + Ready func(string) +} + +func Run(ctx context.Context, options Options) error { + if err := validateMode(options); err != nil { + return err + } + configPath := strings.TrimSpace(options.ConfigPath) + if configPath == "" { + var err error + configPath, err = clientconfig.DefaultPath() + if err != nil { + return errors.New("Screener Client configuration is unavailable") + } + } + config, err := clientconfig.LoadOrCreate(configPath) + if err != nil { + return errors.New("Screener Client configuration is unavailable") + } + config, err = applyMode(config, options) + if err != nil { + return err + } + if options.SiteSet || options.Local { + if err = clientconfig.Save(configPath, config); err != nil { + return errors.New("Screener Client configuration is unavailable") + } + } + nativeMedia := discoverNativeMedia(ctx, options.CaptureProcess) + if !explicitMode(options) { + return runLauncher(ctx, options, configPath, config, nativeMedia) + } + return runConfigured(ctx, options, config, nativeMedia) +} + +func explicitMode(options Options) bool { + return options.SiteSet || options.Local || options.Link || options.DisableBrowser +} + +func runConfigured( + ctx context.Context, + options Options, + config clientconfig.Config, + nativeMedia nativeRuntime, +) error { + if config.Site != "" && !options.Local && !options.Link { + return runSite(ctx, config.Site, options, nativeMedia) + } + return runLocal(ctx, options, config, nativeMedia) +} + +func runLauncher( + ctx context.Context, + options Options, + configPath string, + config clientconfig.Config, + nativeMedia nativeRuntime, +) error { + _, appDirectory, err := packagePaths(options.NodePath, options.AppDirectory) + if err != nil { + return err + } + launch, err := launcher.Start( + ctx, + filepath.Join(appDirectory, "dist", "client"), + config.Site, + BuildRevision, + ) + if err != nil { + return err + } + defer launch.Close() + fmt.Printf("Screener Client launcher: %s\n", launch.URL()) + if err = browser.Open(launch.URL()); err != nil { + return errors.New("Screener Client could not open its launcher") + } + + var selection launcher.Selection + select { + case selection = <-launch.Selection(): + case <-ctx.Done(): + return nil + case err = <-launch.Done(): + if err != nil { + return errors.New("Screener Client launcher stopped unexpectedly") + } + return nil + } + if selection.Mode == launcher.ModeSite { + config.Site = selection.Site + } + if err = clientconfig.Save(configPath, config); err != nil { + launch.SetResult("", err) + <-launch.Handled() + return errors.New("Screener Client configuration is unavailable") + } + options.SiteSet = false + options.Local = selection.Mode == launcher.ModeLocal + options.Link = selection.Mode == launcher.ModeLink + options.DisableBrowser = true + ready := make(chan string, 1) + options.Ready = func(target string) { ready <- target } + runtimeDone := make(chan error, 1) + go func() { + runtimeDone <- runConfigured(ctx, options, config, nativeMedia) + }() + + select { + case target := <-ready: + launch.SetResult(target, nil) + case err = <-runtimeDone: + launch.SetResult("", err) + <-launch.Handled() + return err + case <-ctx.Done(): + launch.SetResult("", ctx.Err()) + return nil + } + + select { + case <-launch.Handled(): + _ = launch.Close() + case err = <-runtimeDone: + return err + case <-ctx.Done(): + return nil + } + select { + case err = <-runtimeDone: + return err + case <-ctx.Done(): + return nil + } +} + +func applyMode(config clientconfig.Config, options Options) (clientconfig.Config, error) { + if options.SiteSet && options.Local { + return clientconfig.Config{}, errors.New("choose either --site or --local") + } + if options.SiteSet { + var err error + config.Site, err = clientconfig.NormalizeSite(options.Site) + if err != nil || config.Site == "" { + return clientconfig.Config{}, errors.New("Screener Site must be an HTTP or HTTPS origin") + } + } else if options.Local { + config.Site = "" + } + return config, nil +} + +func validateMode(options Options) error { + if options.Link && (options.SiteSet || options.Local) { + return errors.New("--link is a self-contained Local mode") + } + return nil +} + +func runSite(ctx context.Context, site string, options Options, + nativeMedia nativeRuntime, +) error { + client, err := loopback.Start(ctx, loopback.Options{ + AllowedOrigin: site, + NativeMedia: nativeMedia.capabilities, + NewControl: nativeMedia.controlFactory(true), + }) + if err != nil { + return errors.New("Screener Client could not start") + } + defer client.Close() + if err = printEndpoint(client.Endpoint()); err != nil { + return err + } + if !options.DisableBrowser { + if err = browser.Open(clientLaunchURL(site)); err != nil { + return errors.New("Screener Client could not open the Site") + } + } else if options.Ready != nil { + options.Ready(clientLaunchURL(site)) + } + if err = <-client.Done(); err != nil { + return errors.New("Screener Client stopped unexpectedly") + } + return nil +} + +func runLocal(ctx context.Context, options Options, config clientconfig.Config, + nativeMedia nativeRuntime, +) error { + addresses, err := lan.Addresses() + if err != nil { + return err + } + selectedAddress, err := lan.Select(addresses, options.LANAddress) + if err != nil { + return err + } + nodePath, appDirectory, err := packagePaths(options.NodePath, options.AppDirectory) + if err != nil { + return err + } + client, err := loopback.Start(ctx, loopback.Options{ + AllowedOrigin: fmt.Sprintf("http://localhost:%d", options.Port), + NativeMedia: nativeMedia.capabilities, + NewControl: nativeMedia.controlFactory(options.Link), + }) + if err != nil { + return errors.New("Screener Client could not start") + } + defer client.Close() + if err = printEndpoint(client.Endpoint()); err != nil { + return err + } + var tunnel *publictunnel.Process + publicOrigin := "" + if options.Link { + tunnel, err = publictunnel.Start( + ctx, + tunnelExecutable(options.TunnelProcess), + fmt.Sprintf("http://127.0.0.1:%d", options.Port), + ) + if err != nil { + return err + } + defer tunnel.Close() + publicOrigin = tunnel.Origin() + } + + entry := filepath.Join(appDirectory, "dist", "server", "server", "local-index.js") + stunURLs := []string(nil) + if options.Link { + stunURLs = []string{publicSTUNURL} + } + localServer, err := supervisor.Start(ctx, supervisor.Command{ + Path: nodePath, + Args: []string{entry}, + Directory: appDirectory, + Environment: localEnvironment( + options.Port, + selectedAddress, + addresses, + config.LocalAccessPassword, + stunURLs, + publicOrigin, + ), + Stdout: os.Stdout, + Stderr: os.Stderr, + HealthURL: fmt.Sprintf("http://127.0.0.1:%d/healthz", options.Port), + }) + if err != nil { + return err + } + defer localServer.Close() + + fmt.Printf("Local access password: %s\n", config.LocalAccessPassword) + if publicOrigin != "" { + fmt.Printf("Public invitation origin: %s\n", publicOrigin) + } else { + fmt.Printf("LAN invitation origin: http://%s:%d\n", selectedAddress, options.Port) + } + launchURL := clientLaunchURL(fmt.Sprintf( + "http://localhost:%d/#client-access=%s", + options.Port, + config.LocalAccessPassword, + )) + if !options.DisableBrowser { + if err = browser.Open(launchURL); err != nil { + return errors.New("Screener Client could not open the Local page") + } + } else if options.Ready != nil { + options.Ready(launchURL) + } + var tunnelDone <-chan struct{} + if tunnel != nil { + tunnelDone = tunnel.Done() + } + + select { + case <-ctx.Done(): + return localServer.Close() + case <-localServer.Done(): + if err = localServer.Err(); err != nil { + return errors.New("Screener Client local server stopped unexpectedly") + } + return nil + case err = <-client.Done(): + if err != nil { + return errors.New("Screener Client runtime stopped unexpectedly") + } + return nil + case <-tunnelDone: + return errors.New("public invitation link stopped unexpectedly") + } +} + +func clientLaunchURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil { + return raw + } + fragment.Set("screener-client", "1") + parsed.Fragment = fragment.Encode() + return parsed.String() +} + +type nativeRuntime struct { + capabilities loopback.NativeMediaCapabilities + capture nativecapture.Capabilities + captureProcess string +} + +func (runtime nativeRuntime) available() bool { + return runtime.capabilities.Video && runtime.capabilities.HardwareH264 && + runtime.captureProcess != "" +} + +func (runtime nativeRuntime) controlFactory(portMapping bool) func() loopback.ControlSession { + if !runtime.available() { + return nil + } + return func() loopback.ControlSession { + return nativecontrol.New(runtime.captureProcess, runtime.capture, portMapping) + } +} + +func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRuntime { + path := strings.TrimSpace(configuredPath) + if path == "" { + path = nativecapture.PackagedExecutable() + } + capabilities, err := nativecapture.Discover(ctx, path) + if err != nil { + return nativeRuntime{} + } + summary := capabilities.Summary() + return nativeRuntime{ + captureProcess: path, + capture: capabilities, + capabilities: loopback.NativeMediaCapabilities{ + Video: summary.Video, + ProcessAudio: summary.ProcessAudio, + SystemAudio: summary.SystemAudio, + HardwareH264: summary.HardwareH264, + }, + } +} + +func packagePaths(nodePath, appDirectory string) (string, string, error) { + root := "" + if strings.TrimSpace(nodePath) == "" || strings.TrimSpace(appDirectory) == "" { + executable, err := os.Executable() + if err != nil { + return "", "", errors.New("Screener Client package location is unavailable") + } + root = filepath.Dir(executable) + } + if strings.TrimSpace(nodePath) == "" { + nodeName := "node" + if runtime.GOOS == "windows" { + nodeName += ".exe" + } + nodePath = filepath.Join(root, "runtime", "node", nodeName) + } + if strings.TrimSpace(appDirectory) == "" { + appDirectory = filepath.Join(root, "app") + } + nodePath = filepath.Clean(nodePath) + appDirectory = filepath.Clean(appDirectory) + if _, err := os.Stat(nodePath); err != nil { + return "", "", errors.New("bundled Node runtime is unavailable") + } + if _, err := os.Stat(filepath.Join(appDirectory, "dist", "server", "server", "local-index.js")); err != nil { + return "", "", errors.New("bundled Screener application is unavailable") + } + if BuildRevision != "development" { + revision, err := os.ReadFile(filepath.Join(appDirectory, "REVISION")) + if err != nil || strings.TrimSpace(string(revision)) != BuildRevision { + return "", "", errors.New("Client and application revisions do not match") + } + } + return nodePath, appDirectory, nil +} + +func tunnelExecutable(configured string) string { + if path := strings.TrimSpace(configured); path != "" { + return filepath.Clean(path) + } + return publictunnel.PackagedExecutable() +} + +func localEnvironment( + port int, + publicAddress string, + addresses []string, + password string, + stunURLs []string, + publicOrigin string, +) []string { + overrides := map[string]string{ + "SCREENER_CLIENT_PORT": fmt.Sprint(port), + "SCREENER_CLIENT_LAN_ADDRESS": publicAddress, + "SCREENER_CLIENT_ALLOWED_LAN_ADDRESSES": strings.Join(addresses, ","), + "SCREENER_CLIENT_LOCAL_PASSWORD": password, + "SCREENER_CLIENT_PUBLIC_ORIGIN": publicOrigin, + "STUN_URLS": strings.Join(stunURLs, ","), + } + result := make([]string, 0, len(os.Environ())+len(overrides)) + for _, entry := range os.Environ() { + name, _, found := strings.Cut(entry, "=") + if _, replaced := overrides[name]; found && replaced { + continue + } + result = append(result, entry) + } + for name, value := range overrides { + result = append(result, name+"="+value) + } + return result +} + +func printEndpoint(endpoint loopback.Endpoint) error { + payload, err := json.Marshal(endpoint) + if err != nil { + return errors.New("Screener Client endpoint is unavailable") + } + fmt.Println(string(payload)) + return nil +} diff --git a/native/client/internal/clientapp/app_test.go b/native/client/internal/clientapp/app_test.go new file mode 100644 index 0000000..c7b802c --- /dev/null +++ b/native/client/internal/clientapp/app_test.go @@ -0,0 +1,140 @@ +package clientapp + +import ( + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/TNTcraftHIM/Screener/native/client/internal/clientconfig" +) + +func TestPackagePathsValidateAnExplicitPackage(t *testing.T) { + nodePath, appDirectory := packageFixture(t) + actualNode, actualApp, err := packagePaths(nodePath, appDirectory) + if err != nil || actualNode != nodePath || actualApp != appDirectory { + t.Fatalf("packagePaths = %q, %q, %v", actualNode, actualApp, err) + } +} + +func TestPackagePathsRequireTheExactPackagedRevision(t *testing.T) { + nodePath, appDirectory := packageFixture(t) + if err := os.WriteFile(filepath.Join(appDirectory, "REVISION"), []byte("different"), 0o600); err != nil { + t.Fatal(err) + } + previous := BuildRevision + BuildRevision = "expected" + t.Cleanup(func() { BuildRevision = previous }) + if _, _, err := packagePaths(nodePath, appDirectory); err == nil { + t.Fatal("mismatched package revision was accepted") + } + if err := os.WriteFile(filepath.Join(appDirectory, "REVISION"), []byte("expected\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := packagePaths(nodePath, appDirectory); err != nil { + t.Fatal(err) + } +} + +func TestClientLaunchURLMarksThePageWithoutChangingOrigin(t *testing.T) { + value := clientLaunchURL("https://share.example/") + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host != "share.example" || + parsed.RawQuery != "" { + t.Fatalf("launch URL = %q, %v", value, err) + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("screener-client") != "1" { + t.Fatalf("Client launch fragment = %q, %v", parsed.Fragment, err) + } +} + +func TestMissingNativeRuntimeLeavesBrowserCaptureAvailable(t *testing.T) { + runtime := discoverNativeMedia(t.Context(), "missing-capture-process") + if runtime.available() || runtime.controlFactory(false) != nil { + t.Fatalf("missing native runtime = %+v", runtime) + } +} + +func TestLaunchURLPreservesLocalAccessInsideThePrivateFragment(t *testing.T) { + value := clientLaunchURL("http://localhost:8787/#client-access=secret") + parsed, err := url.Parse(value) + if err != nil || parsed.RawQuery != "" { + t.Fatalf("local native launch URL = %q, %v", value, err) + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("client-access") != "secret" || + fragment.Get("screener-client") != "1" { + t.Fatalf("local native launch fragment = %q, %v", parsed.Fragment, err) + } +} + +func TestLinkModeKeepsOneLocalAuthority(t *testing.T) { + config := clientconfig.Config{ + Version: 1, + LocalAccessPassword: "abcdefghijklmnopqrstuvwxyzABCDEF", + Site: "https://example.test", + } + selected, err := applyMode(config, Options{Link: true}) + if err != nil || selected.Site != config.Site { + t.Fatalf("link mode = %+v, %v", selected, err) + } + if err = validateMode(Options{Link: true, SiteSet: true}); err == nil { + t.Fatal("link mode accepted a separate Site") + } + if err = validateMode(Options{Link: true, Local: true}); err == nil { + t.Fatal("link mode accepted a second Local selector") + } +} + +func TestLocalEnvironmentOwnsItsSTUNConfiguration(t *testing.T) { + t.Setenv("STUN_URLS", "stun:inherited.example:3478") + environment := localEnvironment( + 8787, + "192.168.1.2", + []string{"192.168.1.2"}, + "abcdefghijklmnopqrstuvwxyzABCDEF", + []string{"stun:public.example:3478"}, + "https://small-bright-room.trycloudflare.com", + ) + stunEntries := []string{} + publicEntries := []string{} + for _, entry := range environment { + if strings.HasPrefix(entry, "STUN_URLS=") { + stunEntries = append(stunEntries, entry) + } + if strings.HasPrefix(entry, "SCREENER_CLIENT_PUBLIC_ORIGIN=") { + publicEntries = append(publicEntries, entry) + } + } + if len(stunEntries) != 1 || stunEntries[0] != "STUN_URLS=stun:public.example:3478" { + t.Fatalf("Local STUN environment = %v", stunEntries) + } + if len(publicEntries) != 1 || + publicEntries[0] != "SCREENER_CLIENT_PUBLIC_ORIGIN=https://small-bright-room.trycloudflare.com" { + t.Fatalf("Local public environment = %v", publicEntries) + } +} + +func packageFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + nodeName := "node" + if runtime.GOOS == "windows" { + nodeName += ".exe" + } + nodePath := filepath.Join(root, nodeName) + appDirectory := filepath.Join(root, "app") + entry := filepath.Join(appDirectory, "dist", "server", "server", "local-index.js") + if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil { + t.Fatal(err) + } + for path, body := range map[string]string{nodePath: "node", entry: "entry"} { + if err := os.WriteFile(path, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + } + return nodePath, appDirectory +} diff --git a/native/client/internal/clientconfig/config.go b/native/client/internal/clientconfig/config.go new file mode 100644 index 0000000..e04d85b --- /dev/null +++ b/native/client/internal/clientconfig/config.go @@ -0,0 +1,144 @@ +package clientconfig + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +const ( + currentVersion = 1 + passwordBytes = 24 +) + +var passwordPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32}$`) + +type Config struct { + Version int `json:"version"` + LocalAccessPassword string `json:"localAccessPassword"` + Site string `json:"site,omitempty"` +} + +func DefaultPath() (string, error) { + directory, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("find user configuration directory: %w", err) + } + return filepath.Join(directory, "Screener", "client.json"), nil +} + +func LoadOrCreate(path string) (Config, error) { + config, err := load(path) + if err == nil { + return config, nil + } + if !errors.Is(err, os.ErrNotExist) { + return Config{}, err + } + + password := make([]byte, passwordBytes) + if _, err = rand.Read(password); err != nil { + return Config{}, fmt.Errorf("create Local access password: %w", err) + } + config = Config{ + Version: currentVersion, + LocalAccessPassword: base64.RawURLEncoding.EncodeToString(password), + } + payload, err := encode(config) + if err != nil { + return Config{}, fmt.Errorf("encode Client configuration: %w", err) + } + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return Config{}, fmt.Errorf("create Client configuration directory: %w", err) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return load(path) + } + if err != nil { + return Config{}, fmt.Errorf("create Client configuration: %w", err) + } + if _, writeErr := file.Write(payload); writeErr != nil { + _ = file.Close() + _ = os.Remove(path) + return Config{}, fmt.Errorf("write Client configuration: %w", writeErr) + } + if err = file.Close(); err != nil { + return Config{}, fmt.Errorf("close Client configuration: %w", err) + } + return config, nil +} + +func Save(path string, config Config) error { + payload, err := encode(config) + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create Client configuration directory: %w", err) + } + if err = os.WriteFile(path, payload, 0o600); err != nil { + return fmt.Errorf("write Client configuration: %w", err) + } + if err = os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("protect Client configuration: %w", err) + } + return nil +} + +func NormalizeSite(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.Host == "" || parsed.User != nil || + (parsed.Path != "" && parsed.Path != "/") || + parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("Screener Site must be an HTTP or HTTPS origin") + } + return parsed.Scheme + "://" + parsed.Host, nil +} + +func load(path string) (Config, error) { + payload, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + var config Config + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if decoder.Decode(&config) != nil || decoder.Decode(&struct{}{}) != io.EOF || + config.Version != currentVersion || !passwordPattern.MatchString(config.LocalAccessPassword) { + return Config{}, errors.New("Client configuration is invalid") + } + if config.Site, err = NormalizeSite(config.Site); err != nil { + return Config{}, errors.New("Client configuration is invalid") + } + return config, nil +} + +func encode(config Config) ([]byte, error) { + if config.Version != currentVersion || !passwordPattern.MatchString(config.LocalAccessPassword) { + return nil, errors.New("Client configuration is invalid") + } + site, err := NormalizeSite(config.Site) + if err != nil || site != config.Site { + return nil, errors.New("Client configuration is invalid") + } + payload, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Client configuration: %w", err) + } + return append(payload, '\n'), nil +} diff --git a/native/client/internal/clientconfig/config_test.go b/native/client/internal/clientconfig/config_test.go new file mode 100644 index 0000000..eb6e329 --- /dev/null +++ b/native/client/internal/clientconfig/config_test.go @@ -0,0 +1,83 @@ +package clientconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadOrCreatePersistsOneValidConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "client.json") + first, err := LoadOrCreate(path) + if err != nil { + t.Fatal(err) + } + second, err := LoadOrCreate(path) + if err != nil { + t.Fatal(err) + } + if first != second || first.Version != currentVersion || + !passwordPattern.MatchString(first.LocalAccessPassword) { + t.Fatalf("persisted configuration = %+v, %+v", first, second) + } +} + +func TestSavePersistsOneNormalizedSiteChoice(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.json") + config, err := LoadOrCreate(path) + if err != nil { + t.Fatal(err) + } + config.Site = "https://share.example" + if err = Save(path, config); err != nil { + t.Fatal(err) + } + loaded, err := LoadOrCreate(path) + if err != nil { + t.Fatal(err) + } + if loaded.Site != "https://share.example" || + loaded.LocalAccessPassword != config.LocalAccessPassword { + t.Fatalf("saved configuration = %+v", loaded) + } +} + +func TestNormalizeSiteAcceptsOnlyAPlainWebOrigin(t *testing.T) { + for _, value := range []string{ + "https://share.example", + "http://192.168.1.4:8787/", + } { + if _, err := NormalizeSite(value); err != nil { + t.Fatalf("NormalizeSite(%q) = %v", value, err) + } + } + for _, value := range []string{ + "file:///tmp/app", + "https://user:pass@share.example", + "https://share.example/room", + "https://share.example?mode=host", + } { + if _, err := NormalizeSite(value); err == nil { + t.Fatalf("NormalizeSite accepted %q", value) + } + } +} + +func TestLoadOrCreateRejectsUnknownOrMalformedState(t *testing.T) { + for name, payload := range map[string]string{ + "unknown": `{"version":1,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF","extra":true}`, + "version": `{"version":2,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF"}`, + "password": `{"version":1,"localAccessPassword":"short"}`, + "trailing": `{"version":1,"localAccessPassword":"abcdefghijklmnopqrstuvwxyzABCDEF"} trailing`, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client.json") + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadOrCreate(path); err == nil { + t.Fatal("invalid Client configuration was accepted") + } + }) + } +} diff --git a/native/client/internal/lan/addresses.go b/native/client/internal/lan/addresses.go new file mode 100644 index 0000000..1a24922 --- /dev/null +++ b/native/client/internal/lan/addresses.go @@ -0,0 +1,67 @@ +package lan + +import ( + "errors" + "fmt" + "net" + "sort" + "strings" +) + +func Addresses() ([]string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("list network interfaces: %w", err) + } + unique := make(map[string]struct{}) + for _, networkInterface := range interfaces { + if networkInterface.Flags&net.FlagUp == 0 || networkInterface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, addressErr := networkInterface.Addrs() + if addressErr != nil { + continue + } + for _, address := range addresses { + ip, _, parseErr := net.ParseCIDR(address.String()) + if parseErr != nil || ip.To4() == nil || ip.IsLoopback() || ip.IsUnspecified() { + continue + } + unique[ip.String()] = struct{}{} + } + } + result := make([]string, 0, len(unique)) + for address := range unique { + result = append(result, address) + } + sort.Strings(result) + if len(result) == 0 { + return nil, errors.New("no active LAN IPv4 address is available") + } + return result, nil +} + +func Select(addresses []string, preferred string) (string, error) { + preferred = strings.TrimSpace(preferred) + if preferred != "" { + for _, address := range addresses { + if address == preferred { + return preferred, nil + } + } + return "", errors.New("selected LAN address is not active") + } + if len(addresses) == 1 { + return addresses[0], nil + } + private := make([]string, 0, len(addresses)) + for _, address := range addresses { + if ip := net.ParseIP(address); ip != nil && ip.IsPrivate() { + private = append(private, address) + } + } + if len(private) == 1 { + return private[0], nil + } + return "", fmt.Errorf("multiple LAN addresses are active; select one of: %s", strings.Join(addresses, ", ")) +} diff --git a/native/client/internal/lan/addresses_test.go b/native/client/internal/lan/addresses_test.go new file mode 100644 index 0000000..7c06bbc --- /dev/null +++ b/native/client/internal/lan/addresses_test.go @@ -0,0 +1,21 @@ +package lan + +import "testing" + +func TestSelectUsesOneAddressOrAnExplicitActiveAddress(t *testing.T) { + if selected, err := Select([]string{"192.168.1.4"}, ""); err != nil || selected != "192.168.1.4" { + t.Fatalf("single selection = %q, %v", selected, err) + } + if selected, err := Select([]string{"10.0.0.2", "192.168.1.4"}, "192.168.1.4"); err != nil || selected != "192.168.1.4" { + t.Fatalf("explicit selection = %q, %v", selected, err) + } + if selected, err := Select([]string{"192.168.1.4", "198.18.0.1"}, ""); err != nil || selected != "192.168.1.4" { + t.Fatalf("private LAN selection = %q, %v", selected, err) + } + if _, err := Select([]string{"10.0.0.2", "192.168.1.4"}, ""); err == nil { + t.Fatal("ambiguous selection was accepted") + } + if _, err := Select([]string{"10.0.0.2"}, "192.168.1.4"); err == nil { + t.Fatal("inactive selection was accepted") + } +} diff --git a/native/client/internal/launcher/server.go b/native/client/internal/launcher/server.go new file mode 100644 index 0000000..a81e662 --- /dev/null +++ b/native/client/internal/launcher/server.go @@ -0,0 +1,321 @@ +package launcher + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/TNTcraftHIM/Screener/native/client/internal/clientconfig" +) + +const ( + maxRequestBytes = 2 << 10 + shutdownTimeout = 2 * time.Second +) + +type Mode string + +const ( + ModeLocal Mode = "local" + ModeLink Mode = "link" + ModeSite Mode = "site" +) + +type Selection struct { + Mode Mode `json:"mode"` + Site string `json:"site,omitempty"` +} + +type Server struct { + ctx context.Context + cancel context.CancelFunc + listener net.Listener + httpServer *http.Server + static http.Handler + indexPath string + site string + revision string + selection chan Selection + resultReady chan struct{} + handled chan struct{} + done chan error + ready chan struct{} + + mu sync.Mutex + selected bool + resultSet bool + target string + resultErr error + closeOnce sync.Once + resultOnce sync.Once + handledOnce sync.Once +} + +func Start(parent context.Context, staticDirectory, site, revision string) (*Server, error) { + if parent == nil { + parent = context.Background() + } + normalizedSite, err := clientconfig.NormalizeSite(site) + if err != nil { + return nil, errors.New("Client launcher Site is invalid") + } + site = normalizedSite + indexPath := filepath.Join(staticDirectory, "index.html") + if metadata, err := os.Stat(indexPath); err != nil || !metadata.Mode().IsRegular() { + return nil, errors.New("Client launcher assets are unavailable") + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, errors.New("Client launcher could not start") + } + ctx, cancel := context.WithCancel(parent) + server := &Server{ + ctx: ctx, + cancel: cancel, + listener: listener, + static: http.FileServer(http.Dir(staticDirectory)), + indexPath: indexPath, + site: site, + revision: strings.TrimSpace(revision), + selection: make(chan Selection, 1), + resultReady: make(chan struct{}), + handled: make(chan struct{}), + done: make(chan error, 1), + ready: make(chan struct{}), + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + } + go server.serve() + <-server.ready + go func() { + <-ctx.Done() + _ = server.Close() + }() + return server, nil +} + +func (server *Server) URL() string { + return "http://" + server.listener.Addr().String() + "/client" +} + +func (server *Server) Selection() <-chan Selection { + return server.selection +} + +func (server *Server) Handled() <-chan struct{} { + return server.handled +} + +func (server *Server) Done() <-chan error { + return server.done +} + +func (server *Server) SetResult(target string, resultErr error) { + server.resultOnce.Do(func() { + if resultErr == nil && !validTarget(target) { + resultErr = errors.New("Client target is invalid") + } + server.mu.Lock() + server.resultSet = true + server.target = target + server.resultErr = resultErr + server.mu.Unlock() + close(server.resultReady) + }) +} + +func (server *Server) Close() error { + server.closeOnce.Do(server.cancel) + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return server.httpServer.Shutdown(ctx) +} + +func (server *Server) serve() { + close(server.ready) + err := server.httpServer.Serve(server.listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + server.done <- err + close(server.done) +} + +func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) { + if !server.requestAllowed(request) { + http.NotFound(response, request) + return + } + response.Header().Set("X-Content-Type-Options", "nosniff") + switch request.URL.Path { + case "/api/client-launcher": + server.handleState(response, request) + case "/api/client-launcher/launch": + server.handleLaunch(response, request) + case "/client", "/client/": + if request.Method != http.MethodGet && request.Method != http.MethodHead { + response.Header().Set("Allow", "GET, HEAD") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + response.Header().Set("Cache-Control", "no-store") + http.ServeFile(response, request, server.indexPath) + default: + server.static.ServeHTTP(response, request) + } +} + +func (server *Server) handleState(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(response, http.StatusOK, map[string]any{ + "site": server.site, + "revision": server.revision, + "defaultMode": func() Mode { + if server.site != "" { + return ModeSite + } + return ModeLocal + }(), + }) +} + +func (server *Server) handleLaunch(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + response.Header().Set("Allow", http.MethodPost) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + request.Body = http.MaxBytesReader(response, request.Body, maxRequestBytes) + selection, err := decodeSelection(request.Body) + if err != nil { + http.Error(response, "invalid launch request", http.StatusBadRequest) + return + } + server.mu.Lock() + if server.selected { + server.mu.Unlock() + http.Error(response, "launch already requested", http.StatusConflict) + return + } + server.selected = true + server.mu.Unlock() + + select { + case server.selection <- selection: + case <-request.Context().Done(): + server.markHandled() + return + case <-server.ctx.Done(): + server.markHandled() + return + } + select { + case <-server.resultReady: + case <-request.Context().Done(): + server.markHandled() + return + case <-server.ctx.Done(): + server.markHandled() + return + } + server.mu.Lock() + target, resultErr, resultSet := server.target, server.resultErr, server.resultSet + server.mu.Unlock() + if !resultSet || resultErr != nil { + writeJSON(response, http.StatusServiceUnavailable, map[string]string{ + "error": "Screener Client could not start", + }) + server.markHandled() + return + } + writeJSON(response, http.StatusOK, map[string]string{"target": target}) + server.markHandled() +} + +func (server *Server) markHandled() { + server.handledOnce.Do(func() { close(server.handled) }) +} + +func (server *Server) requestAllowed(request *http.Request) bool { + _, port, err := net.SplitHostPort(request.Host) + if err != nil { + return false + } + _, listenerPort, listenerErr := net.SplitHostPort(server.listener.Addr().String()) + if listenerErr != nil || port != listenerPort { + return false + } + host := strings.TrimSuffix(strings.ToLower(request.Host), ":"+port) + if host != "127.0.0.1" && host != "localhost" { + return false + } + origin := strings.TrimSpace(request.Header.Get("Origin")) + return origin == "" || sameOrigin(origin, "http://"+request.Host) +} + +func sameOrigin(actual, expected string) bool { + actualURL, actualErr := url.Parse(actual) + expectedURL, expectedErr := url.Parse(expected) + return actualErr == nil && expectedErr == nil && + actualURL.Scheme == expectedURL.Scheme && + strings.EqualFold(actualURL.Host, expectedURL.Host) && + actualURL.Path == "" && actualURL.RawQuery == "" && actualURL.Fragment == "" +} + +func decodeSelection(reader io.Reader) (Selection, error) { + var selection Selection + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if decoder.Decode(&selection) != nil || decoder.Decode(&struct{}{}) != io.EOF { + return Selection{}, errors.New("invalid launch request") + } + switch selection.Mode { + case ModeLocal, ModeLink: + if strings.TrimSpace(selection.Site) != "" { + return Selection{}, errors.New("local launch cannot include a Site") + } + selection.Site = "" + case ModeSite: + normalized, err := clientconfig.NormalizeSite(selection.Site) + if err != nil || normalized == "" { + return Selection{}, errors.New("Site launch requires an HTTP or HTTPS origin") + } + selection.Site = normalized + default: + return Selection{}, errors.New("unknown launch mode") + } + return selection, nil +} + +func validTarget(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + payload, err := json.Marshal(value) + if err != nil { + http.Error(response, "response unavailable", http.StatusInternalServerError) + return + } + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.WriteHeader(status) + _, _ = response.Write(payload) +} diff --git a/native/client/internal/launcher/server_test.go b/native/client/internal/launcher/server_test.go new file mode 100644 index 0000000..77134c0 --- /dev/null +++ b/native/client/internal/launcher/server_test.go @@ -0,0 +1,198 @@ +package launcher + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) { + server := startFixture(t, "https://share.example") + origin := strings.TrimSuffix(server.URL(), "/client") + + response, err := http.Get(origin + "/api/client-launcher") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var state struct { + Site string `json:"site"` + DefaultMode Mode `json:"defaultMode"` + Revision string `json:"revision"` + } + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil || + state.Site != "https://share.example" || state.DefaultMode != ModeSite || + state.Revision != "" { + t.Fatalf("launcher state = %d, %+v", response.StatusCode, state) + } + + result := make(chan *http.Response, 1) + requestErr := make(chan error, 1) + go func() { + response, err := http.Post( + origin+"/api/client-launcher/launch", + "application/json", + bytes.NewBufferString(`{"mode":"link"}`), + ) + if err != nil { + requestErr <- err + return + } + result <- response + }() + + select { + case selection := <-server.Selection(): + if selection != (Selection{Mode: ModeLink}) { + t.Fatalf("selection = %+v", selection) + } + server.SetResult("http://localhost:8787/#screener-client=1", nil) + case <-time.After(time.Second): + t.Fatal("launcher did not emit a selection") + } + select { + case err = <-requestErr: + t.Fatal(err) + case response = <-result: + defer response.Body.Close() + var launch struct { + Target string `json:"target"` + } + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&launch) != nil || + launch.Target != "http://localhost:8787/#screener-client=1" { + t.Fatalf("launcher result = %d, %+v", response.StatusCode, launch) + } + case <-time.After(time.Second): + t.Fatal("launcher did not return its result") + } +} + +func TestLauncherIncludesTheInjectedBuildRevision(t *testing.T) { + const revision = "0123456789abcdef0123456789abcdef01234567" + server := startFixtureWithRevision(t, "", revision) + origin := strings.TrimSuffix(server.URL(), "/client") + response, err := http.Get(origin + "/api/client-launcher") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var state struct { + Revision string `json:"revision"` + } + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil || + state.Revision != revision { + t.Fatalf("launcher revision = %d, %+v", response.StatusCode, state) + } +} + +func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile( + filepath.Join(directory, "index.html"), + []byte("client"), + 0o600, + ); err != nil { + t.Fatal(err) + } + if _, err := Start(t.Context(), directory, "https://example.test/path", ""); err == nil { + t.Fatal("launcher accepted a Site path") + } +} + +func TestLauncherRejectsInvalidSelections(t *testing.T) { + for name, payload := range map[string]string{ + "unknown": `{"mode":"other"}`, + "local-site": `{"mode":"local","site":"https://share.example"}`, + "missing-site": `{"mode":"site"}`, + "site-path": `{"mode":"site","site":"https://share.example/path"}`, + "unknown-field": `{"mode":"local","extra":true}`, + } { + t.Run(name, func(t *testing.T) { + server := startFixture(t, "") + origin := strings.TrimSuffix(server.URL(), "/client") + response, err := http.Post( + origin+"/api/client-launcher/launch", + "application/json", + bytes.NewBufferString(payload), + ) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", response.StatusCode) + } + }) + } +} + +func TestLauncherAcceptsLocalhostAlias(t *testing.T) { + server := startFixture(t, "") + origin := strings.TrimSuffix(server.URL(), "/client") + request, err := http.NewRequest( + http.MethodGet, + origin+"/api/client-launcher", + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Host = "localhost:" + strings.TrimPrefix(origin, "http://127.0.0.1:") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("localhost status = %d", response.StatusCode) + } +} + +func TestLauncherRejectsCrossOriginControl(t *testing.T) { + server := startFixture(t, "") + origin := strings.TrimSuffix(server.URL(), "/client") + request, err := http.NewRequest( + http.MethodGet, + origin+"/api/client-launcher", + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", "https://unrelated.example") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("cross-origin status = %d", response.StatusCode) + } +} + +func startFixture(t *testing.T, site string) *Server { + return startFixtureWithRevision(t, site, "") +} + +func startFixtureWithRevision(t *testing.T, site, revision string) *Server { + t.Helper() + directory := t.TempDir() + if err := os.WriteFile( + filepath.Join(directory, "index.html"), + []byte("client"), + 0o600, + ); err != nil { + t.Fatal(err) + } + server, err := Start(t.Context(), directory, site, revision) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + return server +} diff --git a/native/client/internal/loopback/protocol.go b/native/client/internal/loopback/protocol.go new file mode 100644 index 0000000..eac68ea --- /dev/null +++ b/native/client/internal/loopback/protocol.go @@ -0,0 +1,73 @@ +package loopback + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "regexp" +) + +const ( + ProtocolVersion = 5 + ServiceName = "screener-client" + ControlSubprotocol = "screener-client-v5" + MaxControlMessageBytes = 64 << 10 + DefaultPortStart = 39721 + DefaultPortEnd = 39730 +) + +var ( + requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + errInvalidMessage = errors.New("loopback control message is invalid") +) + +type controlMessage struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` +} + +func decodeEnvelope(payload []byte) (controlMessage, error) { + var message controlMessage + decoder := json.NewDecoder(bytes.NewReader(payload)) + if decoder.Decode(&message) != nil || decoder.Decode(&struct{}{}) != io.EOF || + message.Version != ProtocolVersion || !requestIDPattern.MatchString(message.ID) || + message.Type == "" { + return controlMessage{}, errInvalidMessage + } + return message, nil +} + +func decodeRequest(payload []byte) (controlMessage, error) { + var message controlMessage + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if decoder.Decode(&message) != nil || decoder.Decode(&struct{}{}) != io.EOF || + message.Version != ProtocolVersion || !requestIDPattern.MatchString(message.ID) { + return controlMessage{}, errInvalidMessage + } + return message, nil +} + +func validateHello(message controlMessage) error { + if message.Type != "hello" { + return errInvalidMessage + } + return nil +} + +func validatePing(message controlMessage) error { + if message.Type != "ping" { + return errInvalidMessage + } + return nil +} + +func encodeMessage(value any) ([]byte, error) { + payload, err := json.Marshal(value) + if err != nil || len(payload) == 0 || len(payload) > MaxControlMessageBytes { + return nil, errInvalidMessage + } + return payload, nil +} diff --git a/native/client/internal/loopback/protocol_test.go b/native/client/internal/loopback/protocol_test.go new file mode 100644 index 0000000..2b259bc --- /dev/null +++ b/native/client/internal/loopback/protocol_test.go @@ -0,0 +1,68 @@ +package loopback + +import ( + "testing" +) + +func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) { + valid := []string{ + `{"version":5,"id":"request_hello","type":"hello"}`, + `{"version":5,"id":"request_ping","type":"ping"}`, + } + for _, payload := range valid { + if _, err := decodeRequest([]byte(payload)); err != nil { + t.Fatalf("decodeRequest(%s) = %v", payload, err) + } + } + invalid := []string{ + `{"version":3,"id":"request_ping","type":"ping"}`, + `{"version":5,"id":"short","type":"ping"}`, + `{"version":5,"id":"request_ping","type":"ping","extra":true}`, + `{"version":5,"id":"request_hello","type":"hello","nonce":"obsolete"}`, + `{"version":5,"id":"request_ping","type":"ping"} trailing`, + } + for _, payload := range invalid { + if _, err := decodeRequest([]byte(payload)); err == nil { + t.Fatalf("decodeRequest accepted %s", payload) + } + } +} + +func TestValidateMessagesRequireTheExpectedPhase(t *testing.T) { + hello := controlMessage{Version: ProtocolVersion, ID: "request_hello", Type: "hello"} + if err := validateHello(hello); err != nil { + t.Fatalf("validateHello = %v", err) + } + ping := controlMessage{Version: ProtocolVersion, ID: "request_ping", Type: "ping"} + if err := validatePing(ping); err != nil { + t.Fatal(err) + } + if err := validatePing(hello); err == nil { + t.Fatal("validatePing accepted hello") + } + if err := validateHello(ping); err == nil { + t.Fatal("validateHello accepted ping") + } +} + +func TestEnvelopeAllowsAnExtensionToOwnItsStrictShape(t *testing.T) { + payload := []byte(`{"version":5,"id":"request_extension","type":"extension","value":1}`) + message, err := decodeEnvelope(payload) + if err != nil || message.Type != "extension" { + t.Fatalf("extension envelope = %+v, %v", message, err) + } + if _, err = decodeRequest(payload); err == nil { + t.Fatal("base message decoder accepted extension fields") + } +} + +func TestNormalizePortRangeUsesTheBoundedDefaults(t *testing.T) { + start, end := normalizePortRange(0, 0) + if start != DefaultPortStart || end != DefaultPortEnd { + t.Fatalf("default range = %d-%d", start, end) + } + start, end = normalizePortRange(0, 40000) + if start != 40000 || end != 40000 { + t.Fatalf("single-port range = %d-%d", start, end) + } +} diff --git a/native/client/internal/loopback/server.go b/native/client/internal/loopback/server.go new file mode 100644 index 0000000..fc17d56 --- /dev/null +++ b/native/client/internal/loopback/server.go @@ -0,0 +1,443 @@ +package loopback + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/coder/websocket" +) + +const ( + defaultBindAddress = "127.0.0.1" + instanceTokenBytes = 32 + helloTimeout = 5 * time.Second + shutdownTimeout = 2 * time.Second +) + +type Options struct { + PortStart int + PortEnd int + AllowedOrigin string + NativeMedia NativeMediaCapabilities + NewControl func() ControlSession +} + +type ControlSession interface { + Handle(context.Context, []byte) (any, error) + Events() <-chan any + Close() error +} + +type Endpoint struct { + URL string `json:"url"` + Host string `json:"host"` + Port int `json:"port"` + InstanceToken string `json:"instanceToken"` +} + +type Health struct { + Protocol int `json:"protocol"` + Service string `json:"service"` + Port int `json:"port"` + InstanceToken string `json:"instanceToken"` + NativeMedia NativeMediaCapabilities `json:"nativeMedia"` +} + +type NativeMediaCapabilities struct { + Video bool `json:"video"` + ProcessAudio bool `json:"processAudio"` + SystemAudio bool `json:"systemAudio"` + HardwareH264 bool `json:"hardwareH264"` +} + +type Server struct { + ctx context.Context + cancel context.CancelFunc + listener net.Listener + httpServer *http.Server + endpoint Endpoint + allowedOrigin string + nativeMedia NativeMediaCapabilities + newControl func() ControlSession + done chan error + + mu sync.Mutex + activeConn *websocket.Conn + claimed bool + closed bool +} + +func Start(parent context.Context, options Options) (*Server, error) { + if parent == nil { + parent = context.Background() + } + portStart, portEnd := normalizePortRange(options.PortStart, options.PortEnd) + listener, err := listenInRange(defaultBindAddress, portStart, portEnd) + if err != nil { + return nil, err + } + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, errors.New("loopback listener returned an unexpected address") + } + instanceToken, err := newInstanceToken() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("create helper instance token: %w", err) + } + ctx, cancel := context.WithCancel(parent) + server := &Server{ + ctx: ctx, cancel: cancel, listener: listener, + endpoint: Endpoint{ + URL: "http://" + listener.Addr().String(), + Host: listener.Addr().String(), + Port: address.Port, + InstanceToken: instanceToken, + }, + allowedOrigin: strings.TrimSpace(options.AllowedOrigin), + nativeMedia: options.NativeMedia, + newControl: options.NewControl, + done: make(chan error, 1), + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + } + go server.serve() + go func() { + <-ctx.Done() + _ = server.Close() + }() + return server, nil +} + +func (server *Server) Endpoint() Endpoint { + return server.endpoint +} + +func (server *Server) Done() <-chan error { + return server.done +} + +func (server *Server) Close() error { + server.mu.Lock() + if server.closed { + server.mu.Unlock() + return nil + } + server.closed = true + connection := server.activeConn + server.activeConn = nil + server.claimed = false + server.mu.Unlock() + server.cancel() + if connection != nil { + _ = connection.Close(websocket.StatusNormalClosure, "helper stopped") + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return server.httpServer.Shutdown(ctx) +} + +func (server *Server) serve() { + err := server.httpServer.Serve(server.listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + if err != nil { + server.cancel() + } + server.done <- err + close(server.done) +} + +func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) { + if !server.requestAllowed(response, request) { + return + } + switch request.URL.Path { + case "/health": + server.handleHealth(response, request) + case "/control": + server.handleControl(response, request) + default: + http.NotFound(response, request) + } +} + +func (server *Server) handleHealth(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + response.Header().Set("Cache-Control", "no-store") + writeJSON(response, http.StatusOK, Health{ + Protocol: ProtocolVersion, + Service: ServiceName, + Port: server.endpoint.Port, + InstanceToken: server.endpoint.InstanceToken, + NativeMedia: server.nativeMedia, + }) +} + +func (server *Server) handleControl(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !server.claimConnection() { + http.Error(response, "control session already active", http.StatusConflict) + return + } + expectedSubprotocol := ControlSubprotocol + "." + server.endpoint.InstanceToken + connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{ + Subprotocols: []string{expectedSubprotocol}, + OriginPatterns: server.originPatterns(), + }) + if err != nil { + server.releaseConnection(nil) + return + } + if connection.Subprotocol() != expectedSubprotocol { + closeControl(connection, "unsupported control protocol") + server.releaseConnection(connection) + return + } + server.setConnection(connection) + defer server.releaseConnection(connection) + connection.SetReadLimit(MaxControlMessageBytes) + + helloContext, cancelHello := context.WithTimeout(server.ctx, helloTimeout) + messageType, payload, err := connection.Read(helloContext) + cancelHello() + if err != nil || messageType != websocket.MessageText { + closeControl(connection, "hello frame required") + return + } + message, err := decodeRequest(payload) + if err != nil { + closeControl(connection, "invalid control message") + return + } + if validateHello(message) != nil { + closeControl(connection, "hello required") + return + } + var writeMutex sync.Mutex + write := func(ctx context.Context, value any) error { + encoded, encodeErr := encodeMessage(value) + if encodeErr != nil { + return encodeErr + } + writeMutex.Lock() + defer writeMutex.Unlock() + return connection.Write(ctx, websocket.MessageText, encoded) + } + if err = write(server.ctx, controlMessage{ + Version: ProtocolVersion, ID: message.ID, Type: "ready", + }); err != nil { + return + } + controlContext, cancelControl := context.WithCancel(server.ctx) + defer cancelControl() + var extension ControlSession + if server.newControl != nil { + extension = server.newControl() + } + if extension != nil { + defer extension.Close() + go func() { + for { + select { + case event, open := <-extension.Events(): + if !open { + return + } + if write(controlContext, event) != nil { + cancelControl() + return + } + case <-controlContext.Done(): + return + } + } + }() + } + for { + messageType, payload, err = connection.Read(controlContext) + if err != nil || messageType != websocket.MessageText { + closeControl(connection, "control frame required") + return + } + message, err = decodeEnvelope(payload) + if err != nil { + closeControl(connection, "invalid control message") + return + } + if message.Type == "ping" { + strict, strictErr := decodeRequest(payload) + if strictErr != nil || validatePing(strict) != nil || + write(controlContext, controlMessage{ + Version: ProtocolVersion, ID: message.ID, Type: "pong", + }) != nil { + closeControl(connection, "invalid control message") + return + } + continue + } + if extension == nil { + closeControl(connection, "unsupported control message") + return + } + value, handleErr := extension.Handle(controlContext, payload) + if handleErr != nil || value == nil { + closeControl(connection, "invalid control message") + return + } + if err = write(controlContext, value); err != nil { + return + } + } +} + +func (server *Server) originPatterns() []string { + patterns := make([]string, 0, 3) + if parsed, err := url.Parse(server.allowedOrigin); err == nil && parsed.Host != "" { + patterns = append(patterns, parsed.Scheme+"://"+parsed.Host) + } + port := strconv.Itoa(server.endpoint.Port) + patterns = append(patterns, "http://localhost:"+port, "http://127.0.0.1:"+port) + return patterns +} + +func closeControl(connection *websocket.Conn, reason string) { + _ = connection.Close(websocket.StatusPolicyViolation, reason) +} + +func (server *Server) requestAllowed(response http.ResponseWriter, request *http.Request) bool { + origin := strings.TrimSpace(request.Header.Get("Origin")) + if origin != "" && !sameOrigin(origin, server.allowedOrigin) && !server.localOrigin(origin) { + http.Error(response, "forbidden", http.StatusForbidden) + return false + } + if !server.hostAllowed(request.Host) { + http.NotFound(response, request) + return false + } + if origin != "" { + response.Header().Set("Access-Control-Allow-Origin", origin) + response.Header().Add("Vary", "Origin") + } + return true +} + +func (server *Server) hostAllowed(host string) bool { + if host == server.endpoint.Host { + return true + } + return host == net.JoinHostPort("localhost", strconv.Itoa(server.endpoint.Port)) +} + +func (server *Server) localOrigin(origin string) bool { + parsed, err := url.Parse(origin) + return err == nil && parsed.Scheme == "http" && + parsed.Port() == strconv.Itoa(server.endpoint.Port) && + (strings.EqualFold(parsed.Hostname(), "localhost") || + strings.EqualFold(parsed.Hostname(), "127.0.0.1")) +} + +func (server *Server) claimConnection() bool { + server.mu.Lock() + defer server.mu.Unlock() + if server.closed || server.claimed { + return false + } + server.claimed = true + return true +} + +func (server *Server) setConnection(connection *websocket.Conn) { + server.mu.Lock() + server.activeConn = connection + server.mu.Unlock() +} + +func (server *Server) releaseConnection(connection *websocket.Conn) { + server.mu.Lock() + if server.activeConn == connection { + server.activeConn = nil + } + server.claimed = false + server.mu.Unlock() +} + +func listenInRange(bindAddress string, start, end int) (net.Listener, error) { + var lastErr error + for port := start; port <= end; port++ { + listener, err := net.Listen("tcp4", net.JoinHostPort(bindAddress, strconv.Itoa(port))) + if err == nil { + return listener, nil + } + lastErr = err + } + return nil, fmt.Errorf("helper could not bind port range %d-%d: %w", start, end, lastErr) +} + +func normalizePortRange(start, end int) (int, int) { + if start <= 0 && end <= 0 { + return DefaultPortStart, DefaultPortEnd + } + if start <= 0 { + start = end + } + if start < 1 { + start = 1 + } + if start > 65535 { + start = 65535 + } + if end < start { + end = start + } + if end > 65535 { + end = 65535 + } + return start, end +} + +func sameOrigin(actual, expected string) bool { + return strings.EqualFold(strings.TrimRight(actual, "/"), strings.TrimRight(expected, "/")) +} + +func newInstanceToken() (string, error) { + buffer := make([]byte, instanceTokenBytes) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + payload, err := encodeMessage(value) + if err != nil { + http.Error(response, "response unavailable", http.StatusInternalServerError) + return + } + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.WriteHeader(status) + _, _ = response.Write(payload) +} diff --git a/native/client/internal/loopback/server_test.go b/native/client/internal/loopback/server_test.go new file mode 100644 index 0000000..3d5422e --- /dev/null +++ b/native/client/internal/loopback/server_test.go @@ -0,0 +1,292 @@ +package loopback + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +const testOrigin = "https://share.bonfire.icu" + +func TestStartServesHealthAndStrictControlHandshake(t *testing.T) { + expectedMedia := NativeMediaCapabilities{ + Video: true, ProcessAudio: false, SystemAudio: true, HardwareH264: true, + } + server := startTestServerWithOptions(t, Options{ + AllowedOrigin: testOrigin, + NativeMedia: expectedMedia, + }) + endpoint := server.Endpoint() + + var health Health + response := doHealthRequest(t, endpoint, endpoint.Host, testOrigin) + if response.StatusCode != http.StatusOK { + t.Fatalf("health status = %d", response.StatusCode) + } + if err := json.NewDecoder(response.Body).Decode(&health); err != nil { + t.Fatal(err) + } + response.Body.Close() + if health.Protocol != ProtocolVersion || health.Service != ServiceName || + health.Port != endpoint.Port || health.InstanceToken != endpoint.InstanceToken || + health.NativeMedia != expectedMedia { + t.Fatalf("health = %+v", health) + } + + connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin) + defer connection.CloseNow() + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + if ready.Type != "ready" || ready.ID != "request_hello" { + t.Fatalf("ready = %+v", ready) + } + + writeControl(t, connection, requestJSON("request_ping", "ping")) + var pong controlMessage + readControl(t, connection, &pong) + if pong.Type != "pong" || pong.ID != "request_ping" { + t.Fatalf("pong = %+v", pong) + } +} + +func TestHealthRejectsUnexpectedOriginAndHost(t *testing.T) { + server := startTestServer(t, testOrigin) + endpoint := server.Endpoint() + response := doHealthRequest(t, endpoint, endpoint.Host, "https://other.example") + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("bad origin status = %d", response.StatusCode) + } + response = doHealthRequest(t, endpoint, "127.0.0.2:"+strconv.Itoa(endpoint.Port), testOrigin) + response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("bad host status = %d", response.StatusCode) + } +} + +func TestHealthAcceptsTheLocalHostAlias(t *testing.T) { + server := startTestServer(t, testOrigin) + endpoint := server.Endpoint() + response := doHealthRequest(t, endpoint, + "localhost:"+strconv.Itoa(endpoint.Port), + "http://localhost:"+strconv.Itoa(endpoint.Port), + ) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("localhost health status = %d", response.StatusCode) + } +} + +func TestOnlyOneControlSessionIsClaimed(t *testing.T) { + server := startTestServer(t, testOrigin) + endpoint := server.Endpoint() + first := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin) + defer first.CloseNow() + second, response, err := websocket.Dial(context.Background(), websocketURL(endpoint)+"/control", &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{testOrigin}}, + Subprotocols: []string{ControlSubprotocol + "." + endpoint.InstanceToken}, + }) + if second != nil { + second.CloseNow() + } + if err == nil || response == nil || response.StatusCode != http.StatusConflict { + t.Fatalf("second control dial: conn=%v response=%v err=%v", second, response, err) + } +} + +func TestControlSessionSharesOneBoundedSocketForResponsesAndEvents(t *testing.T) { + extension := &testControlSession{ + events: make(chan any, 1), + closed: make(chan struct{}), + } + server := startTestServerWithOptions(t, Options{ + AllowedOrigin: testOrigin, + NewControl: func() ControlSession { + return extension + }, + }) + connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin) + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + extension.events <- controlMessage{ + Version: ProtocolVersion, ID: "event_123456", Type: "extension-event", + } + writeControl(t, connection, requestJSON("request_extension", "extension")) + received := map[string]bool{} + for len(received) < 2 { + var message controlMessage + readControl(t, connection, &message) + received[message.Type] = true + } + if !received["extension-event"] || !received["extension-response"] { + t.Fatalf("control messages = %+v", received) + } + connection.CloseNow() + select { + case <-extension.closed: + case <-time.After(time.Second): + t.Fatal("control extension was not closed with its socket") + } +} + +func TestStartAlwaysBindsToIPv4Loopback(t *testing.T) { + server := startTestServer(t, testOrigin) + host, _, err := net.SplitHostPort(server.Endpoint().Host) + if err != nil || host != "127.0.0.1" { + t.Fatalf("helper host = %q, %v", host, err) + } +} + +func TestCloseStopsTheControlSession(t *testing.T) { + server := startTestServer(t, testOrigin) + endpoint := server.Endpoint() + connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin) + if err := server.Close(); err != nil { + t.Fatal(err) + } + readContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, _, err := connection.Read(readContext); err == nil { + t.Fatal("control connection remained open after server close") + } + connection.CloseNow() +} + +func TestUnexpectedServeFailureIsReported(t *testing.T) { + server := startTestServer(t, testOrigin) + if err := server.listener.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-server.Done(): + if err == nil { + t.Fatal("unexpected listener close was reported as a clean shutdown") + } + case <-time.After(time.Second): + t.Fatal("serve failure was not reported") + } +} + +func startTestServer(t *testing.T, origin string) *Server { + t.Helper() + return startTestServerWithOptions(t, Options{AllowedOrigin: origin}) +} + +func startTestServerWithOptions(t *testing.T, options Options) *Server { + t.Helper() + port := freePort(t) + options.PortStart = port + options.PortEnd = port + server, err := Start(context.Background(), options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + return server +} + +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +func doHealthRequest(t *testing.T, endpoint Endpoint, host, origin string) *http.Response { + t.Helper() + request, err := http.NewRequest(http.MethodGet, endpoint.URL+"/health", nil) + if err != nil { + t.Fatal(err) + } + request.Host = host + if origin != "" { + request.Header.Set("Origin", origin) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func dialControl(t *testing.T, endpoint Endpoint, token, origin string) *websocket.Conn { + t.Helper() + connection, _, err := websocket.Dial(context.Background(), websocketURL(endpoint)+"/control", &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{origin}}, + Subprotocols: []string{ControlSubprotocol + "." + token}, + }) + if err != nil { + t.Fatal(err) + } + return connection +} + +func websocketURL(endpoint Endpoint) string { + return strings.Replace(endpoint.URL, "http://", "ws://", 1) +} + +func writeControl(t *testing.T, connection *websocket.Conn, value map[string]any) { + t.Helper() + if err := wsjson.Write(context.Background(), connection, value); err != nil { + t.Fatal(err) + } +} + +func readControl(t *testing.T, connection *websocket.Conn, value any) { + t.Helper() + contextWithDeadline, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := wsjson.Read(contextWithDeadline, connection, value); err != nil { + t.Fatal(err) + } +} + +func requestJSON(id, messageType string) map[string]any { + return map[string]any{ + "version": ProtocolVersion, + "id": id, + "type": messageType, + } +} + +type testControlSession struct { + events chan any + closed chan struct{} +} + +func (session *testControlSession) Handle(_ context.Context, payload []byte) (any, error) { + message, err := decodeEnvelope(payload) + if err != nil || message.Type != "extension" { + return nil, errors.New("unexpected extension request") + } + return controlMessage{ + Version: ProtocolVersion, ID: message.ID, Type: "extension-response", + }, nil +} + +func (session *testControlSession) Events() <-chan any { + return session.events +} + +func (session *testControlSession) Close() error { + select { + case <-session.closed: + default: + close(session.closed) + } + return nil +} diff --git a/native/client/internal/mediaedge/audio.go b/native/client/internal/mediaedge/audio.go new file mode 100644 index 0000000..5a689d7 --- /dev/null +++ b/native/client/internal/mediaedge/audio.go @@ -0,0 +1,175 @@ +package mediaedge + +import ( + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +const opusSDPFmtpLine = "minptime=10;useinbandfec=1" + +var opusCapability = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: nativeaudio.SampleRate, + Channels: nativeaudio.Channels, + SDPFmtpLine: opusSDPFmtpLine, + RTCPFeedback: []webrtc.RTCPFeedback{ + {Type: webrtc.TypeRTCPFBTransportCC}, + }, +} + +// AudioSource owns one encoded Opus source and the same bounded edge capacity +// as the corresponding video source. +type AudioSource struct { + engine *Engine + track *webrtc.TrackLocalStaticSample + encoder *nativeaudio.Encoder + capacity int + + mu sync.Mutex + encodeMu sync.Mutex + edges map[*Edge]bool + reservations int + localReservation bool + closed bool + bytes atomic.Uint64 +} + +func (engine *Engine) NewAudioSource(capacity, bitrate int) (*AudioSource, error) { + if capacity < 1 || capacity > 4 { + return nil, errors.New("native audio source capacity is outside the route bound") + } + encoder, err := nativeaudio.NewEncoder(bitrate) + if err != nil { + return nil, err + } + engine.mu.Lock() + defer engine.mu.Unlock() + if engine.closed { + return nil, errors.New("native media engine is closed") + } + track, err := webrtc.NewTrackLocalStaticSample( + opusCapability, + "audio", + "screener-native", + ) + if err != nil { + return nil, err + } + return &AudioSource{ + engine: engine, track: track, encoder: encoder, + capacity: capacity, edges: make(map[*Edge]bool), + }, nil +} + +func (source *AudioSource) WritePCM(pcm []byte, duration time.Duration) error { + if source == nil || source.encoder == nil || duration <= 0 { + return errors.New("native PCM sample is invalid") + } + source.encodeMu.Lock() + defer source.encodeMu.Unlock() + source.mu.Lock() + closed := source.closed + source.mu.Unlock() + if closed { + return errors.New("native audio source is closed") + } + packet, err := source.encoder.Encode(pcm) + if err != nil { + return err + } + source.bytes.Add(uint64(len(packet))) + return source.track.WriteSample(media.Sample{Data: packet, Duration: duration}) +} + +func (source *AudioSource) snapshotBytes() uint64 { + if source == nil { + return 0 + } + return source.bytes.Load() +} + +func (source *AudioSource) reserve(local bool) error { + source.mu.Lock() + defer source.mu.Unlock() + if source.closed { + return errors.New("native audio source is closed") + } + if local { + if source.localReservation { + return ErrSourceCapacity + } + for _, existingLocal := range source.edges { + if existingLocal { + return ErrSourceCapacity + } + } + source.localReservation = true + return nil + } + routeEdges := 0 + for _, existingLocal := range source.edges { + if !existingLocal { + routeEdges++ + } + } + if routeEdges+source.reservations >= source.capacity { + return ErrSourceCapacity + } + source.reservations++ + return nil +} + +func (source *AudioSource) attach(edge *Edge, local bool) error { + source.mu.Lock() + defer source.mu.Unlock() + if local { + source.localReservation = false + } else if source.reservations > 0 { + source.reservations-- + } + if source.closed { + return errors.New("native audio source is closed") + } + source.edges[edge] = local + return nil +} + +func (source *AudioSource) releaseReservation(local bool) { + source.mu.Lock() + if local { + source.localReservation = false + } else if source.reservations > 0 { + source.reservations-- + } + source.mu.Unlock() +} + +func (source *AudioSource) detach(edge *Edge) { + source.mu.Lock() + delete(source.edges, edge) + source.mu.Unlock() +} + +func (source *AudioSource) Close() error { + source.mu.Lock() + if source.closed { + source.mu.Unlock() + return nil + } + source.closed = true + edges := make([]*Edge, 0, len(source.edges)) + for edge := range source.edges { + edges = append(edges, edge) + } + source.mu.Unlock() + for _, edge := range edges { + _ = edge.Close() + } + return nil +} diff --git a/native/client/internal/mediaedge/edge.go b/native/client/internal/mediaedge/edge.go new file mode 100644 index 0000000..1263997 --- /dev/null +++ b/native/client/internal/mediaedge/edge.go @@ -0,0 +1,277 @@ +package mediaedge + +import ( + "errors" + "io" + "sync" + + "github.com/pion/rtcp" + "github.com/pion/webrtc/v4" +) + +const maxPendingCandidates = 64 + +type EdgeEvents struct { + LocalCandidate func(*webrtc.ICECandidateInit) + ConnectionState func(webrtc.PeerConnectionState, *SelectedPair) +} + +type EdgeOptions struct { + ConnectionID string + ICEServers []webrtc.ICEServer + Audio *AudioSource + Local bool + Events EdgeEvents +} + +type Edge struct { + connectionID string + engine *Engine + source *Source + audioSource *AudioSource + connection *webrtc.PeerConnection + sender *webrtc.RTPSender + audioSender *webrtc.RTPSender + bandwidth *bandwidthObserver + events EdgeEvents + + mu sync.Mutex + pendingCandidates []webrtc.ICECandidateInit + remoteDescriptionSet bool + closed bool + qualityMu sync.Mutex + qualityBaseline qualityBaseline +} + +func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error) { + if source == nil || source.engine != engine || options.ConnectionID == "" || + len(options.ConnectionID) > 256 { + return nil, errors.New("native media edge input is invalid") + } + if options.Audio != nil && options.Audio.engine != engine { + return nil, errors.New("native audio edge source belongs to another engine") + } + if engine.portMapping != nil && !options.Local { + engine.portMapping.Prepare() + } + if err := source.reserve(options.Local); err != nil { + return nil, err + } + if options.Audio != nil { + if err := options.Audio.reserve(options.Local); err != nil { + source.releaseReservation(options.Local) + return nil, err + } + } + connection, err := engine.api.NewPeerConnection(webrtc.Configuration{ + ICEServers: options.ICEServers, + }) + if err != nil { + source.releaseReservation(options.Local) + if options.Audio != nil { + options.Audio.releaseReservation(options.Local) + } + return nil, err + } + bandwidth := engine.bandwidth.take(connection.ID()) + if bandwidth == nil { + source.releaseReservation(options.Local) + if options.Audio != nil { + options.Audio.releaseReservation(options.Local) + } + _ = connection.Close() + return nil, errors.New("native media bandwidth observer is unavailable") + } + edge := &Edge{ + connectionID: options.ConnectionID, + engine: engine, + source: source, + audioSource: options.Audio, + connection: connection, + bandwidth: bandwidth, + events: options.Events, + } + if err = engine.register(edge); err != nil { + source.releaseReservation(options.Local) + if options.Audio != nil { + options.Audio.releaseReservation(options.Local) + } + _ = connection.Close() + return nil, err + } + if err = source.attach(edge, options.Local); err != nil { + if options.Audio != nil { + options.Audio.releaseReservation(options.Local) + } + engine.remove(edge) + _ = connection.Close() + return nil, err + } + if options.Audio != nil { + if err = options.Audio.attach(edge, options.Local); err != nil { + source.detach(edge) + engine.remove(edge) + _ = connection.Close() + return nil, err + } + } + sender, err := connection.AddTrack(source.track) + if err != nil { + _ = edge.Close() + return nil, err + } + edge.sender = sender + if options.Audio != nil { + audioSender, audioErr := connection.AddTrack(options.Audio.track) + if audioErr != nil { + _ = edge.Close() + return nil, audioErr + } + edge.audioSender = audioSender + } + connection.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if edge.events.LocalCandidate != nil { + if candidate == nil { + edge.events.LocalCandidate(nil) + return + } + value := candidate.ToJSON() + edge.events.LocalCandidate(&value) + } + }) + connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + if state == webrtc.PeerConnectionStateConnected { + edge.source.RequestRecoveryFrame() + } + if edge.events.ConnectionState != nil { + var selected *SelectedPair + if state == webrtc.PeerConnectionStateConnected { + if pair, pairErr := edge.SelectedPair(); pairErr == nil { + selected = &pair + } + } + edge.events.ConnectionState(state, selected) + } + }) + go edge.readRTCP(edge.sender, edge.source) + if edge.audioSender != nil { + go edge.readRTCP(edge.audioSender, nil) + } + return edge, nil +} + +func (edge *Edge) ConnectionID() string { + return edge.connectionID +} + +func (edge *Edge) CreateOffer() (webrtc.SessionDescription, error) { + offer, err := edge.connection.CreateOffer(nil) + if err != nil { + return webrtc.SessionDescription{}, err + } + if err = edge.connection.SetLocalDescription(offer); err != nil { + return webrtc.SessionDescription{}, err + } + return offer, nil +} + +func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error { + if answer.Type != webrtc.SDPTypeAnswer { + return errors.New("native media edge requires an SDP answer") + } + if err := edge.connection.SetRemoteDescription(answer); err != nil { + return err + } + edge.mu.Lock() + edge.remoteDescriptionSet = true + pending := edge.pendingCandidates + edge.pendingCandidates = nil + edge.mu.Unlock() + for _, candidate := range pending { + if err := edge.connection.AddICECandidate(candidate); err != nil { + return err + } + } + return nil +} + +func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { + value := webrtc.ICECandidateInit{} + if candidate != nil { + value = *candidate + } + edge.mu.Lock() + if edge.closed { + edge.mu.Unlock() + return errors.New("native media edge is closed") + } + if !edge.remoteDescriptionSet { + if len(edge.pendingCandidates) >= maxPendingCandidates { + edge.mu.Unlock() + return errors.New("native media ICE candidate queue is full") + } + edge.pendingCandidates = append(edge.pendingCandidates, value) + edge.mu.Unlock() + return nil + } + edge.mu.Unlock() + return edge.connection.AddICECandidate(value) +} + +type SelectedPair struct { + Local webrtc.ICECandidateType + Remote webrtc.ICECandidateType +} + +func (edge *Edge) SelectedPair() (SelectedPair, error) { + transport := edge.sender.Transport() + if transport == nil || transport.ICETransport() == nil { + return SelectedPair{}, errors.New("native media ICE transport is unavailable") + } + pair, err := transport.ICETransport().GetSelectedCandidatePair() + if err != nil || pair == nil { + return SelectedPair{}, errors.New("native media ICE pair is unavailable") + } + return SelectedPair{Local: pair.Local.Typ, Remote: pair.Remote.Typ}, nil +} + +func (edge *Edge) State() webrtc.PeerConnectionState { + return edge.connection.ConnectionState() +} + +func (edge *Edge) Close() error { + edge.mu.Lock() + if edge.closed { + edge.mu.Unlock() + return nil + } + edge.closed = true + edge.pendingCandidates = nil + edge.mu.Unlock() + edge.source.detach(edge) + if edge.audioSource != nil { + edge.audioSource.detach(edge) + } + edge.engine.remove(edge) + return edge.connection.Close() +} + +func (edge *Edge) readRTCP(sender *webrtc.RTPSender, videoSource *Source) { + for { + packets, _, err := sender.ReadRTCP() + if err != nil { + if !errors.Is(err, io.EOF) { + return + } + return + } + for _, packet := range packets { + switch packet.(type) { + case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest: + if videoSource != nil { + videoSource.RequestRecoveryFrame() + } + } + } + } +} diff --git a/native/client/internal/mediaedge/edge_test.go b/native/client/internal/mediaedge/edge_test.go new file mode 100644 index 0000000..2658fa6 --- /dev/null +++ b/native/client/internal/mediaedge/edge_test.go @@ -0,0 +1,503 @@ +package mediaedge + +import ( + "errors" + "net" + "strings" + "testing" + "time" + + "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/pion/interceptor" + "github.com/pion/logging" + "github.com/pion/rtcp" + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +func TestOneEncodedSourceFeedsTwoIndependentEdges(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", + IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + keyFrames := make(chan struct{}, 1) + source, err := engine.NewSource(2, func() { + select { + case keyFrames <- struct{}{}: + default: + } + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = source.Close() }) + + edgeA, receiverA, packetA := connectedReceiver(t, engine, source, "edge-a") + edgeB, receiverB, packetB := connectedReceiver(t, engine, source, "edge-b") + t.Cleanup(func() { _ = receiverA.Close() }) + t.Cleanup(func() { _ = receiverB.Close() }) + if edgeA.connection == edgeB.connection || engine.ListenAddress() == "" { + t.Fatal("media edges did not keep independent transports") + } + if edgeA.bandwidth == edgeB.bandwidth { + t.Fatal("media edges shared one bandwidth estimator") + } + if _, err = engine.NewEdge(source, EdgeOptions{ConnectionID: "edge-c"}); !errors.Is(err, ErrSourceCapacity) { + t.Fatalf("third edge error = %v", err) + } + select { + case <-keyFrames: + case <-time.After(3 * time.Second): + t.Fatal("connected edge did not request a recovery frame") + } + + accessUnit := []byte{ + 0, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1f, 0x96, 0x54, 0x05, 0x01, + 0, 0, 0, 1, 0x68, 0xce, 0x3c, 0x80, + 0, 0, 0, 1, 0x65, 0x88, 0x84, 0x00, + } + if err = source.WriteH264(accessUnit, time.Second, time.Second/30); err != nil { + t.Fatal(err) + } + first := waitPacket(t, packetA) + _ = waitPacket(t, packetB) + if err = receiverA.WriteRTCP([]rtcp.Packet{ + &rtcp.PictureLossIndication{MediaSSRC: first.SSRC}, + }); err != nil { + t.Fatal(err) + } + select { + case <-keyFrames: + case <-time.After(3 * time.Second): + t.Fatal("PLI did not reach the shared source") + } + + if err = edgeA.Close(); err != nil { + t.Fatal(err) + } + edgeC, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "edge-c"}) + if err != nil { + t.Fatalf("released source slot was not reusable: %v", err) + } + _ = edgeC.Close() +} + +func TestRemoteCandidatesAreBoundedUntilTheAnswer(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", + IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: "edge"}) + if err != nil { + t.Fatal(err) + } + for index := 0; index < maxPendingCandidates; index++ { + if err = edge.AddRemoteCandidate(&webrtc.ICECandidateInit{ + Candidate: "candidate:1 1 udp 1 127.0.0.1 9 typ host", + }); err != nil { + t.Fatalf("candidate %d: %v", index, err) + } + } + if err = edge.AddRemoteCandidate(&webrtc.ICECandidateInit{ + Candidate: "candidate:1 1 udp 1 127.0.0.1 9 typ host", + }); err == nil { + t.Fatal("unbounded candidate queue was accepted") + } +} + +func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = source.Close() }) + _, receiver, packets := connectedReceiver(t, engine, source, "timed-edge") + t.Cleanup(func() { _ = receiver.Close() }) + accessUnit := []byte{0, 0, 0, 1, 0x65, 0x88, 0x84, 0x00} + start := 10 * time.Second + frameDuration := time.Second / 30 + + if err = source.WriteH264(accessUnit, start, frameDuration); err != nil { + t.Fatal(err) + } + first := waitPacket(t, packets) + if err = source.WriteH264(accessUnit, start+frameDuration, frameDuration); err != nil { + t.Fatal(err) + } + second := waitPacket(t, packets) + if got := second.Timestamp - first.Timestamp; got < 2_999 || got > 3_001 { + t.Fatalf("steady timestamp delta = %d", got) + } + if err = source.WriteH264(accessUnit, start+5*time.Second, frameDuration); err != nil { + t.Fatal(err) + } + third := waitPacket(t, packets) + if got := third.Timestamp - second.Timestamp; got < 446_999 || got > 447_001 { + t.Fatalf("sparse timestamp delta = %d", got) + } +} + +func TestOneLocalBridgeDoesNotConsumeRouteCapacity(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + audio, err := engine.NewAudioSource(1, 128_000) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = audio.Close() }) + local, err := engine.NewEdge(source, EdgeOptions{ + ConnectionID: "local-preview", Local: true, Audio: audio, + }) + if err != nil { + t.Fatal(err) + } + route, err := engine.NewEdge(source, EdgeOptions{ + ConnectionID: "route-edge", Audio: audio, + }) + if err != nil { + t.Fatal(err) + } + if _, err = engine.NewEdge(source, EdgeOptions{ + ConnectionID: "second-local", Local: true, + }); !errors.Is(err, ErrSourceCapacity) { + t.Fatalf("second local edge error = %v", err) + } + if _, err = engine.NewEdge(source, EdgeOptions{ + ConnectionID: "second-route", + }); !errors.Is(err, ErrSourceCapacity) { + t.Fatalf("second route edge error = %v", err) + } + _ = local.Close() + _ = route.Close() + if replacement, replaceErr := engine.NewEdge(source, EdgeOptions{ + ConnectionID: "replacement-local", Local: true, Audio: audio, + }); replaceErr != nil { + t.Fatalf("released local slot was not reusable: %v", replaceErr) + } else { + _ = replacement.Close() + } +} + +func TestBandwidthObserverReceivesTransportFeedbackWithoutPacing(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + edge, receiver, packets := connectedReceiver(t, engine, source, "quality-edge") + t.Cleanup(func() { _ = receiver.Close() }) + if !strings.Contains(edge.connection.LocalDescription().SDP, "transport-cc") { + t.Fatal("native media offer did not negotiate transport feedback") + } + source.SetFormat(1280, 720) + started := time.Now() + if _, ok := edge.QualitySample(started); ok { + t.Fatal("first quality sample did not establish a baseline") + } + + accessUnit := []byte{ + 0, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1f, 0x96, 0x54, 0x05, 0x01, + 0, 0, 0, 1, 0x68, 0xce, 0x3c, 0x80, + 0, 0, 0, 1, 0x65, 0x88, 0x84, 0x00, + } + deadline := time.Now().Add(5 * time.Second) + captureTimestamp := time.Second + for time.Now().Before(deadline) { + if err = source.WriteH264( + accessUnit, + captureTimestamp, + time.Second/30, + ); err != nil { + t.Fatal(err) + } + captureTimestamp += time.Second / 30 + select { + case <-packets: + default: + } + if bitrate, observed := edge.bandwidth.targetBitrate(); observed && time.Since(started) >= time.Second { + if bitrate <= 0 { + t.Fatalf("observed target bitrate = %d", bitrate) + } + sample, ok := edge.QualitySample(time.Now()) + if !ok || sample.State != "healthy" || sample.Reason == nil || + *sample.Reason != "none" || sample.IntervalFramesEncoded == 0 || + sample.RTPStatsID == "" || sample.TrackIdentifier == "" { + t.Fatalf("quality sample = %+v, %v", sample, ok) + } + return + } + time.Sleep(time.Second / 30) + } + t.Fatal("transport feedback did not produce a bandwidth estimate") +} + +func TestAudioUsesTheSamePeerConnectionAndCapacityAsVideo(t *testing.T) { + engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + video, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + audio, err := engine.NewAudioSource(1, 128_000) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = video.Close(); _ = audio.Close() }) + edge, err := engine.NewEdge(video, EdgeOptions{ + ConnectionID: "audio-edge", + Audio: audio, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = edge.Close() }) + if edge.audioSender == nil { + t.Fatal("audio sender was not attached") + } + if _, err = engine.NewEdge(video, EdgeOptions{ConnectionID: "video-only"}); !errors.Is(err, ErrSourceCapacity) { + t.Fatalf("video capacity was not shared: %v", err) + } + if _, err = engine.NewEdge(video, EdgeOptions{ConnectionID: "audio-only", Audio: audio}); !errors.Is(err, ErrSourceCapacity) { + t.Fatalf("audio capacity was not shared: %v", err) + } + offer, err := edge.CreateOffer() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(offer.SDP, "m=audio") || !strings.Contains(offer.SDP, "opus/48000") { + t.Fatalf("same connection offer has no Opus section: %s", offer.SDP) + } +} + +func TestAudioSourceDeliversOpusOnTheVideoPeerConnection(t *testing.T) { + engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + video, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + audio, err := engine.NewAudioSource(1, 128_000) + if err != nil { + t.Fatal(err) + } + edge, err := engine.NewEdge(video, EdgeOptions{ConnectionID: "audio-edge", Audio: audio}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = edge.Close(); _ = video.Close(); _ = audio.Close() }) + receiver := newAudioReceiver(t) + t.Cleanup(func() { _ = receiver.Close() }) + audioPackets := make(chan *rtp.Packet, 1) + receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + if track.Kind() != webrtc.RTPCodecTypeAudio { + return + } + packet, _, readErr := track.ReadRTP() + if readErr == nil { + audioPackets <- packet + } + }) + connectEdgeToReceiver(t, edge, receiver) + if err = audio.WritePCM(make([]byte, nativeaudio.FrameBytes), 20*time.Millisecond); err != nil { + t.Fatal(err) + } + select { + case packet := <-audioPackets: + if packet.PayloadType != 111 || len(packet.Payload) == 0 { + t.Fatalf("unexpected Opus RTP packet: payload type %d, %d bytes", packet.PayloadType, len(packet.Payload)) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Opus RTP") + } +} + +func connectedReceiver( + t *testing.T, + engine *Engine, + source *Source, + connectionID string, +) (*Edge, *webrtc.PeerConnection, <-chan *rtp.Packet) { + t.Helper() + edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: connectionID}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = edge.Close() }) + receiver := newReceiver(t) + packets := make(chan *rtp.Packet, 1) + receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + go func() { + for { + packet, _, readErr := track.ReadRTP() + if readErr != nil { + return + } + select { + case packets <- packet: + default: + } + } + }() + }) + + connectEdgeToReceiver(t, edge, receiver) + return edge, receiver, packets +} + +func connectEdgeToReceiver( + t *testing.T, + edge *Edge, + receiver *webrtc.PeerConnection, +) { + t.Helper() + gatherOffer := webrtc.GatheringCompletePromise(edge.connection) + if _, err := edge.CreateOffer(); err != nil { + t.Fatal(err) + } + waitSignal(t, gatherOffer, "offer ICE gathering") + if err := receiver.SetRemoteDescription(*edge.connection.LocalDescription()); err != nil { + t.Fatal(err) + } + gatherAnswer := webrtc.GatheringCompletePromise(receiver) + answer, err := receiver.CreateAnswer(nil) + if err != nil { + t.Fatal(err) + } + if err = receiver.SetLocalDescription(answer); err != nil { + t.Fatal(err) + } + waitSignal(t, gatherAnswer, "answer ICE gathering") + if err = edge.SetAnswer(*receiver.LocalDescription()); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for edge.State() != webrtc.PeerConnectionStateConnected { + if time.Now().After(deadline) { + t.Fatalf("edge state = %s", edge.State()) + } + time.Sleep(10 * time.Millisecond) + } +} + +func newReceiver(t *testing.T) *webrtc.PeerConnection { + return newReceiverWithAudio(t, false) +} + +func newAudioReceiver(t *testing.T) *webrtc.PeerConnection { + return newReceiverWithAudio(t, true) +} + +func newReceiverWithAudio(t *testing.T, includeAudio bool) *webrtc.PeerConnection { + t.Helper() + mediaEngine := &webrtc.MediaEngine{} + if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: h264Capability, + PayloadType: 102, + }, webrtc.RTPCodecTypeVideo); err != nil { + t.Fatal(err) + } + if includeAudio { + if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: opusCapability, + PayloadType: 111, + }, webrtc.RTPCodecTypeAudio); err != nil { + t.Fatal(err) + } + } + registry := &interceptor.Registry{} + if err := webrtc.ConfigureTWCCSender(mediaEngine, registry); err != nil { + t.Fatal(err) + } + if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil { + t.Fatal(err) + } + settings := webrtc.SettingEngine{} + settings.SetIncludeLoopbackCandidate(true) + settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4}) + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + mux := webrtc.NewICEUDPMux(logging.NewDefaultLoggerFactory().NewLogger("mediaedge-test"), udp) + settings.SetICEUDPMux(mux) + t.Cleanup(func() { _ = mux.Close() }) + api := webrtc.NewAPI( + webrtc.WithMediaEngine(mediaEngine), + webrtc.WithInterceptorRegistry(registry), + webrtc.WithSettingEngine(settings), + ) + connection, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + if includeAudio { + if _, err = connection.AddTransceiverFromKind( + webrtc.RTPCodecTypeAudio, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}, + ); err != nil { + t.Fatal(err) + } + } + return connection +} + +func waitSignal(t *testing.T, signal <-chan struct{}, label string) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", label) + } +} + +func waitPacket(t *testing.T, packets <-chan *rtp.Packet) *rtp.Packet { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for RTP") + return nil + } +} diff --git a/native/client/internal/mediaedge/engine.go b/native/client/internal/mediaedge/engine.go new file mode 100644 index 0000000..55a6432 --- /dev/null +++ b/native/client/internal/mediaedge/engine.go @@ -0,0 +1,183 @@ +package mediaedge + +import ( + "errors" + "net" + "sync" + + "github.com/TNTcraftHIM/Screener/native/client/internal/portmapping" + "github.com/pion/interceptor" + "github.com/pion/logging" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v4" +) + +const H264ProfileLevelID = "42c01f" + +var h264Capability = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90_000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=" + H264ProfileLevelID, + RTCPFeedback: []webrtc.RTCPFeedback{ + {Type: "goog-remb"}, + {Type: webrtc.TypeRTCPFBTransportCC}, + {Type: "ccm", Parameter: "fir"}, + {Type: "nack"}, + {Type: "nack", Parameter: "pli"}, + }, +} + +type EngineOptions struct { + BindAddress string + IncludeLoopback bool + PortMapping bool +} + +type Engine struct { + api *webrtc.API + mux interface{ Close() error } + listenAddress string + portMapping *portmapping.Mapping + bandwidth *bandwidthObservers + + mu sync.Mutex + edges map[*Edge]struct{} + closed bool +} + +func NewEngine(options EngineOptions) (*Engine, error) { + bindAddress := options.BindAddress + if bindAddress == "" { + bindAddress = "0.0.0.0:0" + } + address, err := net.ResolveUDPAddr("udp4", bindAddress) + if err != nil { + return nil, errors.New("native media UDP bind address is invalid") + } + connection, err := net.ListenUDP("udp4", address) + if err != nil { + return nil, errors.New("native media UDP socket is unavailable") + } + loggerFactory := logging.NewDefaultLoggerFactory() + mux := webrtc.NewICEUDPMux(loggerFactory.NewLogger("screener-ice"), connection) + settingEngine := webrtc.SettingEngine{LoggerFactory: loggerFactory} + settingEngine.SetICEUDPMux(mux) + settingEngine.SetIncludeLoopbackCandidate(options.IncludeLoopback) + + mediaEngine := &webrtc.MediaEngine{} + if err = mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: h264Capability, + PayloadType: h264PayloadType, + }, webrtc.RTPCodecTypeVideo); err != nil { + _ = mux.Close() + return nil, err + } + if err = mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: opusCapability, + PayloadType: 111, + }, webrtc.RTPCodecTypeAudio); err != nil { + _ = mux.Close() + return nil, err + } + registry := &interceptor.Registry{} + bandwidth, err := configureBandwidthObservers(mediaEngine, registry) + if err != nil { + _ = mux.Close() + return nil, err + } + if err = webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil { + _ = mux.Close() + return nil, err + } + engine := &Engine{ + api: webrtc.NewAPI( + webrtc.WithMediaEngine(mediaEngine), + webrtc.WithInterceptorRegistry(registry), + webrtc.WithSettingEngine(settingEngine), + ), + mux: mux, + listenAddress: connection.LocalAddr().String(), + bandwidth: bandwidth, + edges: make(map[*Edge]struct{}), + } + if options.PortMapping { + engine.portMapping = portmapping.Start(connection.LocalAddr().(*net.UDPAddr).Port) + } + return engine, nil +} + +func (engine *Engine) ListenAddress() string { + return engine.listenAddress +} + +func (engine *Engine) NewSource(capacity int, requestKeyFrame func()) (*Source, error) { + if capacity < 1 || capacity > 4 { + return nil, errors.New("native media source capacity is outside the route bound") + } + engine.mu.Lock() + defer engine.mu.Unlock() + if engine.closed { + return nil, errors.New("native media engine is closed") + } + track, err := webrtc.NewTrackLocalStaticRTP( + h264Capability, + "screen", + "screener-native", + ) + if err != nil { + return nil, err + } + return &Source{ + engine: engine, + track: track, + packetizer: rtp.NewPacketizer( + h264PacketMTU, + h264PayloadType, + 0, + &codecs.H264Payloader{}, + rtp.NewRandomSequencer(), + h264ClockRate, + ), + capacity: capacity, + requestKeyFrame: requestKeyFrame, + edges: make(map[*Edge]bool), + }, nil +} + +func (engine *Engine) register(edge *Edge) error { + engine.mu.Lock() + defer engine.mu.Unlock() + if engine.closed { + return errors.New("native media engine is closed") + } + engine.edges[edge] = struct{}{} + return nil +} + +func (engine *Engine) remove(edge *Edge) { + engine.mu.Lock() + delete(engine.edges, edge) + engine.mu.Unlock() +} + +func (engine *Engine) Close() error { + engine.mu.Lock() + if engine.closed { + engine.mu.Unlock() + return nil + } + engine.closed = true + edges := make([]*Edge, 0, len(engine.edges)) + for edge := range engine.edges { + edges = append(edges, edge) + } + engine.mu.Unlock() + for _, edge := range edges { + _ = edge.Close() + } + if engine.portMapping != nil { + engine.portMapping.Close() + } + return engine.mux.Close() +} diff --git a/native/client/internal/mediaedge/quality.go b/native/client/internal/mediaedge/quality.go new file mode 100644 index 0000000..fc6fd78 --- /dev/null +++ b/native/client/internal/mediaedge/quality.go @@ -0,0 +1,158 @@ +package mediaedge + +import ( + "sync" + "time" + + "github.com/pion/interceptor" + "github.com/pion/interceptor/pkg/cc" + "github.com/pion/interceptor/pkg/gcc" + "github.com/pion/webrtc/v4" +) + +// This bootstraps GCC at the current fixed native encoder rate. Classification +// starts only after real feedback and uses measured payload, not this value. +const nativeSourceInitialBitrate = 3_000_000 + +type targetBitrateEstimator interface { + GetTargetBitrate() int + OnTargetBitrateChange(func(int)) +} + +type bandwidthObserver struct { + estimator targetBitrateEstimator + + mu sync.RWMutex + observed bool +} + +func newBandwidthObserver(estimator targetBitrateEstimator) *bandwidthObserver { + observer := &bandwidthObserver{estimator: estimator} + estimator.OnTargetBitrateChange(func(int) { + observer.mu.Lock() + observer.observed = true + observer.mu.Unlock() + }) + return observer +} + +type QualitySample struct { + SampleTimestampMs int64 + SampleWindowMs int64 + RTPStatsID string + TrackIdentifier string + State string + Reason *string + IntervalFramesEncoded uint64 + FramesPerSecond float64 + BitrateKbps float64 + AvailableOutgoingKbps float64 + Width uint32 + Height uint32 +} + +type qualityBaseline struct { + at time.Time + frames uint64 + videoBytes uint64 + audioBytes uint64 +} + +func (edge *Edge) QualitySample(now time.Time) (QualitySample, bool) { + video := edge.source.snapshot() + audioBytes := edge.audioSource.snapshotBytes() + edge.qualityMu.Lock() + previous := edge.qualityBaseline + edge.qualityBaseline = qualityBaseline{ + at: now, frames: video.frames, videoBytes: video.bytes, audioBytes: audioBytes, + } + edge.qualityMu.Unlock() + if previous.at.IsZero() { + return QualitySample{}, false + } + window := now.Sub(previous.at) + if window < time.Second || window > 5*time.Second || + video.frames < previous.frames || video.bytes < previous.videoBytes || + audioBytes < previous.audioBytes { + return QualitySample{}, false + } + frames := video.frames - previous.frames + bytes := video.bytes - previous.videoBytes + audioBytes - previous.audioBytes + seconds := window.Seconds() + bitrate := float64(bytes*8) / seconds + sample := QualitySample{ + SampleTimestampMs: now.UnixMilli(), + SampleWindowMs: window.Milliseconds(), + RTPStatsID: edge.connection.ID(), + TrackIdentifier: edge.source.track.ID(), + State: "unknown", + IntervalFramesEncoded: frames, + FramesPerSecond: float64(frames) / seconds, + BitrateKbps: bitrate / 1000, + Width: video.width, + Height: video.height, + } + target, observed := edge.bandwidth.targetBitrate() + if edge.State() != webrtc.PeerConnectionStateConnected || !observed || + frames == 0 || bytes == 0 || video.width == 0 || video.height == 0 { + return sample, true + } + sample.AvailableOutgoingKbps = float64(target) / 1000 + reason := "none" + sample.State = "healthy" + if float64(target) < bitrate { + reason = "bandwidth" + sample.State = "degraded" + } + sample.Reason = &reason + return sample, true +} + +func (observer *bandwidthObserver) targetBitrate() (int, bool) { + observer.mu.RLock() + observed := observer.observed + observer.mu.RUnlock() + if !observed { + return 0, false + } + return observer.estimator.GetTargetBitrate(), true +} + +type bandwidthObservers struct { + mu sync.Mutex + pending map[string]*bandwidthObserver +} + +func configureBandwidthObservers( + media *webrtc.MediaEngine, + registry *interceptor.Registry, +) (*bandwidthObservers, error) { + observers := &bandwidthObservers{pending: make(map[string]*bandwidthObserver)} + factory, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) { + return gcc.NewSendSideBWE( + gcc.SendSideBWEInitialBitrate(nativeSourceInitialBitrate), + gcc.SendSideBWEPacer(gcc.NewNoOpPacer()), + ) + }) + if err != nil { + return nil, err + } + factory.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) { + observers.mu.Lock() + observers.pending[id] = newBandwidthObserver(estimator) + observers.mu.Unlock() + }) + registry.Add(factory) + if err = webrtc.ConfigureTWCCHeaderExtensionSender(media, registry); err != nil { + return nil, err + } + return observers, nil +} + +func (observers *bandwidthObservers) take(id string) *bandwidthObserver { + observers.mu.Lock() + defer observers.mu.Unlock() + observer := observers.pending[id] + delete(observers.pending, id) + return observer +} diff --git a/native/client/internal/mediaedge/quality_test.go b/native/client/internal/mediaedge/quality_test.go new file mode 100644 index 0000000..3c08c86 --- /dev/null +++ b/native/client/internal/mediaedge/quality_test.go @@ -0,0 +1,50 @@ +package mediaedge + +import ( + "testing" + "time" +) + +type fixedTargetEstimator struct { + target int +} + +func (estimator fixedTargetEstimator) GetTargetBitrate() int { + return estimator.target +} + +func (estimator fixedTargetEstimator) OnTargetBitrateChange(callback func(int)) { + callback(estimator.target) +} + +func TestQualitySampleUsesEstimatorCapacityAgainstEncodedPayload(t *testing.T) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource(1, nil) + if err != nil { + t.Fatal(err) + } + edge, receiver, _ := connectedReceiver(t, engine, source, "limited-edge") + t.Cleanup(func() { _ = receiver.Close() }) + edge.bandwidth = newBandwidthObserver(fixedTargetEstimator{target: 100_000}) + source.SetFormat(1280, 720) + started := time.Now() + if _, ok := edge.QualitySample(started); ok { + t.Fatal("first quality sample did not establish a baseline") + } + source.frames.Add(60) + source.bytes.Add(1_000_000) + + sample, ok := edge.QualitySample(started.Add(2 * time.Second)) + if !ok || sample.State != "degraded" || sample.Reason == nil || + *sample.Reason != "bandwidth" || sample.IntervalFramesEncoded != 60 || + sample.FramesPerSecond != 30 || sample.BitrateKbps != 4_000 || + sample.AvailableOutgoingKbps != 100 { + t.Fatalf("quality sample = %+v, %v", sample, ok) + } +} diff --git a/native/client/internal/mediaedge/source.go b/native/client/internal/mediaedge/source.go new file mode 100644 index 0000000..9a7d06e --- /dev/null +++ b/native/client/internal/mediaedge/source.go @@ -0,0 +1,190 @@ +package mediaedge + +import ( + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +const maxAccessUnitBytes = 4 * 1024 * 1024 +const h264ClockRate = 90_000 +const h264PacketMTU = 1200 +const h264PayloadType = 102 + +var ErrSourceCapacity = errors.New("native media source capacity is exhausted") +var ErrInvalidVideoTimestamp = errors.New("native video timestamp is not monotonic") + +type Source struct { + engine *Engine + track *webrtc.TrackLocalStaticRTP + packetizer rtp.Packetizer + capacity int + requestKeyFrame func() + + mu sync.Mutex + writeMu sync.Mutex + edges map[*Edge]bool + reservations int + localReservation bool + closed bool + hasTimestamp bool + lastTimestamp time.Duration + timestampRemainder float64 + frames atomic.Uint64 + bytes atomic.Uint64 + format atomic.Uint64 +} + +func (source *Source) WriteH264( + accessUnit []byte, + timestamp time.Duration, + duration time.Duration, +) error { + if len(accessUnit) == 0 || len(accessUnit) > maxAccessUnitBytes || + timestamp < 0 || duration <= 0 { + return errors.New("native H264 access unit is invalid") + } + source.writeMu.Lock() + defer source.writeMu.Unlock() + source.mu.Lock() + closed := source.closed + source.mu.Unlock() + if closed { + return errors.New("native media source is closed") + } + if source.hasTimestamp { + if timestamp <= source.lastTimestamp { + return ErrInvalidVideoTimestamp + } + ticksFloat := (timestamp-source.lastTimestamp).Seconds()*h264ClockRate + + source.timestampRemainder + ticks := uint64(ticksFloat) + source.timestampRemainder = ticksFloat - float64(ticks) + source.packetizer.SkipSamples(uint32(ticks)) + } else { + source.hasTimestamp = true + } + source.lastTimestamp = timestamp + packets := source.packetizer.Packetize(accessUnit, 0) + if len(packets) == 0 { + return errors.New("native H264 access unit produced no RTP packets") + } + source.frames.Add(1) + source.bytes.Add(uint64(len(accessUnit))) + var result error + for _, packet := range packets { + result = errors.Join(result, source.track.WriteRTP(packet)) + } + return result +} + +func (source *Source) SetFormat(width, height uint32) { + source.format.Store(uint64(width)<<32 | uint64(height)) +} + +type sourceSnapshot struct { + frames uint64 + bytes uint64 + width uint32 + height uint32 +} + +func (source *Source) snapshot() sourceSnapshot { + format := source.format.Load() + return sourceSnapshot{ + frames: source.frames.Load(), + bytes: source.bytes.Load(), + width: uint32(format >> 32), + height: uint32(format), + } +} + +func (source *Source) reserve(local bool) error { + source.mu.Lock() + defer source.mu.Unlock() + if source.closed { + return errors.New("native media source is closed") + } + if local { + if source.localReservation { + return ErrSourceCapacity + } + for _, existingLocal := range source.edges { + if existingLocal { + return ErrSourceCapacity + } + } + source.localReservation = true + return nil + } + routeEdges := 0 + for _, existingLocal := range source.edges { + if !existingLocal { + routeEdges++ + } + } + if routeEdges+source.reservations >= source.capacity { + return ErrSourceCapacity + } + source.reservations++ + return nil +} + +func (source *Source) attach(edge *Edge, local bool) error { + source.mu.Lock() + defer source.mu.Unlock() + if local { + source.localReservation = false + } else if source.reservations > 0 { + source.reservations-- + } + if source.closed { + return errors.New("native media source is closed") + } + source.edges[edge] = local + return nil +} + +func (source *Source) releaseReservation(local bool) { + source.mu.Lock() + if local { + source.localReservation = false + } else if source.reservations > 0 { + source.reservations-- + } + source.mu.Unlock() +} + +func (source *Source) detach(edge *Edge) { + source.mu.Lock() + delete(source.edges, edge) + source.mu.Unlock() +} + +func (source *Source) RequestRecoveryFrame() { + if source.requestKeyFrame != nil { + source.requestKeyFrame() + } +} + +func (source *Source) Close() error { + source.mu.Lock() + if source.closed { + source.mu.Unlock() + return nil + } + source.closed = true + edges := make([]*Edge, 0, len(source.edges)) + for edge := range source.edges { + edges = append(edges, edge) + } + source.mu.Unlock() + for _, edge := range edges { + _ = edge.Close() + } + return nil +} diff --git a/native/client/internal/nativeaudio/encoder.go b/native/client/internal/nativeaudio/encoder.go new file mode 100644 index 0000000..977d062 --- /dev/null +++ b/native/client/internal/nativeaudio/encoder.go @@ -0,0 +1,73 @@ +package nativeaudio + +import ( + "encoding/binary" + "errors" + + gopus "github.com/thesyncim/gopus" +) + +const ( + SampleRate = 48_000 + Channels = 2 + FrameSamples = 960 + FrameBytes = FrameSamples * Channels * 2 + MaxPacketBytes = 4_000 + DefaultBitrate = 128_000 + defaultComplexity = 5 +) + +// Encoder adapts the capture process's fixed 48 kHz PCM frames to WebRTC +// Opus packets. The buffers are owned by the encoder and reused after each +// Encode call; the returned packet must be consumed before the next call. +type Encoder struct { + codec *gopus.Encoder + pcm [FrameSamples * Channels]int16 + packet [MaxPacketBytes]byte +} + +func NewEncoder(bitrate int) (*Encoder, error) { + if bitrate < 6_000 || bitrate > 510_000 { + return nil, errors.New("native audio bitrate is outside the Opus range") + } + codec, err := gopus.NewEncoder(gopus.EncoderConfig{ + SampleRate: SampleRate, + Channels: Channels, + Application: gopus.ApplicationAudio, + }) + if err != nil { + return nil, err + } + if err = codec.SetBitrate(bitrate); err != nil { + return nil, err + } + if err = codec.SetComplexity(defaultComplexity); err != nil { + return nil, err + } + codec.SetDTX(false) + if err = codec.SetInBandFEC(gopus.InBandFECDisabled); err != nil { + return nil, err + } + return &Encoder{codec: codec}, nil +} + +// Encode accepts one interleaved little-endian PCM16 frame and returns an +// Opus packet backed by the encoder's reusable output buffer. +func (encoder *Encoder) Encode(pcm []byte) ([]byte, error) { + if encoder == nil || encoder.codec == nil || len(pcm) != FrameBytes { + return nil, errors.New("native audio PCM frame is invalid") + } + for index := range encoder.pcm { + encoder.pcm[index] = int16(binary.LittleEndian.Uint16( + pcm[index*2 : index*2+2], + )) + } + count, err := encoder.codec.EncodeInt16(encoder.pcm[:], encoder.packet[:]) + if err != nil { + return nil, err + } + if count <= 0 || count > len(encoder.packet) { + return nil, errors.New("native audio encoder returned an invalid packet") + } + return encoder.packet[:count], nil +} diff --git a/native/client/internal/nativeaudio/encoder_test.go b/native/client/internal/nativeaudio/encoder_test.go new file mode 100644 index 0000000..9168e87 --- /dev/null +++ b/native/client/internal/nativeaudio/encoder_test.go @@ -0,0 +1,113 @@ +package nativeaudio + +import ( + "encoding/binary" + "math" + "testing" + + gopus "github.com/thesyncim/gopus" +) + +func TestEncodeProducesAReusableOpusPacket(t *testing.T) { + encoder, err := NewEncoder(DefaultBitrate) + if err != nil { + t.Fatal(err) + } + pcm := make([]byte, FrameBytes) + first, err := encoder.Encode(pcm) + if err != nil || len(first) == 0 { + t.Fatalf("first packet = %d bytes, %v", len(first), err) + } + firstCopy := append([]byte(nil), first...) + second, err := encoder.Encode(pcm) + if err != nil || len(second) == 0 { + t.Fatalf("second packet = %d bytes, %v", len(second), err) + } + if string(firstCopy) != string(second) { + t.Fatalf("silence packet changed between identical frames") + } + if &first[0] != &second[0] { + t.Fatalf("encoder did not reuse its output buffer") + } +} + +func TestEncodeRejectsNonFrameSizedPCM(t *testing.T) { + encoder, err := NewEncoder(DefaultBitrate) + if err != nil { + t.Fatal(err) + } + if _, err = encoder.Encode(make([]byte, FrameBytes-2)); err == nil { + t.Fatal("short PCM frame was accepted") + } + if _, err = encoder.Encode(make([]byte, FrameBytes+2)); err == nil { + t.Fatal("long PCM frame was accepted") + } +} + +func TestEncodePreservesAudiblePCM(t *testing.T) { + encoder, err := NewEncoder(DefaultBitrate) + if err != nil { + t.Fatal(err) + } + pcm := make([]byte, FrameBytes) + for frame := 0; frame < FrameSamples; frame++ { + sample := int16(math.Sin(2*math.Pi*440*float64(frame)/SampleRate) * 8_000) + for channel := 0; channel < Channels; channel++ { + binary.LittleEndian.PutUint16( + pcm[(frame*Channels+channel)*2:], + uint16(sample), + ) + } + } + packet, err := encoder.Encode(pcm) + if err != nil { + t.Fatal(err) + } + decoder, err := gopus.NewDecoder(gopus.DefaultDecoderConfig(SampleRate, Channels)) + if err != nil { + t.Fatal(err) + } + decoded := make([]int16, FrameSamples*Channels) + samples, err := decoder.DecodeInt16(packet, decoded) + if err != nil { + t.Fatal(err) + } + energy := int64(0) + for _, sample := range decoded[:samples*Channels] { + if sample < 0 { + energy -= int64(sample) + } else { + energy += int64(sample) + } + } + if energy == 0 { + t.Fatal("Opus round trip produced silence") + } +} + +func TestNewEncoderRejectsOutOfRangeBitrate(t *testing.T) { + for _, bitrate := range []int{0, 5_999, 510_001} { + if _, err := NewEncoder(bitrate); err == nil { + t.Fatalf("bitrate %d was accepted", bitrate) + } + } +} + +func TestEncodeSteadyStateAllocationIsBounded(t *testing.T) { + encoder, err := NewEncoder(DefaultBitrate) + if err != nil { + t.Fatal(err) + } + pcm := make([]byte, FrameBytes) + if _, err = encoder.Encode(pcm); err != nil { + t.Fatal(err) + } + allocations := testing.AllocsPerRun(20, func() { + if _, err := encoder.Encode(pcm); err != nil { + t.Fatal(err) + } + }) + if allocations > 4 { + t.Fatalf("steady-state Opus encoding allocated %.1f objects", allocations) + } +} diff --git a/native/client/internal/nativecapture/json.go b/native/client/internal/nativecapture/json.go new file mode 100644 index 0000000..5946b34 --- /dev/null +++ b/native/client/internal/nativecapture/json.go @@ -0,0 +1,20 @@ +package nativecapture + +import ( + "bytes" + "encoding/json" + "errors" + "io" +) + +func decodeStrictJSON(payload []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("trailing JSON data") + } + return nil +} diff --git a/native/client/internal/nativecapture/probe.go b/native/client/internal/nativecapture/probe.go new file mode 100644 index 0000000..d8ce712 --- /dev/null +++ b/native/client/internal/nativecapture/probe.go @@ -0,0 +1,194 @@ +package nativecapture + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + probeProtocol = 3 + probeTimeout = 3 * time.Second + maxProbeOutputBytes = 64 * 1024 + maxProbeErrorBytes = 4 * 1024 + maxAdapters = 64 + maxEncoders = 64 + maxIdentityBytes = 512 +) + +type Encoder struct { + Index uint32 `json:"index"` + Name string `json:"name"` + Identity string `json:"identity"` +} + +type Adapter struct { + Index uint32 `json:"index"` + Name string `json:"name"` + Identity string `json:"identity"` + HardwareH264 []Encoder `json:"hardwareH264"` +} + +type Capabilities struct { + Protocol int `json:"protocol"` + Platform string `json:"platform"` + PlatformBuild string `json:"platformBuild"` + VideoCapture bool `json:"videoCapture"` + ProcessAudio bool `json:"processAudio"` + SystemAudio bool `json:"systemAudio"` + Adapters []Adapter `json:"adapters"` +} + +type Summary struct { + Video bool + ProcessAudio bool + SystemAudio bool + HardwareH264 bool +} + +func (capabilities Capabilities) Summary() Summary { + summary := Summary{ + Video: capabilities.VideoCapture, + ProcessAudio: capabilities.ProcessAudio, + SystemAudio: capabilities.SystemAudio, + } + for _, adapter := range capabilities.Adapters { + if len(adapter.HardwareH264) > 0 { + summary.HardwareH264 = true + break + } + } + return summary +} + +func (summary Summary) AudioFor(kind string) bool { + if kind == "window" { + return summary.ProcessAudio + } + return kind == "display" && summary.SystemAudio +} + +func Discover(parent context.Context, executable string) (Capabilities, error) { + if strings.TrimSpace(executable) == "" { + return Capabilities{}, nil + } + if _, err := os.Stat(executable); err != nil { + if errors.Is(err, os.ErrNotExist) { + return Capabilities{}, nil + } + return Capabilities{}, fmt.Errorf("inspect native capture process: %w", err) + } + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + stdout := &boundedBuffer{limit: maxProbeOutputBytes} + stderr := &boundedBuffer{limit: maxProbeErrorBytes} + command := exec.CommandContext(ctx, executable, "--probe") + command.Stdout = stdout + command.Stderr = stderr + hideWindow(command) + if err := command.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return Capabilities{}, errors.New("native capture probe timed out") + } + return Capabilities{}, fmt.Errorf("native capture probe failed: %w", err) + } + capabilities, err := decodeProbe(stdout.Bytes()) + if err != nil { + return Capabilities{}, err + } + if capabilities.Platform != runtime.GOOS { + return Capabilities{}, errors.New("native capture platform does not match the Client") + } + return capabilities, nil +} + +func PackagedExecutable() string { + executable, err := os.Executable() + if err != nil { + return "" + } + name := "" + switch runtime.GOOS { + case "windows": + name = "screener-client-capture.exe" + case "darwin": + name = "screener-client-capture" + default: + return "" + } + return filepath.Join(filepath.Dir(executable), "runtime", "native", name) +} + +func decodeProbe(payload []byte) (Capabilities, error) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var capabilities Capabilities + if err := decoder.Decode(&capabilities); err != nil { + return Capabilities{}, errors.New("native capture probe returned invalid JSON") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Capabilities{}, errors.New("native capture probe returned trailing data") + } + if capabilities.Protocol != probeProtocol || + (capabilities.Platform != "windows" && capabilities.Platform != "darwin" && + capabilities.Platform != "linux") || + len(capabilities.PlatformBuild) == 0 || + len(capabilities.PlatformBuild) > maxIdentityBytes || + len(capabilities.Adapters) > maxAdapters { + return Capabilities{}, errors.New("native capture probe returned an invalid contract") + } + adapterIndexes := make(map[uint32]struct{}, len(capabilities.Adapters)) + for _, adapter := range capabilities.Adapters { + if len(adapter.Name) == 0 || len(adapter.Name) > maxIdentityBytes || + len(adapter.Identity) == 0 || len(adapter.Identity) > maxIdentityBytes || + len(adapter.HardwareH264) > maxEncoders { + return Capabilities{}, errors.New("native capture probe returned an invalid adapter") + } + if _, exists := adapterIndexes[adapter.Index]; exists { + return Capabilities{}, errors.New("native capture probe returned duplicate adapters") + } + adapterIndexes[adapter.Index] = struct{}{} + encoderIndexes := make(map[uint32]struct{}, len(adapter.HardwareH264)) + for _, encoder := range adapter.HardwareH264 { + if len(encoder.Name) == 0 || len(encoder.Name) > maxIdentityBytes || + len(encoder.Identity) == 0 || len(encoder.Identity) > maxIdentityBytes { + return Capabilities{}, errors.New("native capture probe returned an invalid encoder") + } + if _, exists := encoderIndexes[encoder.Index]; exists { + return Capabilities{}, errors.New("native capture probe returned duplicate encoders") + } + encoderIndexes[encoder.Index] = struct{}{} + } + } + return capabilities, nil +} + +type boundedBuffer struct { + buffer bytes.Buffer + limit int +} + +func (buffer *boundedBuffer) Write(payload []byte) (int, error) { + remaining := buffer.limit - buffer.buffer.Len() + if remaining <= 0 { + return 0, errors.New("native capture probe output exceeded its bound") + } + if len(payload) > remaining { + _, _ = buffer.buffer.Write(payload[:remaining]) + return remaining, errors.New("native capture probe output exceeded its bound") + } + return buffer.buffer.Write(payload) +} + +func (buffer *boundedBuffer) Bytes() []byte { + return buffer.buffer.Bytes() +} diff --git a/native/client/internal/nativecapture/probe_test.go b/native/client/internal/nativecapture/probe_test.go new file mode 100644 index 0000000..5ac64cb --- /dev/null +++ b/native/client/internal/nativecapture/probe_test.go @@ -0,0 +1,48 @@ +package nativecapture + +import ( + "strings" + "testing" +) + +const validProbe = `{"protocol":3,"platform":"windows","platformBuild":"26200","videoCapture":true,"processAudio":true,"systemAudio":true,"adapters":[{"index":0,"name":"GPU","identity":"0x0:0x1","hardwareH264":[{"index":0,"name":"H264","identity":"{encoder}"}]}]}` + +func TestDecodeProbeSeparatesCaptureAudioAndHardwareEncode(t *testing.T) { + capabilities, err := decodeProbe([]byte(validProbe)) + if err != nil { + t.Fatal(err) + } + summary := capabilities.Summary() + if !summary.Video || !summary.ProcessAudio || !summary.SystemAudio || + !summary.HardwareH264 { + t.Fatalf("summary = %+v", summary) + } +} + +func TestDecodeProbeRejectsUnknownTrailingAndDuplicateData(t *testing.T) { + tests := []string{ + strings.Replace(validProbe, `"protocol":3`, `"protocol":3,"extra":true`, 1), + strings.Replace(validProbe, `"protocol":3`, `"protocol":1`, 1), + validProbe + `{}`, + strings.Replace(validProbe, `"adapters":[`, `"adapters":[{"index":0,"name":"GPU 2","identity":"0x0:0x2","hardwareH264":[]},`, 1), + } + for _, payload := range tests { + if _, err := decodeProbe([]byte(payload)); err == nil { + t.Fatalf("invalid probe was accepted: %s", payload) + } + } +} + +func TestSummaryDoesNotConflateVideoAudioAndEncoder(t *testing.T) { + capabilities, err := decodeProbe([]byte( + `{"protocol":3,"platform":"windows","platformBuild":"19045","videoCapture":true,"processAudio":false,"systemAudio":true,"adapters":[{"index":0,"name":"GPU","identity":"0x0:0x1","hardwareH264":[]}]}`, + )) + if err != nil { + t.Fatal(err) + } + summary := capabilities.Summary() + if !summary.Video || summary.ProcessAudio || !summary.SystemAudio || + summary.HardwareH264 { + t.Fatalf("summary = %+v", summary) + } +} diff --git a/native/client/internal/nativecapture/process_other.go b/native/client/internal/nativecapture/process_other.go new file mode 100644 index 0000000..ebecb48 --- /dev/null +++ b/native/client/internal/nativecapture/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package nativecapture + +import "os/exec" + +func hideWindow(_ *exec.Cmd) {} diff --git a/native/client/internal/nativecapture/process_windows.go b/native/client/internal/nativecapture/process_windows.go new file mode 100644 index 0000000..4fb07df --- /dev/null +++ b/native/client/internal/nativecapture/process_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package nativecapture + +import ( + "os/exec" + "syscall" +) + +func hideWindow(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/native/client/internal/nativecapture/protocol.go b/native/client/internal/nativecapture/protocol.go new file mode 100644 index 0000000..cc6f140 --- /dev/null +++ b/native/client/internal/nativecapture/protocol.go @@ -0,0 +1,69 @@ +package nativecapture + +import ( + "encoding/binary" + "errors" + "io" + "time" +) + +const ( + envelopeHeaderBytes = 28 + maxMediaBytes = 1024 * 1024 + maxStatusBytes = 4 * 1024 +) + +type FrameKind uint8 + +const ( + FramePCM FrameKind = 1 + FrameH264 FrameKind = 2 + FrameStatus FrameKind = 3 +) + +type Frame struct { + Kind FrameKind + KeyFrame bool + Timestamp time.Duration + Duration time.Duration + Data []byte +} + +func readFrame(input io.Reader) (Frame, error) { + header := make([]byte, envelopeHeaderBytes) + if _, err := io.ReadFull(input, header); err != nil { + return Frame{}, err + } + kind := FrameKind(header[5]) + size := binary.BigEndian.Uint32(header[24:]) + maximum := uint32(maxMediaBytes) + if kind == FrameStatus { + maximum = maxStatusBytes + } + if string(header[:4]) != "SMED" || header[4] != 1 || header[7] != 0 || + (kind != FramePCM && kind != FrameH264 && kind != FrameStatus) || + size == 0 || size > maximum { + return Frame{}, errors.New("native capture frame header is invalid") + } + timestamp100ns := binary.BigEndian.Uint64(header[8:16]) + duration100ns := binary.BigEndian.Uint64(header[16:24]) + if kind == FrameStatus { + if header[6] != 0 || timestamp100ns != 0 || duration100ns != 0 { + return Frame{}, errors.New("native capture status frame is invalid") + } + } else if duration100ns == 0 || (kind == FramePCM && header[6] != 0) || + (kind == FrameH264 && header[6] > 1) { + return Frame{}, errors.New("native capture media frame is invalid") + } + data := make([]byte, size) + if _, err := io.ReadFull(input, data); err != nil { + return Frame{}, err + } + return Frame{ + Kind: kind, + KeyFrame: kind == FrameH264 && header[6] == 1, + Timestamp: time.Duration(timestamp100ns) * 100 * time.Nanosecond, + Duration: time.Duration(duration100ns) * 100 * time.Nanosecond, + Data: data, + }, nil +} diff --git a/native/client/internal/nativecapture/protocol_test.go b/native/client/internal/nativecapture/protocol_test.go new file mode 100644 index 0000000..fb241fa --- /dev/null +++ b/native/client/internal/nativecapture/protocol_test.go @@ -0,0 +1,115 @@ +package nativecapture + +import ( + "bytes" + "encoding/binary" + "testing" + "time" +) + +func TestReadFrameUsesTheBoundedVersionedEnvelope(t *testing.T) { + payload := []byte{0, 0, 0, 1, 0x65} + header := make([]byte, envelopeHeaderBytes) + copy(header, "SMED") + header[4] = 1 + header[5] = byte(FrameH264) + header[6] = 1 + binary.BigEndian.PutUint64(header[8:16], 123) + binary.BigEndian.PutUint64(header[16:24], 333_333) + binary.BigEndian.PutUint32(header[24:], uint32(len(payload))) + frame, err := readFrame(bytes.NewReader(append(header, payload...))) + if err != nil { + t.Fatal(err) + } + if frame.Kind != FrameH264 || !frame.KeyFrame || + frame.Timestamp != 12_300*time.Nanosecond || + frame.Duration != 33_333_300*time.Nanosecond || + !bytes.Equal(frame.Data, payload) { + t.Fatalf("frame = %+v", frame) + } +} + +func TestReadFrameRejectsInvalidKindsFlagsAndBounds(t *testing.T) { + base := make([]byte, envelopeHeaderBytes+1) + copy(base, "SMED") + base[4] = 1 + base[5] = byte(FrameH264) + binary.BigEndian.PutUint64(base[16:24], 1) + binary.BigEndian.PutUint32(base[24:], 1) + for _, mutate := range []func([]byte){ + func(value []byte) { value[4] = 2 }, + func(value []byte) { value[5] = 9 }, + func(value []byte) { value[6] = 2 }, + func(value []byte) { value[7] = 1 }, + func(value []byte) { binary.BigEndian.PutUint32(value[24:], maxMediaBytes+1) }, + } { + candidate := append([]byte(nil), base...) + mutate(candidate) + if _, err := readFrame(bytes.NewReader(candidate)); err == nil { + t.Fatal("invalid native frame was accepted") + } + } +} + +func TestSourceListRejectsPrivateProtocolDrift(t *testing.T) { + var targets []CaptureTarget + if err := decodeStrictJSON([]byte( + `[{"kind":"window","sourceId":"1","pid":2,"creationTime":"3","title":"Game","extra":true}]`, + ), &targets); err == nil { + t.Fatal("unknown source-list field was accepted") + } +} + +func TestAudioReadyStateIsStrictAndExplicit(t *testing.T) { + if err := validateAudioReadyFrame(Frame{ + Kind: FrameStatus, + Data: []byte(`{"state":"active","audio":true}`), + }); err != nil { + t.Fatal(err) + } + for _, payload := range []string{ + `{"state":"active","audio":false}`, + `{"state":"starting","audio":true}`, + `{"state":"active","audio":true,"extra":true}`, + } { + if err := validateAudioReadyFrame(Frame{ + Kind: FrameStatus, + Data: []byte(payload), + }); err == nil { + t.Fatalf("invalid audio state was accepted: %s", payload) + } + } + if err := validateAudioReadyFrame(Frame{ + Kind: FramePCM, + Data: []byte(`{"state":"active","audio":true}`), + }); err == nil { + t.Fatal("audio PCM was accepted as a ready frame") + } +} + +func TestCaptureTargetIdentityAndAudioScope(t *testing.T) { + window := CaptureTarget{ + Kind: "window", SourceID: "123", PID: 42, + CreationTime: "456", Title: "Game", + } + display := CaptureTarget{Kind: "display", SourceID: "789", Title: "Display 1"} + if !validCaptureTarget(window) || !validCaptureTarget(display) { + t.Fatal("valid capture targets were rejected") + } + for _, invalid := range []CaptureTarget{ + {Kind: "window", SourceID: "123", PID: 42, Title: "Game"}, + {Kind: "display", SourceID: "789", PID: 42, Title: "Display 1"}, + {Kind: "display", SourceID: "789", CreationTime: "456", Title: "Display 1"}, + {Kind: "other", SourceID: "789", Title: "Display 1"}, + } { + if validCaptureTarget(invalid) { + t.Fatalf("invalid capture target was accepted: %+v", invalid) + } + } + audio := Summary{ProcessAudio: true, SystemAudio: true} + windowWithoutProcess := Summary{SystemAudio: true} + if !audio.AudioFor("window") || !audio.AudioFor("display") || + windowWithoutProcess.AudioFor("window") { + t.Fatal("capture audio scope was not separated by target kind") + } +} diff --git a/native/client/internal/nativecapture/stream.go b/native/client/internal/nativecapture/stream.go new file mode 100644 index 0000000..5f5957d --- /dev/null +++ b/native/client/internal/nativecapture/stream.go @@ -0,0 +1,277 @@ +package nativecapture + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "strconv" + "sync" + "time" +) + +const ( + captureStopTimeout = time.Second + maxPreviewBytes = 48 * 1024 +) + +type audioCaptureState struct { + State string `json:"state"` + Audio bool `json:"audio"` +} + +type CaptureTarget struct { + Kind string `json:"kind"` + SourceID string `json:"sourceId"` + PID uint32 `json:"pid,omitempty"` + CreationTime string `json:"creationTime,omitempty"` + Title string `json:"title"` +} + +type VideoOptions struct { + Target CaptureTarget + AdapterIndex uint32 + EncoderIndex uint32 +} + +type Stream struct { + cancel context.CancelFunc + input io.ReadCloser + key io.WriteCloser + done chan error + + closeOnce sync.Once +} + +func ListSources(parent context.Context, executable string) ([]CaptureTarget, error) { + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + stdout := &boundedBuffer{limit: maxProbeOutputBytes} + command := exec.CommandContext(ctx, executable, "--list") + command.Stdout = stdout + command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes} + hideWindow(command) + if err := command.Run(); err != nil { + return nil, errors.New("native capture source list is unavailable") + } + var targets []CaptureTarget + if err := decodeStrictJSON(stdout.Bytes(), &targets); err != nil || len(targets) > 1024 { + return nil, errors.New("native capture source list is invalid") + } + for _, target := range targets { + if !validCaptureTarget(target) { + return nil, errors.New("native capture target is invalid") + } + } + return targets, nil +} + +func PreviewSource(parent context.Context, executable string, target CaptureTarget) ([]byte, error) { + if !validCaptureTarget(target) { + return nil, errors.New("native preview target is invalid") + } + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + stdout := &boundedBuffer{limit: maxPreviewBytes} + command := exec.CommandContext(ctx, executable, + "--preview", + target.Kind, + target.SourceID, + strconv.FormatUint(uint64(target.PID), 10), + zeroWhenEmpty(target.CreationTime), + ) + command.Stdout = stdout + command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes} + hideWindow(command) + if err := command.Run(); err != nil { + return nil, errors.New("native capture preview is unavailable") + } + preview := stdout.Bytes() + if len(preview) < 54 || preview[0] != 'B' || preview[1] != 'M' { + return nil, errors.New("native capture preview is invalid") + } + return append([]byte(nil), preview...), nil +} + +func StartVideo(parent context.Context, executable string, options VideoOptions) (*Stream, error) { + if !validCaptureTarget(options.Target) { + return nil, errors.New("native video target is invalid") + } + return startStream(parent, executable, []string{ + "--capture-video", + options.Target.Kind, + options.Target.SourceID, + strconv.FormatUint(uint64(options.Target.PID), 10), + zeroWhenEmpty(options.Target.CreationTime), + "--adapter-index", + strconv.FormatUint(uint64(options.AdapterIndex), 10), + "--mft-index", + strconv.FormatUint(uint64(options.EncoderIndex), 10), + "--protocol-v3", + }) +} + +func StartAudio(parent context.Context, executable string, target CaptureTarget) (*Stream, error) { + if !validCaptureTarget(target) { + return nil, errors.New("native audio target is invalid") + } + return startAudioStream(parent, executable, []string{ + "--capture-audio", + target.Kind, + strconv.FormatUint(uint64(target.PID), 10), + zeroWhenEmpty(target.CreationTime), + }) +} + +func StartSystemAudio(parent context.Context, executable string) (*Stream, error) { + return startAudioStream(parent, executable, []string{ + "--capture-audio", "display", "0", "0", + }) +} + +func startAudioStream(parent context.Context, executable string, arguments []string) (*Stream, error) { + if parent == nil { + parent = context.Background() + } + stream, err := startStream(parent, executable, arguments) + if err != nil { + return nil, err + } + ready := make(chan error, 1) + go func() { + frame, readErr := stream.Read() + if readErr != nil { + ready <- errors.New("native audio capture did not become ready") + return + } + ready <- validateAudioReadyFrame(frame) + }() + timer := time.NewTimer(probeTimeout) + defer timer.Stop() + select { + case readyErr := <-ready: + if readyErr == nil { + return stream, nil + } + _ = stream.Close() + return nil, readyErr + case <-timer.C: + _ = stream.Close() + return nil, errors.New("native audio capture timed out during startup") + case <-parent.Done(): + _ = stream.Close() + return nil, parent.Err() + } +} + +func validCaptureTarget(target CaptureTarget) bool { + if !positiveDecimal(target.SourceID) || len(target.Title) == 0 || + len(target.Title) > 4096 { + return false + } + switch target.Kind { + case "window": + return target.PID > 0 && positiveDecimal(target.CreationTime) + case "display": + return target.PID == 0 && target.CreationTime == "" + default: + return false + } +} + +func zeroWhenEmpty(value string) string { + if value == "" { + return "0" + } + return value +} + +func validateAudioReadyFrame(frame Frame) error { + var state audioCaptureState + if frame.Kind != FrameStatus || decodeStrictJSON(frame.Data, &state) != nil || + state.State != "active" || !state.Audio { + return errors.New("native audio capture returned an invalid ready state") + } + return nil +} + +func positiveDecimal(value string) bool { + if value == "" || len(value) > 20 || value[0] == '0' { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + _, err := strconv.ParseUint(value, 10, 64) + return err == nil +} + +func startStream(parent context.Context, executable string, arguments []string) (*Stream, error) { + if parent == nil { + parent = context.Background() + } + if executable == "" { + return nil, errors.New("native capture process is unavailable") + } + ctx, cancel := context.WithCancel(parent) + command := exec.CommandContext(ctx, executable, arguments...) + stdout, err := command.StdoutPipe() + if err != nil { + cancel() + return nil, err + } + stdin, err := command.StdinPipe() + if err != nil { + cancel() + return nil, err + } + command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes} + hideWindow(command) + if err = command.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start native capture process: %w", err) + } + stream := &Stream{ + cancel: cancel, + input: stdout, + key: stdin, + done: make(chan error, 1), + } + go func() { + stream.done <- command.Wait() + close(stream.done) + }() + return stream, nil +} + +func (stream *Stream) Read() (Frame, error) { + return readFrame(stream.input) +} + +func (stream *Stream) RequestKeyFrame() error { + _, err := stream.key.Write([]byte{'K'}) + return err +} + +func (stream *Stream) Done() <-chan error { + return stream.done +} + +func (stream *Stream) Close() error { + stream.closeOnce.Do(func() { + _, _ = stream.key.Write([]byte{'Q'}) + _ = stream.key.Close() + }) + select { + case <-stream.done: + stream.cancel() + return nil + case <-time.After(captureStopTimeout): + stream.cancel() + <-stream.done + return nil + } +} diff --git a/native/client/internal/nativecontrol/session.go b/native/client/internal/nativecontrol/session.go new file mode 100644 index 0000000..8b5d4f3 --- /dev/null +++ b/native/client/internal/nativecontrol/session.go @@ -0,0 +1,519 @@ +package nativecontrol + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "unicode" + + "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativehost" + "github.com/pion/webrtc/v4" +) + +const ( + maxSDPBytes = 48 * 1024 + maxCandidateBytes = 4096 + maxSTUNURLBytes = 512 + maxICEServers = 8 + maxURLsPerServer = 8 + maxEdgeCapacity = 3 +) + +var identityPattern = regexp.MustCompile("^[A-Za-z0-9_-]{8,256}$") + +type Session struct { + captureProcess string + capabilities nativecapture.Capabilities + portMapping bool + ctx context.Context + cancel context.CancelFunc + events chan any + hostEvents chan nativehost.Event + + mu sync.Mutex + host *nativehost.Session + closed bool +} + +func New( + captureProcess string, + capabilities nativecapture.Capabilities, + portMapping bool, +) *Session { + ctx, cancel := context.WithCancel(context.Background()) + session := &Session{ + captureProcess: captureProcess, + capabilities: capabilities, + portMapping: portMapping, + ctx: ctx, + cancel: cancel, + events: make(chan any, 256), + hostEvents: make(chan nativehost.Event, 256), + } + go session.relayEvents() + return session +} + +func (session *Session) Events() <-chan any { + return session.events +} + +func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { + var envelope requestEnvelope + if err := decodeEnvelope(payload, &envelope); err != nil { + return nil, err + } + switch envelope.Type { + case "capture-options": + var request captureOptionsRequest + if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type { + return nil, errors.New("native capture-options request is invalid") + } + return captureOptionsResponse{ + responseEnvelope: response(envelope, "capture-options"), + Adapters: session.capabilities.Adapters, + }, nil + case "list-sources": + var request listSourcesRequest + if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type { + return nil, errors.New("native list-sources request is invalid") + } + targets, err := nativecapture.ListSources(session.ctx, session.captureProcess) + if err != nil { + return nil, err + } + return sourceListResponse{ + responseEnvelope: response(envelope, "source-list"), + Sources: targets, + }, nil + case "source-preview": + var request sourcePreviewRequest + if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type { + return nil, errors.New("native source-preview request is invalid") + } + preview, err := nativecapture.PreviewSource( + session.ctx, + session.captureProcess, + request.Source, + ) + data := "" + if err == nil { + data = base64.StdEncoding.EncodeToString(preview) + } + return sourcePreviewResponse{ + responseEnvelope: response(envelope, "source-preview"), + SourceKey: captureTargetKey(request.Source), + Mime: "image/bmp", + Data: data, + }, nil + case "start-share": + var request startShareRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !identityPattern.MatchString(request.ShareID) || + request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity { + return nil, errors.New("native start-share request is invalid") + } + return session.startShare(envelope, request) + case "prepare-edge": + var request prepareEdgeRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) { + return nil, errors.New("native prepare-edge request is invalid") + } + return session.prepareEdge(envelope, request) + case "prepare-local-edge": + var request prepareLocalEdgeRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) { + return nil, errors.New("native prepare-local-edge request is invalid") + } + return session.prepareLocalEdge(envelope, request) + case "edge-answer": + var request edgeAnswerRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) || + len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes { + return nil, errors.New("native edge-answer request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + if err := host.SetAnswer( + request.ConnectionID, + webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: request.SDP}, + ); err != nil { + return nil, err + } + return response(envelope, "edge-answer-accepted"), nil + case "edge-candidate": + var request edgeCandidateRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) || + !validCandidate(request.Candidate) { + return nil, errors.New("native edge-candidate request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + if err := host.AddCandidate(request.ConnectionID, request.Candidate); err != nil { + return nil, err + } + return response(envelope, "edge-candidate-accepted"), nil + case "close-edge": + var request closeEdgeRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) { + return nil, errors.New("native close-edge request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + host.CloseEdge(request.ConnectionID) + return response(envelope, "edge-closed"), nil + case "stop-share": + var request stopShareRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !identityPattern.MatchString(request.ShareID) { + return nil, errors.New("native stop-share request is invalid") + } + if err := session.stopShare(request.ShareID); err != nil { + return nil, err + } + return response(envelope, "share-stopped"), nil + case "pause-share": + var request pauseShareRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID) { + return nil, errors.New("native pause-share request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + host.SetPaused(request.Paused) + return response(envelope, "share-paused"), nil + default: + return nil, errors.New("native control message is unsupported") + } +} + +func (session *Session) Close() error { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return nil + } + session.closed = true + host := session.host + session.host = nil + session.mu.Unlock() + session.cancel() + if host != nil { + _ = host.Close() + } + return nil +} + +func (session *Session) startShare( + envelope requestEnvelope, + request startShareRequest, +) (any, error) { + session.mu.Lock() + if session.closed || session.host != nil { + session.mu.Unlock() + return nil, errors.New("native share is already active") + } + session.mu.Unlock() + host, err := nativehost.Start(session.ctx, nativehost.Options{ + ShareID: request.ShareID, + CaptureProcess: session.captureProcess, + Video: nativecapture.VideoOptions{ + Target: request.Source, + AdapterIndex: request.AdapterIndex, + EncoderIndex: request.EncoderIndex, + }, + EdgeCapacity: request.EdgeCapacity, + AudioEnabled: request.Audio && session.capabilities.Summary().AudioFor( + request.Source.Kind, + ), + PortMapping: session.portMapping, + Events: session.hostEvents, + }) + if err != nil { + return nil, err + } + session.mu.Lock() + if session.closed || session.host != nil { + session.mu.Unlock() + _ = host.Close() + return nil, errors.New("native share is unavailable") + } + session.host = host + session.mu.Unlock() + go session.watchHost(host) + return shareStartedResponse{ + responseEnvelope: response(envelope, "share-started"), + ShareID: request.ShareID, + Audio: host.HasAudio(), + }, nil +} + +func (session *Session) prepareEdge( + envelope requestEnvelope, + request prepareEdgeRequest, +) (any, error) { + servers, err := pionICEServers(request.ICEServers) + if err != nil { + return nil, err + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + offer, err := host.PrepareEdge(request.ConnectionID, servers) + if err != nil { + return nil, err + } + return edgeOfferResponse{ + responseEnvelope: response(envelope, "edge-offer"), + ShareID: request.ShareID, + ConnectionID: request.ConnectionID, + SDP: offer.SDP, + }, nil +} + +func (session *Session) prepareLocalEdge( + envelope requestEnvelope, + request prepareLocalEdgeRequest, +) (any, error) { + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + offer, err := host.PrepareLocalEdge(request.ConnectionID) + if err != nil { + return nil, err + } + return edgeOfferResponse{ + responseEnvelope: response(envelope, "edge-offer"), + ShareID: request.ShareID, + ConnectionID: request.ConnectionID, + SDP: offer.SDP, + }, nil +} + +func (session *Session) stopShare(shareID string) error { + session.mu.Lock() + host := session.host + if host == nil || host.ShareID() != shareID { + session.mu.Unlock() + return errors.New("native share does not exist") + } + session.host = nil + session.mu.Unlock() + return host.Close() +} + +func (session *Session) current(shareID string) *nativehost.Session { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed || session.host == nil || session.host.ShareID() != shareID { + return nil + } + return session.host +} + +func (session *Session) watchHost(host *nativehost.Session) { + err, open := <-host.Done() + session.mu.Lock() + if session.host == host { + session.host = nil + } + session.mu.Unlock() + if open { + session.emit(shareEndedEvent{ + Version: loopback.ProtocolVersion, + Type: "share-ended", + ShareID: host.ShareID(), + Failed: err != nil, + }) + } +} + +func (session *Session) relayEvents() { + for { + select { + case event := <-session.hostEvents: + session.emit(eventMessage(event)) + case <-session.ctx.Done(): + return + } + } +} + +func (session *Session) emit(event any) { + select { + case session.events <- event: + case <-session.ctx.Done(): + } +} + +func eventMessage(event nativehost.Event) any { + base := eventEnvelope{ + Version: loopback.ProtocolVersion, + Type: event.Type, + ShareID: event.ShareID, + ConnectionID: event.ConnectionID, + } + switch event.Type { + case "edge-candidate": + return edgeCandidateEvent{eventEnvelope: base, Candidate: event.Candidate} + case "edge-state": + return edgeStateEvent{eventEnvelope: base, State: event.State} + case "edge-path": + return edgePathEvent{ + eventEnvelope: base, + LocalType: event.LocalType, + RemoteType: event.RemoteType, + } + case "edge-quality": + quality := event.Quality + return edgeQualityEvent{ + eventEnvelope: base, + SampleTimestampMs: quality.SampleTimestampMs, + SampleWindowMs: quality.SampleWindowMs, + RTPStatsID: quality.RTPStatsID, + TrackIdentifier: quality.TrackIdentifier, + State: quality.State, + Reason: quality.Reason, + IntervalFramesEncoded: quality.IntervalFramesEncoded, + FramesPerSecond: quality.FramesPerSecond, + BitrateKbps: quality.BitrateKbps, + AvailableOutgoingKbps: quality.AvailableOutgoingKbps, + Width: quality.Width, + Height: quality.Height, + } + default: + return captureStateEvent{eventEnvelope: base, State: event.State} + } +} + +func pionICEServers(values []iceServer) ([]webrtc.ICEServer, error) { + if len(values) > maxICEServers { + return nil, errors.New("native ICE server list is too large") + } + result := make([]webrtc.ICEServer, 0, len(values)) + for _, server := range values { + if len(server.URLs) == 0 || len(server.URLs) > maxURLsPerServer { + return nil, errors.New("native ICE server is invalid") + } + urls := make([]string, 0, len(server.URLs)) + for _, value := range server.URLs { + if !validSTUNURL(value) { + return nil, errors.New("native STUN URL is invalid") + } + urls = append(urls, value) + } + result = append(result, webrtc.ICEServer{URLs: urls}) + } + return result, nil +} + +func validSTUNURL(value string) bool { + if len(value) < 6 || len(value) > maxSTUNURLBytes || + !strings.EqualFold(value[:5], "stun:") { + return false + } + authority := value[5:] + if authority == "" || strings.ContainsAny(authority, "/?#@") || + strings.IndexFunc(authority, unicode.IsSpace) >= 0 || + strings.HasSuffix(authority, ":") { + return false + } + parsed, err := url.Parse("http://" + authority) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || + (parsed.Path != "" && parsed.Path != "/") || + parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + port := parsed.Port() + if port == "" { + return true + } + number, err := strconv.Atoi(port) + return err == nil && number > 0 && number <= 65_535 +} + +func validCandidate(candidate *webrtc.ICECandidateInit) bool { + if candidate == nil { + return true + } + return len(candidate.Candidate) <= maxCandidateBytes && + (candidate.SDPMid == nil || len(*candidate.SDPMid) <= 128) && + (candidate.UsernameFragment == nil || len(*candidate.UsernameFragment) <= 256) +} + +func validIdentities(values ...string) bool { + for _, value := range values { + if !identityPattern.MatchString(value) { + return false + } + } + return true +} + +func captureTargetKey(target nativecapture.CaptureTarget) string { + if target.Kind == "window" { + return "window:" + target.SourceID + ":" + + strconv.FormatUint(uint64(target.PID), 10) + ":" + target.CreationTime + } + return "display:" + target.SourceID +} + +func decodeEnvelope(payload []byte, envelope *requestEnvelope) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + if err := decoder.Decode(envelope); err != nil || decoder.Decode(&struct{}{}) != io.EOF || + envelope.Version != loopback.ProtocolVersion || + !identityPattern.MatchString(envelope.ID) || envelope.Type == "" { + return errors.New("native control envelope is invalid") + } + return nil +} + +func decodeStrict(payload []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + return errors.New("native control message is invalid") + } + return nil +} + +func response(request requestEnvelope, responseType string) responseEnvelope { + return responseEnvelope{ + Version: loopback.ProtocolVersion, + ID: request.ID, + Type: responseType, + } +} diff --git a/native/client/internal/nativecontrol/session_test.go b/native/client/internal/nativecontrol/session_test.go new file mode 100644 index 0000000..dc6c50e --- /dev/null +++ b/native/client/internal/nativecontrol/session_test.go @@ -0,0 +1,82 @@ +package nativecontrol + +import ( + "testing" + + "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" +) + +func TestSTUNURLsUseTheCurrentBoundedWire(t *testing.T) { + servers, err := pionICEServers([]iceServer{{URLs: []string{ + "stun:example.test:3478", + "stun:example.test:3479", + }}}) + if err != nil || len(servers) != 1 || len(servers[0].URLs) != 2 { + t.Fatalf("servers = %+v, %v", servers, err) + } + for _, value := range []string{ + "turn:example.com:3478", + "stun:user@example.com:3478", + "stun:example.com:0", + "stun:example.com/path", + } { + if validSTUNURL(value) { + t.Fatalf("invalid STUN URL accepted: %s", value) + } + } +} + +func TestControlMessagesRejectUnknownFieldsAndStaleVersions(t *testing.T) { + session := New("missing-capture-process", nativecapture.Capabilities{}, false) + t.Cleanup(func() { _ = session.Close() }) + for _, payload := range []string{ + `{"version":5,"id":"request_sources","type":"list-sources","extra":true}`, + `{"version":4,"id":"request_sources","type":"list-sources"}`, + `{"version":5,"id":"short","type":"stop-share","shareId":"share_123456"}`, + } { + if _, err := session.Handle(t.Context(), []byte(payload)); err == nil { + t.Fatalf("invalid control message accepted: %s", payload) + } + } +} + +func TestResponseKeepsTheRequestIdentity(t *testing.T) { + value := response(requestEnvelope{ + Version: loopback.ProtocolVersion, + ID: "request_123456", + Type: "stop-share", + }, "share-stopped") + if value.Version != loopback.ProtocolVersion || value.ID != "request_123456" || + value.Type != "share-stopped" { + t.Fatalf("response = %+v", value) + } +} + +func TestPrepareLocalEdgeOwnsOneStrictRequestShape(t *testing.T) { + session := New("missing-capture-process", nativecapture.Capabilities{}, false) + t.Cleanup(func() { _ = session.Close() }) + valid := `{"version":5,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567"}` + if _, err := session.Handle(t.Context(), []byte(valid)); err == nil || err.Error() != "native share does not exist" { + t.Fatalf("valid local-edge request stopped at wrong boundary: %v", err) + } + invalid := `{"version":5,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567","iceServers":[]}` + if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil || err.Error() != "native prepare-local-edge request is invalid" { + t.Fatalf("extended local-edge request was accepted: %v", err) + } +} + +func TestPreviewFailureReturnsAnAdvisoryResponse(t *testing.T) { + session := New("missing-capture-process", nativecapture.Capabilities{}, false) + t.Cleanup(func() { _ = session.Close() }) + value, err := session.Handle(t.Context(), []byte( + `{"version":5,"id":"request_preview","type":"source-preview","source":{"kind":"display","sourceId":"65537","title":"Display 1"}}`, + )) + if err != nil { + t.Fatal(err) + } + response, ok := value.(sourcePreviewResponse) + if !ok || response.Data != "" || response.SourceKey != "display:65537" { + t.Fatalf("preview response = %#v", value) + } +} diff --git a/native/client/internal/nativecontrol/wire.go b/native/client/internal/nativecontrol/wire.go new file mode 100644 index 0000000..1a0a501 --- /dev/null +++ b/native/client/internal/nativecontrol/wire.go @@ -0,0 +1,183 @@ +package nativecontrol + +import ( + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/pion/webrtc/v4" +) + +type requestEnvelope struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` +} + +type responseEnvelope struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` +} + +type captureOptionsRequest requestEnvelope +type listSourcesRequest requestEnvelope + +type sourcePreviewRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + Source nativecapture.CaptureTarget `json:"source"` +} + +type startShareRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Source nativecapture.CaptureTarget `json:"source"` + Audio bool `json:"audio"` + AdapterIndex uint32 `json:"adapterIndex"` + EncoderIndex uint32 `json:"encoderIndex"` + EdgeCapacity int `json:"edgeCapacity"` +} + +type stopShareRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` +} + +type pauseShareRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Paused bool `json:"paused"` +} + +type iceServer struct { + URLs []string `json:"urls"` +} + +type prepareEdgeRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + ICEServers []iceServer `json:"iceServers"` +} + +type prepareLocalEdgeRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` +} + +type edgeAnswerRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + SDP string `json:"sdp"` +} + +type edgeCandidateRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + Candidate *webrtc.ICECandidateInit `json:"candidate"` +} + +type closeEdgeRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` +} + +type sourceListResponse struct { + responseEnvelope + Sources []nativecapture.CaptureTarget `json:"sources"` +} + +type sourcePreviewResponse struct { + responseEnvelope + SourceKey string `json:"sourceKey"` + Mime string `json:"mime"` + Data string `json:"data"` +} + +type captureOptionsResponse struct { + responseEnvelope + Adapters []nativecapture.Adapter `json:"adapters"` +} + +type shareStartedResponse struct { + responseEnvelope + ShareID string `json:"shareId"` + Audio bool `json:"audio"` +} + +type edgeOfferResponse struct { + responseEnvelope + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + SDP string `json:"sdp"` +} + +type eventEnvelope struct { + Version int `json:"version"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId,omitempty"` +} + +type captureStateEvent struct { + eventEnvelope + State string `json:"state"` +} + +type edgeCandidateEvent struct { + eventEnvelope + Candidate *webrtc.ICECandidateInit `json:"candidate"` +} + +type edgeStateEvent struct { + eventEnvelope + State string `json:"state"` +} + +type edgePathEvent struct { + eventEnvelope + LocalType string `json:"localType"` + RemoteType string `json:"remoteType"` +} + +type edgeQualityEvent struct { + eventEnvelope + SampleTimestampMs int64 `json:"sampleTimestampMs"` + SampleWindowMs int64 `json:"sampleWindowMs"` + RTPStatsID string `json:"rtpStatsId"` + TrackIdentifier string `json:"trackIdentifier"` + State string `json:"state"` + Reason *string `json:"reason"` + IntervalFramesEncoded uint64 `json:"intervalFramesEncoded"` + FramesPerSecond float64 `json:"framesPerSecond"` + BitrateKbps float64 `json:"bitrateKbps"` + AvailableOutgoingKbps float64 `json:"availableOutgoingKbps"` + Width uint32 `json:"width"` + Height uint32 `json:"height"` +} + +type shareEndedEvent struct { + Version int `json:"version"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Failed bool `json:"failed"` +} diff --git a/native/client/internal/nativehost/session.go b/native/client/internal/nativehost/session.go new file mode 100644 index 0000000..8c20e97 --- /dev/null +++ b/native/client/internal/nativehost/session.go @@ -0,0 +1,499 @@ +package nativehost + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "sync" + "time" + + "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/pion/webrtc/v4" +) + +const startTimeout = 5 * time.Second +const qualitySamplePeriod = 2 * time.Second + +type CaptureState struct { + State string `json:"state"` + HardwareOnly bool `json:"hardwareOnly"` + AdapterIndex *uint32 `json:"adapterIndex,omitempty"` + AdapterName string `json:"adapterName,omitempty"` + AdapterIdentity string `json:"adapterIdentity,omitempty"` + EncoderIndex *uint32 `json:"encoderIndex,omitempty"` + EncoderName string `json:"encoderName,omitempty"` + EncoderIdentity string `json:"encoderIdentity,omitempty"` + ProfileLevelID string `json:"profileLevelId,omitempty"` + Width uint32 `json:"width,omitempty"` + Height uint32 `json:"height,omitempty"` + FPS uint32 `json:"fps,omitempty"` +} + +type Event struct { + Type string + ShareID string + ConnectionID string + Candidate *webrtc.ICECandidateInit + State string + LocalType string + RemoteType string + Quality *mediaedge.QualitySample +} + +type Options struct { + ShareID string + CaptureProcess string + Video nativecapture.VideoOptions + AudioEnabled bool + EdgeCapacity int + BindAddress string + PortMapping bool + Events chan<- Event +} + +type Session struct { + shareID string + stream *nativecapture.Stream + audioStream *nativecapture.Stream + engine *mediaedge.Engine + source *mediaedge.Source + audioSource *mediaedge.AudioSource + events chan<- Event + + ctx context.Context + cancel context.CancelFunc + done chan error + ready chan error + + mu sync.Mutex + edges map[string]*mediaedge.Edge + paused bool + closed bool +} + +func Start(parent context.Context, options Options) (*Session, error) { + if parent == nil { + parent = context.Background() + } + if options.ShareID == "" || len(options.ShareID) > 256 { + return nil, errors.New("native share identity is invalid") + } + engine, err := mediaedge.NewEngine(mediaedge.EngineOptions{ + BindAddress: options.BindAddress, + IncludeLoopback: true, + PortMapping: options.PortMapping, + }) + if err != nil { + return nil, err + } + stream, err := nativecapture.StartVideo(parent, options.CaptureProcess, options.Video) + if err != nil { + _ = engine.Close() + return nil, err + } + var audioStream *nativecapture.Stream + if options.AudioEnabled { + if options.Video.Target.Kind == "display" { + audioStream, _ = nativecapture.StartSystemAudio(parent, options.CaptureProcess) + } else { + audioStream, _ = nativecapture.StartAudio(parent, options.CaptureProcess, options.Video.Target) + } + } + ctx, cancel := context.WithCancel(parent) + session := &Session{ + shareID: options.ShareID, + stream: stream, + audioStream: audioStream, + engine: engine, + events: options.Events, + ctx: ctx, + cancel: cancel, + done: make(chan error, 1), + ready: make(chan error, 1), + edges: make(map[string]*mediaedge.Edge), + } + source, err := engine.NewSource(options.EdgeCapacity, func() { + _ = stream.RequestKeyFrame() + }) + if err != nil { + cancel() + _ = stream.Close() + if audioStream != nil { + _ = audioStream.Close() + } + _ = engine.Close() + return nil, err + } + session.source = source + if audioStream != nil { + audioSource, audioErr := engine.NewAudioSource( + options.EdgeCapacity, nativeaudio.DefaultBitrate, + ) + if audioErr != nil { + _ = audioStream.Close() + audioStream = nil + session.audioStream = nil + } else { + session.audioSource = audioSource + } + } + go session.run() + select { + case err = <-session.ready: + if err != nil { + _ = session.Close() + return nil, err + } + return session, nil + case <-time.After(startTimeout): + _ = session.Close() + return nil, errors.New("native capture did not start in time") + case <-parent.Done(): + _ = session.Close() + return nil, parent.Err() + } +} + +func (session *Session) ShareID() string { + return session.shareID +} + +func (session *Session) HasAudio() bool { + session.mu.Lock() + defer session.mu.Unlock() + return !session.closed && session.audioSource != nil +} + +func (session *Session) PrepareEdge( + connectionID string, + iceServers []webrtc.ICEServer, +) (webrtc.SessionDescription, error) { + return session.prepareEdge(connectionID, iceServers, false) +} + +func (session *Session) PrepareLocalEdge( + connectionID string, +) (webrtc.SessionDescription, error) { + return session.prepareEdge(connectionID, nil, true) +} + +func (session *Session) prepareEdge( + connectionID string, + iceServers []webrtc.ICEServer, + local bool, +) (webrtc.SessionDescription, error) { + session.mu.Lock() + if session.closed || session.edges[connectionID] != nil { + session.mu.Unlock() + return webrtc.SessionDescription{}, errors.New("native media edge is unavailable") + } + session.mu.Unlock() + created, err := session.engine.NewEdge(session.source, mediaedge.EdgeOptions{ + ConnectionID: connectionID, + ICEServers: iceServers, + Audio: session.audioSource, + Local: local, + Events: mediaedge.EdgeEvents{ + LocalCandidate: func(candidate *webrtc.ICECandidateInit) { + session.emit(Event{ + Type: "edge-candidate", ShareID: session.shareID, + ConnectionID: connectionID, Candidate: candidate, + }) + }, + ConnectionState: func(state webrtc.PeerConnectionState, pair *mediaedge.SelectedPair) { + session.emit(Event{ + Type: "edge-state", ShareID: session.shareID, + ConnectionID: connectionID, State: state.String(), + }) + if pair != nil { + session.emit(Event{ + Type: "edge-path", ShareID: session.shareID, + ConnectionID: connectionID, + LocalType: pair.Local.String(), RemoteType: pair.Remote.String(), + }) + } + }, + }, + }) + if err != nil { + return webrtc.SessionDescription{}, err + } + edge := created + session.mu.Lock() + if session.closed || session.edges[connectionID] != nil { + session.mu.Unlock() + _ = edge.Close() + return webrtc.SessionDescription{}, errors.New("native media edge is unavailable") + } + session.edges[connectionID] = edge + session.mu.Unlock() + offer, err := edge.CreateOffer() + if err != nil { + session.CloseEdge(connectionID) + return webrtc.SessionDescription{}, err + } + return offer, nil +} + +func (session *Session) SetAnswer(connectionID string, answer webrtc.SessionDescription) error { + edge := session.edge(connectionID) + if edge == nil { + return errors.New("native media edge does not exist") + } + return edge.SetAnswer(answer) +} + +func (session *Session) AddCandidate( + connectionID string, + candidate *webrtc.ICECandidateInit, +) error { + edge := session.edge(connectionID) + if edge == nil { + return errors.New("native media edge does not exist") + } + return edge.AddRemoteCandidate(candidate) +} + +func (session *Session) SetPaused(paused bool) { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return + } + session.paused = paused + source := session.source + session.mu.Unlock() + if !paused { + source.RequestRecoveryFrame() + } +} + +func (session *Session) CloseEdge(connectionID string) { + session.mu.Lock() + edge := session.edges[connectionID] + delete(session.edges, connectionID) + session.mu.Unlock() + if edge != nil { + _ = edge.Close() + } +} + +func (session *Session) Done() <-chan error { + return session.done +} + +func (session *Session) Close() error { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + _, _ = <-session.done + return nil + } + session.closed = true + edges := make([]*mediaedge.Edge, 0, len(session.edges)) + for _, edge := range session.edges { + edges = append(edges, edge) + } + session.edges = make(map[string]*mediaedge.Edge) + session.mu.Unlock() + session.cancel() + for _, edge := range edges { + _ = edge.Close() + } + _ = session.stream.Close() + if session.audioStream != nil { + _ = session.audioStream.Close() + } + _ = session.source.Close() + if session.audioSource != nil { + _ = session.audioSource.Close() + } + _ = session.engine.Close() + _, _ = <-session.done + return nil +} + +func (session *Session) edge(connectionID string) *mediaedge.Edge { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + return session.edges[connectionID] +} + +func (session *Session) run() { + videoDone := make(chan error, 1) + go func() { + videoDone <- session.runVideo() + }() + audioDone := make(chan struct{}) + qualityDone := make(chan struct{}) + go func() { + session.runQuality() + close(qualityDone) + }() + if session.audioStream == nil { + close(audioDone) + } else { + go func() { + session.runAudio() + close(audioDone) + }() + } + result := <-videoDone + session.cancel() + if session.audioStream != nil { + _ = session.audioStream.Close() + } + <-audioDone + <-qualityDone + defer func() { + _ = session.source.Close() + if session.audioSource != nil { + _ = session.audioSource.Close() + } + _ = session.engine.Close() + _ = session.stream.Close() + if session.audioStream != nil { + _ = session.audioStream.Close() + } + session.done <- result + close(session.done) + }() + return +} + +func (session *Session) runVideo() error { + ready := false + fail := func(err error) error { + if !ready { + session.ready <- err + } + return err + } + for { + frame, err := session.stream.Read() + if err != nil { + return fail(errors.New("native capture process stopped unexpectedly")) + } + switch frame.Kind { + case nativecapture.FrameStatus: + status, statusErr := decodeCaptureState(frame.Data) + if statusErr != nil { + return fail(statusErr) + } + session.emit(Event{Type: "capture-state", ShareID: session.shareID, State: status.State}) + if status.State == "active" { + session.source.SetFormat(status.Width, status.Height) + } + if !ready { + ready = true + session.ready <- nil + } + case nativecapture.FrameH264: + // Pion writes every binding before returning a per-binding error. Edge + // state owns that failure; one retired edge must not stop healthy siblings. + session.mu.Lock() + paused := session.paused + session.mu.Unlock() + if !paused { + writeErr := session.source.WriteH264( + frame.Data, + frame.Timestamp, + frame.Duration, + ) + if errors.Is(writeErr, mediaedge.ErrInvalidVideoTimestamp) { + return fail(writeErr) + } + } + case nativecapture.FramePCM: + return fail(errors.New("native video process emitted audio")) + default: + return fail(errors.New("native video process emitted an unknown frame")) + } + } +} + +func (session *Session) runQuality() { + ticker := time.NewTicker(qualitySamplePeriod) + defer ticker.Stop() + for { + select { + case now := <-ticker.C: + session.mu.Lock() + edges := make([]*mediaedge.Edge, 0, len(session.edges)) + for _, edge := range session.edges { + edges = append(edges, edge) + } + session.mu.Unlock() + for _, edge := range edges { + if sample, ok := edge.QualitySample(now); ok { + session.emit(Event{ + Type: "edge-quality", ShareID: session.shareID, + ConnectionID: edge.ConnectionID(), Quality: &sample, + }) + } + } + case <-session.ctx.Done(): + return + } + } +} + +func (session *Session) runAudio() { + for { + frame, err := session.audioStream.Read() + if err != nil || session.ctx.Err() != nil { + return + } + if frame.Kind != nativecapture.FramePCM { + return + } + session.mu.Lock() + paused := session.paused + session.mu.Unlock() + if paused { + continue + } + if err = session.audioSource.WritePCM(frame.Data, frame.Duration); err != nil { + return + } + } +} + +func (session *Session) emit(event Event) { + if session.events == nil { + return + } + select { + case session.events <- event: + case <-session.ctx.Done(): + } +} + +func decodeCaptureState(payload []byte) (CaptureState, error) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var state CaptureState + if err := decoder.Decode(&state); err != nil || decoder.Decode(&struct{}{}) != io.EOF || + (state.State != "starting" && state.State != "active") || !state.HardwareOnly { + return CaptureState{}, errors.New("native capture state is invalid") + } + if state.State == "starting" && + (state.AdapterIndex == nil || state.EncoderIndex == nil || + state.AdapterName == "" || state.AdapterIdentity == "" || + state.EncoderName == "" || state.EncoderIdentity == "") { + return CaptureState{}, errors.New("native capture starting state is incomplete") + } + if state.State == "active" && + (state.ProfileLevelID != mediaedge.H264ProfileLevelID || + state.Width == 0 || state.Height == 0 || state.FPS == 0) { + return CaptureState{}, errors.New("native capture active state is incomplete") + } + return state, nil +} diff --git a/native/client/internal/nativehost/session_test.go b/native/client/internal/nativehost/session_test.go new file mode 100644 index 0000000..95f9d9b --- /dev/null +++ b/native/client/internal/nativehost/session_test.go @@ -0,0 +1,30 @@ +package nativehost + +import "testing" + +func TestCaptureStateKeepsStartingAndActiveContractsDistinct(t *testing.T) { + starting, err := decodeCaptureState([]byte( + `{"state":"starting","hardwareOnly":true,"adapterIndex":0,"adapterName":"GPU","adapterIdentity":"0:1","encoderIndex":0,"encoderName":"H264","encoderIdentity":"encoder"}`, + )) + if err != nil || starting.State != "starting" || starting.AdapterIndex == nil { + t.Fatalf("starting = %+v, %v", starting, err) + } + active, err := decodeCaptureState([]byte( + `{"state":"active","hardwareOnly":true,"profileLevelId":"42c01f","width":1280,"height":720,"fps":30}`, + )) + if err != nil || active.State != "active" || active.ProfileLevelID != "42c01f" { + t.Fatalf("active = %+v, %v", active, err) + } +} + +func TestCaptureStateRejectsUnknownOrUnattributedState(t *testing.T) { + for _, payload := range []string{ + `{"state":"active","hardwareOnly":false}`, + `{"state":"ready","hardwareOnly":true}`, + `{"state":"active","hardwareOnly":true,"extra":true}`, + } { + if _, err := decodeCaptureState([]byte(payload)); err == nil { + t.Fatalf("invalid state was accepted: %s", payload) + } + } +} diff --git a/native/client/internal/portmapping/mapping.go b/native/client/internal/portmapping/mapping.go new file mode 100644 index 0000000..78bc867 --- /dev/null +++ b/native/client/internal/portmapping/mapping.go @@ -0,0 +1,122 @@ +package portmapping + +import ( + "context" + "sync" + "time" + + nat "github.com/netbirdio/go-nat" +) + +const ( + // Gateway discovery is link-local and must finish inside the existing + // five-second direct-connection head start. + attemptTimeout = 3 * time.Second + deleteTimeout = 2 * time.Second + // RFC 6886 recommends a two-hour NAT-PMP lease; later edges renew it at + // half-life without adding a background poller. + leaseDuration = 2 * time.Hour +) + +type gateway interface { + AddPortMapping(context.Context, string, int, string, time.Duration) (int, error) + DeletePortMapping(context.Context, string, int) error +} + +var discoverGateway = func(ctx context.Context) (gateway, error) { + return nat.DiscoverGateway(ctx) +} + +// Mapping owns the best-effort lifetime of one UDP mapping. ICE remains fully +// functional when no mapping service is available. +type Mapping struct { + localPort int + cancel context.CancelFunc + ready chan struct{} + + mu sync.Mutex + gateway gateway + attempted bool + mapped bool + deleteRequired bool + renewAfter time.Time + closed bool +} + +func Start(localPort int) *Mapping { + ctx, cancel := context.WithCancel(context.Background()) + mapping := &Mapping{ + localPort: localPort, + cancel: cancel, + ready: make(chan struct{}), + } + go mapping.discover(ctx) + return mapping +} + +func (mapping *Mapping) Prepare() { + <-mapping.ready + mapping.mu.Lock() + defer mapping.mu.Unlock() + if mapping.closed || mapping.gateway == nil || + (mapping.attempted && !mapping.mapped) || + (mapping.mapped && time.Now().Before(mapping.renewAfter)) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), attemptTimeout) + defer cancel() + mapping.mapPortLocked(ctx) +} + +func (mapping *Mapping) Close() { + mapping.cancel() + <-mapping.ready + mapping.mu.Lock() + if mapping.closed { + mapping.mu.Unlock() + return + } + mapping.closed = true + gateway := mapping.gateway + deleteRequired := mapping.deleteRequired + mapping.mapped = false + mapping.deleteRequired = false + mapping.mu.Unlock() + if gateway == nil || !deleteRequired { + return + } + ctx, cancel := context.WithTimeout(context.Background(), deleteTimeout) + defer cancel() + _ = gateway.DeletePortMapping(ctx, "udp", mapping.localPort) +} + +func (mapping *Mapping) discover(parent context.Context) { + defer close(mapping.ready) + ctx, cancel := context.WithTimeout(parent, attemptTimeout) + defer cancel() + gateway, err := discoverGateway(ctx) + if err != nil { + return + } + mapping.mu.Lock() + defer mapping.mu.Unlock() + if mapping.closed || parent.Err() != nil { + return + } + mapping.gateway = gateway + mapping.mapPortLocked(ctx) +} + +func (mapping *Mapping) mapPortLocked(ctx context.Context) { + mapping.attempted = true + externalPort, err := mapping.gateway.AddPortMapping( + ctx, "udp", mapping.localPort, "Screener", leaseDuration, + ) + if err != nil || externalPort < 1 || externalPort > 65535 { + mapping.mapped = false + return + } + mapping.mapped = true + mapping.deleteRequired = true + mapping.renewAfter = time.Now().Add(leaseDuration / 2) +} diff --git a/native/client/internal/portmapping/mapping_test.go b/native/client/internal/portmapping/mapping_test.go new file mode 100644 index 0000000..cd46e20 --- /dev/null +++ b/native/client/internal/portmapping/mapping_test.go @@ -0,0 +1,145 @@ +package portmapping + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakeGateway struct { + mu sync.Mutex + added []int + deleted []int + addErr error +} + +func (gateway *fakeGateway) AddPortMapping( + _ context.Context, + _ string, + port int, + _ string, + _ time.Duration, +) (int, error) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + gateway.added = append(gateway.added, port) + if gateway.addErr != nil { + return 0, gateway.addErr + } + return port + 1, nil +} +func (gateway *fakeGateway) DeletePortMapping( + _ context.Context, + _ string, + port int, +) error { + gateway.mu.Lock() + defer gateway.mu.Unlock() + gateway.deleted = append(gateway.deleted, port) + return nil +} + +func TestMappingCreatesOnceAndReleases(t *testing.T) { + original := discoverGateway + t.Cleanup(func() { discoverGateway = original }) + fake := &fakeGateway{} + discoverGateway = func(context.Context) (gateway, error) { + return fake, nil + } + + mapping := Start(43210) + mapping.Prepare() + mapping.Prepare() + mapping.Close() + mapping.Close() + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.added) != 1 || fake.added[0] != 43210 { + t.Fatalf("added ports = %v, want [43210]", fake.added) + } + if len(fake.deleted) != 1 || fake.deleted[0] != 43210 { + t.Fatalf("deleted ports = %v, want [43210]", fake.deleted) + } +} + +func TestMappingAbsenceLeavesICEAvailable(t *testing.T) { + original := discoverGateway + t.Cleanup(func() { discoverGateway = original }) + discoverGateway = func(context.Context) (gateway, error) { + return nil, errors.New("no mapping service") + } + + mapping := Start(43210) + mapping.Prepare() + mapping.Close() +} + +func TestMappingFailureIsNotRetriedForEveryEdge(t *testing.T) { + original := discoverGateway + t.Cleanup(func() { discoverGateway = original }) + fake := &fakeGateway{addErr: errors.New("mapping rejected")} + discoverGateway = func(context.Context) (gateway, error) { + return fake, nil + } + + mapping := Start(43210) + mapping.Prepare() + mapping.Prepare() + mapping.Close() + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.added) != 1 || len(fake.deleted) != 0 { + t.Fatalf("mapping calls = add %v delete %v", fake.added, fake.deleted) + } +} + +func TestRenewalFailureKeepsTheOriginalMappingOwnedUntilClose(t *testing.T) { + original := discoverGateway + t.Cleanup(func() { discoverGateway = original }) + fake := &fakeGateway{} + discoverGateway = func(context.Context) (gateway, error) { + return fake, nil + } + + mapping := Start(43210) + mapping.Prepare() + mapping.mu.Lock() + mapping.renewAfter = time.Now().Add(-time.Second) + mapping.mu.Unlock() + fake.mu.Lock() + fake.addErr = errors.New("renewal rejected") + fake.mu.Unlock() + mapping.Prepare() + mapping.Close() + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.added) != 2 || len(fake.deleted) != 1 || fake.deleted[0] != 43210 { + t.Fatalf("mapping calls = add %v delete %v", fake.added, fake.deleted) + } +} + +func TestMappingDiscoveryStopsWithTheShare(t *testing.T) { + original := discoverGateway + t.Cleanup(func() { discoverGateway = original }) + discoverGateway = func(ctx context.Context) (gateway, error) { + <-ctx.Done() + return nil, ctx.Err() + } + + mapping := Start(43210) + done := make(chan struct{}) + go func() { + mapping.Close() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("mapping close did not cancel discovery") + } +} diff --git a/native/client/internal/publictunnel/process.go b/native/client/internal/publictunnel/process.go new file mode 100644 index 0000000..ab330d8 --- /dev/null +++ b/native/client/internal/publictunnel/process.go @@ -0,0 +1,251 @@ +package publictunnel + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const ( + startupTimeout = 30 * time.Second + shutdownTimeout = 5 * time.Second + maxLogLineBytes = 64 * 1024 +) + +var quickOriginPattern = regexp.MustCompile( + `https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.trycloudflare\.com`, +) + +type Process struct { + origin string + cancel context.CancelFunc + done chan struct{} + once sync.Once + + mu sync.Mutex + err error +} + +func PackagedExecutable() string { + executable, err := os.Executable() + if err != nil { + return "" + } + name := "cloudflared" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(filepath.Dir(executable), "runtime", "tunnel", name) +} + +func Start( + parent context.Context, + executable string, + localOrigin string, +) (*Process, error) { + if parent == nil { + parent = context.Background() + } + if err := validateExecutable(executable); err != nil { + return nil, err + } + if err := validateLocalOrigin(localOrigin); err != nil { + return nil, err + } + configPath, err := writeTemporaryConfig() + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(parent) + child := exec.CommandContext( + ctx, + executable, + "tunnel", + "--config", configPath, + "--no-autoupdate", + "--loglevel", "info", + "--output", "json", + "--url", localOrigin, + ) + hideWindow(child) + stdout, err := child.StdoutPipe() + if err != nil { + cancel() + _ = os.Remove(configPath) + return nil, errors.New("public tunnel output is unavailable") + } + stderr, err := child.StderrPipe() + if err != nil { + cancel() + _ = os.Remove(configPath) + return nil, errors.New("public tunnel output is unavailable") + } + child.WaitDelay = shutdownTimeout + if err = child.Start(); err != nil { + cancel() + _ = os.Remove(configPath) + return nil, errors.New("public tunnel could not start") + } + process := &Process{cancel: cancel, done: make(chan struct{})} + discovered := make(chan string, 1) + connected := make(chan struct{}, 1) + var discoverOnce sync.Once + var connectOnce sync.Once + readOutput := func(reader io.Reader) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 4096), maxLogLineBytes) + for scanner.Scan() { + line := scanner.Text() + if origin, ok := originFromLogLine(line); ok { + discoverOnce.Do(func() { discovered <- origin }) + } + if connectionRegistered(line) { + connectOnce.Do(func() { connected <- struct{}{} }) + } + } + } + go readOutput(stdout) + go readOutput(stderr) + go func() { + waitErr := child.Wait() + _ = os.Remove(configPath) + if ctx.Err() != nil { + waitErr = nil + } + process.mu.Lock() + process.err = waitErr + process.mu.Unlock() + close(process.done) + }() + + timer := time.NewTimer(startupTimeout) + defer timer.Stop() + registered := false + for process.origin == "" || !registered { + select { + case process.origin = <-discovered: + case <-connected: + registered = true + case <-process.done: + cancel() + return nil, errors.New("public tunnel exited before it became available") + case <-timer.C: + _ = process.Close() + return nil, errors.New("public tunnel startup timed out") + case <-parent.Done(): + _ = process.Close() + return nil, parent.Err() + } + } + return process, nil +} + +func writeTemporaryConfig() (string, error) { + file, err := os.CreateTemp("", "screener-cloudflared-*.yml") + if err != nil { + return "", errors.New("public tunnel configuration is unavailable") + } + path := file.Name() + if _, err = file.WriteString("metrics: 127.0.0.1:0\n"); err != nil { + _ = file.Close() + _ = os.Remove(path) + return "", errors.New("public tunnel configuration is unavailable") + } + if err = file.Close(); err != nil { + _ = os.Remove(path) + return "", errors.New("public tunnel configuration is unavailable") + } + return path, nil +} + +func (process *Process) Origin() string { + return process.origin +} + +func (process *Process) Done() <-chan struct{} { + return process.done +} + +func (process *Process) Err() error { + select { + case <-process.done: + process.mu.Lock() + defer process.mu.Unlock() + return process.err + default: + return nil + } +} + +func (process *Process) Close() error { + process.once.Do(process.cancel) + <-process.done + return process.Err() +} + +func originFromLogLine(line string) (string, bool) { + line = logMessage(line) + origin := quickOriginPattern.FindString(strings.ToLower(line)) + if origin == "" { + return "", false + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme != "https" || parsed.Path != "" || + parsed.RawQuery != "" || parsed.Fragment != "" { + return "", false + } + return parsed.Scheme + "://" + parsed.Host, true +} + +func connectionRegistered(line string) bool { + return strings.Contains(logMessage(line), "Registered tunnel connection") +} + +func logMessage(line string) string { + var entry struct { + Message string `json:"message"` + } + if json.Unmarshal([]byte(line), &entry) == nil && entry.Message != "" { + return entry.Message + } + return line +} + +func validateExecutable(path string) error { + path = strings.TrimSpace(path) + metadata, err := os.Stat(path) + if err != nil || !metadata.Mode().IsRegular() { + return errors.New("packaged public tunnel is unavailable") + } + return nil +} + +func validateLocalOrigin(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "http" || parsed.User != nil || + parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("public tunnel Local origin is invalid") + } + ip := net.ParseIP(parsed.Hostname()) + if ip == nil || !ip.IsLoopback() || parsed.Port() == "" { + return errors.New("public tunnel Local origin is invalid") + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65_535 { + return errors.New("public tunnel Local origin is invalid") + } + return nil +} diff --git a/native/client/internal/publictunnel/process_other.go b/native/client/internal/publictunnel/process_other.go new file mode 100644 index 0000000..32c3ffb --- /dev/null +++ b/native/client/internal/publictunnel/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package publictunnel + +import "os/exec" + +func hideWindow(_ *exec.Cmd) {} diff --git a/native/client/internal/publictunnel/process_test.go b/native/client/internal/publictunnel/process_test.go new file mode 100644 index 0000000..5f1113e --- /dev/null +++ b/native/client/internal/publictunnel/process_test.go @@ -0,0 +1,71 @@ +package publictunnel + +import ( + "os" + "testing" +) + +func TestOriginFromCloudflaredLog(t *testing.T) { + for _, line := range []string{ + `{"level":"info","message":"| https://small-bright-room.trycloudflare.com |"}`, + "Visit HTTPS://SMALL-BRIGHT-ROOM.TRYCLOUDFLARE.COM now", + } { + origin, ok := originFromLogLine(line) + if !ok || origin != "https://small-bright-room.trycloudflare.com" { + t.Fatalf("originFromLogLine(%q) = %q, %v", line, origin, ok) + } + } + for _, line := range []string{ + "https://example.com", + "https://trycloudflare.com", + "https://bad_.trycloudflare.com", + } { + if origin, ok := originFromLogLine(line); ok { + t.Fatalf("unexpected public origin %q from %q", origin, line) + } + } +} + +func TestRegisteredConnectionFromCloudflaredLog(t *testing.T) { + if !connectionRegistered( + `{"level":"info","message":"Registered tunnel connection","protocol":"quic"}`, + ) { + t.Fatal("registered tunnel connection was not recognized") + } + if connectionRegistered(`{"message":"Requesting new quick Tunnel"}`) { + t.Fatal("pre-connection log was accepted as ready") + } +} + +func TestLocalOriginMustBeLoopbackHTTP(t *testing.T) { + for _, value := range []string{ + "http://127.0.0.1:8787", + "http://[::1]:8787", + } { + if err := validateLocalOrigin(value); err != nil { + t.Fatalf("valid Local origin %q: %v", value, err) + } + } + for _, value := range []string{ + "https://127.0.0.1:8787", + "http://192.168.1.2:8787", + "http://127.0.0.1:8787/path", + "http://127.0.0.1", + } { + if err := validateLocalOrigin(value); err == nil { + t.Fatalf("invalid Local origin accepted: %q", value) + } + } +} + +func TestTemporaryConfigIsMinimal(t *testing.T) { + path, err := writeTemporaryConfig() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(path) }) + payload, err := os.ReadFile(path) + if err != nil || string(payload) != "metrics: 127.0.0.1:0\n" { + t.Fatalf("temporary config = %q, %v", payload, err) + } +} diff --git a/native/client/internal/publictunnel/process_windows.go b/native/client/internal/publictunnel/process_windows.go new file mode 100644 index 0000000..cc12544 --- /dev/null +++ b/native/client/internal/publictunnel/process_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package publictunnel + +import ( + "os/exec" + "syscall" +) + +func hideWindow(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/native/client/internal/supervisor/process.go b/native/client/internal/supervisor/process.go new file mode 100644 index 0000000..ec3fe8b --- /dev/null +++ b/native/client/internal/supervisor/process.go @@ -0,0 +1,139 @@ +package supervisor + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "sync" + "time" +) + +const ( + readinessTimeout = 15 * time.Second + pollInterval = 100 * time.Millisecond + requestTimeout = 500 * time.Millisecond + shutdownTimeout = 5 * time.Second +) + +type Command struct { + Path string + Args []string + Directory string + Environment []string + Stdout io.Writer + Stderr io.Writer + HealthURL string +} + +type Process struct { + cancel context.CancelFunc + done chan struct{} + once sync.Once + mu sync.Mutex + err error +} + +func Start(parent context.Context, command Command) (*Process, error) { + if parent == nil { + parent = context.Background() + } + if command.Path == "" || command.HealthURL == "" { + return nil, errors.New("supervised process command is incomplete") + } + ctx, cancel := context.WithCancel(parent) + child := exec.CommandContext(ctx, command.Path, command.Args...) + child.Dir = command.Directory + child.Env = command.Environment + child.Stdout = command.Stdout + child.Stderr = command.Stderr + stdin, err := child.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("open supervised process stdin: %w", err) + } + child.Cancel = stdin.Close + child.WaitDelay = shutdownTimeout + if err = child.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start supervised process: %w", err) + } + process := &Process{cancel: cancel, done: make(chan struct{})} + go func() { + waitErr := child.Wait() + if ctx.Err() != nil && (waitErr == nil || errors.Is(waitErr, context.Canceled)) { + waitErr = nil + } + process.mu.Lock() + process.err = waitErr + process.mu.Unlock() + close(process.done) + }() + if err = waitUntilReady(ctx, process, command.HealthURL); err != nil { + process.cancel() + <-process.done + return nil, err + } + return process, nil +} + +func (process *Process) Done() <-chan struct{} { + return process.done +} + +func (process *Process) Err() error { + select { + case <-process.done: + process.mu.Lock() + defer process.mu.Unlock() + return process.err + default: + return nil + } +} + +func (process *Process) Close() error { + process.once.Do(process.cancel) + <-process.done + return process.Err() +} + +func waitUntilReady(ctx context.Context, process *Process, healthURL string) error { + deadline := time.NewTimer(readinessTimeout) + defer deadline.Stop() + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + transport := &http.Transport{Proxy: nil} + defer transport.CloseIdleConnections() + client := &http.Client{ + Timeout: requestTimeout, + Transport: transport, + } + for { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return errors.New("supervised process health URL is invalid") + } + response, requestErr := client.Do(request) + if requestErr == nil { + _ = response.Body.Close() + if response.StatusCode == http.StatusOK { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-process.done: + if err = process.Err(); err != nil { + return fmt.Errorf("supervised process exited before readiness: %w", err) + } + return errors.New("supervised process exited before readiness") + case <-deadline.C: + return errors.New("supervised process readiness timed out") + case <-ticker.C: + } + } +} diff --git a/native/client/internal/supervisor/process_test.go b/native/client/internal/supervisor/process_test.go new file mode 100644 index 0000000..a6289dd --- /dev/null +++ b/native/client/internal/supervisor/process_test.go @@ -0,0 +1,85 @@ +package supervisor + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "testing" +) + +func TestStartWaitsForReadinessAndClosesThroughStdin(t *testing.T) { + port := freePort(t) + process, err := Start(context.Background(), helperCommand(port, "serve")) + if err != nil { + t.Fatal(err) + } + if err = process.Close(); err != nil { + t.Fatal(err) + } + select { + case <-process.Done(): + default: + t.Fatal("supervised process did not finish") + } +} + +func TestStartReportsAChildThatExitsBeforeReadiness(t *testing.T) { + if _, err := Start(context.Background(), helperCommand(freePort(t), "exit")); err == nil { + t.Fatal("early child exit was accepted") + } +} + +func TestSupervisorChild(t *testing.T) { + if os.Getenv("SCREENER_SUPERVISOR_TEST_CHILD") != "true" { + return + } + if os.Getenv("SCREENER_SUPERVISOR_TEST_MODE") == "exit" { + os.Exit(3) + } + port, err := strconv.Atoi(os.Getenv("SCREENER_SUPERVISOR_TEST_PORT")) + if err != nil { + os.Exit(4) + } + server := &http.Server{Handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/healthz" { + http.NotFound(response, request) + return + } + response.WriteHeader(http.StatusOK) + })} + listener, err := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + os.Exit(5) + } + go func() { _ = server.Serve(listener) }() + _, _ = io.Copy(io.Discard, os.Stdin) + _ = server.Close() + os.Exit(0) +} + +func helperCommand(port int, mode string) Command { + return Command{ + Path: os.Args[0], + Args: []string{"-test.run=TestSupervisorChild"}, + Environment: append(os.Environ(), + "SCREENER_SUPERVISOR_TEST_CHILD=true", + "SCREENER_SUPERVISOR_TEST_MODE="+mode, + fmt.Sprintf("SCREENER_SUPERVISOR_TEST_PORT=%d", port), + ), + HealthURL: fmt.Sprintf("http://127.0.0.1:%d/healthz", port), + } +} + +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} diff --git a/native/client/platform/darwin/capture/README.md b/native/client/platform/darwin/capture/README.md new file mode 100644 index 0000000..e86dbc2 --- /dev/null +++ b/native/client/platform/darwin/capture/README.md @@ -0,0 +1,28 @@ +# macOS Capture Process + +This process keeps the native Client media boundary used on Windows while +replacing only the platform implementation. It lists current ScreenCaptureKit +displays and windows, captures one exact source at 1280x720 and 30 fps, requires a +VideoToolbox hardware H.264 encoder, and writes Annex-B Baseline H.264 through +the bounded `SMED` protocol. It advertises no native process-audio capability. + +Build on Apple Silicon macOS 13 or newer: + +```sh +sh native/client/platform/darwin/capture/build.sh /outside/repository/build +/outside/repository/build/screener-client-capture --self-test +/outside/repository/build/screener-client-capture --probe +``` + +`--self-test` encodes one in-memory 420v frame through the same hardware-only +VideoToolbox path and requires a constrained-baseline SPS/PPS/IDR. It needs no +Screen Recording permission. Real source selection, capture, and recovery still +require a physical macOS gate. + +The first source-list or capture request is subject to the normal macOS Screen +Recording permission. The implementation follows Apple's ScreenCaptureKit and +VideoToolbox contracts and does not bundle another media runtime. + +- +- +- diff --git a/native/client/platform/darwin/capture/build.sh b/native/client/platform/darwin/capture/build.sh new file mode 100644 index 0000000..d8aa56e --- /dev/null +++ b/native/client/platform/darwin/capture/build.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: build.sh " >&2 + exit 2 +fi + +output=$1 +mkdir -p "$output" +xcrun swiftc \ + -O \ + -parse-as-library \ + -swift-version 5 \ + -target arm64-apple-macos13.0 \ + -framework CoreMedia \ + -framework CoreVideo \ + -framework ScreenCaptureKit \ + -framework VideoToolbox \ + "$(dirname "$0")/main.swift" \ + -o "$output/screener-client-capture" diff --git a/native/client/platform/darwin/capture/main.swift b/native/client/platform/darwin/capture/main.swift new file mode 100644 index 0000000..e23b68e --- /dev/null +++ b/native/client/platform/darwin/capture/main.swift @@ -0,0 +1,814 @@ +import CoreMedia +import CoreVideo +import Darwin +import Foundation +import ScreenCaptureKit +import VideoToolbox + +private let captureProtocol = 3 +private let width = 1280 +private let height = 720 +private let frameRate: Int32 = 30 +private let bitrate = 3_000_000 +private let frameDuration100ns: UInt64 = 10_000_000 / UInt64(frameRate) +private let frameInterval = CMTime(value: 1, timescale: frameRate) +private let timestampStep = CMTime(value: 1, timescale: 90_000) +private let maxPayloadBytes = 1_048_576 + +private struct EncoderProbe: Codable { + let index: UInt32 + let name: String + let identity: String +} + +private struct AdapterProbe: Codable { + let index: UInt32 + let name: String + let identity: String + let hardwareH264: [EncoderProbe] +} + +private struct Probe: Codable { + let `protocol`: Int + let platform: String + let platformBuild: String + let videoCapture: Bool + let processAudio: Bool + let systemAudio: Bool + let adapters: [AdapterProbe] +} + +private struct CaptureTarget: Codable { + let kind: String + let sourceId: String + let pid: UInt32? + let creationTime: String? + let title: String + + enum CodingKeys: String, CodingKey { + case kind + case sourceId + case pid + case creationTime + case title + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(sourceId, forKey: .sourceId) + try container.encodeIfPresent(pid, forKey: .pid) + try container.encodeIfPresent(creationTime, forKey: .creationTime) + try container.encode(title, forKey: .title) + } +} + +private struct StartingStatus: Codable { + let state = "starting" + let hardwareOnly = true + let adapterIndex: UInt32 = 0 + let adapterName = "Apple VideoToolbox" + let adapterIdentity = "apple-videotoolbox" + let encoderIndex: UInt32 = 0 + let encoderName = "VideoToolbox H.264" + let encoderIdentity = "com.apple.videotoolbox.h264" +} + +private struct ActiveStatus: Codable { + let state = "active" + let hardwareOnly = true + let profileLevelId = "42c01f" + let width = 1280 + let height = 720 + let fps = 30 +} + +private struct CaptureFailure: Error, CustomStringConvertible { + let description: String +} + +private func require(_ status: OSStatus, _ stage: String) throws { + if status != noErr { + throw CaptureFailure(description: "\(stage) failed (\(status))") + } +} + +private func later(_ first: CMTime, _ second: CMTime) -> CMTime { + CMTimeCompare(first, second) >= 0 ? first : second +} + +private func writeJSON(_ value: T) throws { + let data = try JSONEncoder().encode(value) + try FileHandle.standardOutput.write(contentsOf: data) +} + +private func processCreationTime(_ pid: pid_t) -> String? { + guard pid > 0 else { + return nil + } + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.stride) + let read = withUnsafeMutablePointer(to: &info) { pointer in + proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, pointer, size) + } + guard read == size, info.pbi_start_tvsec > 0 else { + return nil + } + let seconds = UInt64(info.pbi_start_tvsec) + let microseconds = UInt64(info.pbi_start_tvusec) + guard seconds <= (UInt64.max - microseconds) / 1_000_000 else { + return nil + } + return String(seconds * 1_000_000 + microseconds) +} + +private func target(for window: SCWindow) -> CaptureTarget? { + guard let application = window.owningApplication, + application.processID > 0, + let created = processCreationTime(application.processID), + let title = window.title?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { + return nil + } + return CaptureTarget( + kind: "window", + sourceId: String(window.windowID), + pid: UInt32(application.processID), + creationTime: created, + title: title + ) +} + +private func target(for display: SCDisplay) -> CaptureTarget { + CaptureTarget( + kind: "display", + sourceId: String(display.displayID), + pid: nil, + creationTime: nil, + title: "Display \(display.displayID)" + ) +} + +private func shareableContent() async throws -> SCShareableContent { + try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) +} + +private func shareableWindows(_ content: SCShareableContent) -> [(SCWindow, CaptureTarget)] { + return content.windows.compactMap { window in + guard let value = target(for: window) else { return nil } + return (window, value) + } +} + +private func hardwareH264Available() -> Bool { + do { + let encoder = try HardwareEncoder( + writer: ProtocolWriter(), + done: StopSignal() + ) + encoder.close() + return true + } catch { + return false + } +} + +private func probe() throws { + let encoders = hardwareH264Available() + ? [EncoderProbe( + index: 0, + name: "VideoToolbox H.264", + identity: "com.apple.videotoolbox.h264" + )] + : [] + let adapters = encoders.isEmpty + ? [] + : [AdapterProbe( + index: 0, + name: "Apple VideoToolbox", + identity: "apple-videotoolbox", + hardwareH264: encoders + )] + try writeJSON(Probe( + protocol: captureProtocol, + platform: "darwin", + platformBuild: ProcessInfo.processInfo.operatingSystemVersionString, + videoCapture: true, + processAudio: false, + systemAudio: false, + adapters: adapters + )) +} + +private func listSources() async throws { + let content = try await shareableContent() + let displays = content.displays.map { target(for: $0) }.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + let windows = shareableWindows(content).map { $0.1 }.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + try writeJSON(displays + windows) +} + +private final class StopSignal { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var stopped = false + private var failure: Error? + + func signal(_ error: Error? = nil) { + lock.lock() + guard !stopped else { + lock.unlock() + return + } + stopped = true + failure = error + lock.unlock() + semaphore.signal() + } + + func wait() throws { + semaphore.wait() + lock.lock() + let error = failure + lock.unlock() + if let error { throw error } + } +} + +private func appendBigEndian(_ value: T, to data: inout Data) { + var encoded = value.bigEndian + Swift.withUnsafeBytes(of: &encoded) { bytes in + data.append(contentsOf: bytes) + } +} + +private final class ProtocolWriter { + private let lock = NSLock() + private let output: (Data) throws -> Void + + init(output: @escaping (Data) throws -> Void = { data in + try FileHandle.standardOutput.write(contentsOf: data) + }) { + self.output = output + } + + func writeStatus(_ status: T) throws { + try write(kind: 3, flags: 0, timestamp: 0, duration: 0, + payload: JSONEncoder().encode(status)) + } + + func writeH264(_ payload: Data, keyFrame: Bool, timestamp: UInt64) throws { + try write( + kind: 2, + flags: keyFrame ? 1 : 0, + timestamp: timestamp, + duration: frameDuration100ns, + payload: payload + ) + } + + private func write( + kind: UInt8, + flags: UInt8, + timestamp: UInt64, + duration: UInt64, + payload: Data + ) throws { + guard !payload.isEmpty, payload.count <= maxPayloadBytes else { + throw CaptureFailure(description: "capture payload is outside its bound") + } + var envelope = Data([0x53, 0x4d, 0x45, 0x44, 1, kind, flags, 0]) + appendBigEndian(timestamp, to: &envelope) + appendBigEndian(duration, to: &envelope) + appendBigEndian(UInt32(payload.count), to: &envelope) + envelope.append(payload) + lock.lock() + defer { lock.unlock() } + try output(envelope) + } +} + +private func isKeyFrame(_ sample: CMSampleBuffer) -> Bool { + guard let values = CMSampleBufferGetSampleAttachmentsArray( + sample, + createIfNecessary: false + ) as? [[CFString: Any]], let first = values.first else { + return true + } + return (first[kCMSampleAttachmentKey_NotSync] as? Bool) != true +} + +private func h264ParameterSets(_ format: CMFormatDescription) throws -> [Data] { + var count = 0 + var headerLength: Int32 = 0 + try require( + CMVideoFormatDescriptionGetH264ParameterSetAtIndex( + format, + parameterSetIndex: 0, + parameterSetPointerOut: nil, + parameterSetSizeOut: nil, + parameterSetCountOut: &count, + nalUnitHeaderLengthOut: &headerLength + ), + "h264-parameter-count" + ) + guard count >= 2, headerLength == 4 else { + throw CaptureFailure(description: "unexpected H.264 parameter layout") + } + var result: [Data] = [] + for index in 0..? + var size = 0 + try require( + CMVideoFormatDescriptionGetH264ParameterSetAtIndex( + format, + parameterSetIndex: index, + parameterSetPointerOut: &pointer, + parameterSetSizeOut: &size, + parameterSetCountOut: nil, + nalUnitHeaderLengthOut: nil + ), + "h264-parameter-set" + ) + guard let pointer, size > 0 else { + throw CaptureFailure(description: "empty H.264 parameter set") + } + result.append(Data(bytes: pointer, count: size)) + } + let sps = result[0] + guard sps.count >= 4, sps[1] == 0x42, sps[2] == 0xc0, sps[3] == 0x1f else { + throw CaptureFailure(description: "H.264 profile differs from 42c01f") + } + return result +} + +private func annexB(_ sample: CMSampleBuffer, keyFrame: Bool) throws -> Data { + guard let block = CMSampleBufferGetDataBuffer(sample), + let format = CMSampleBufferGetFormatDescription(sample) else { + throw CaptureFailure(description: "encoded H.264 sample is incomplete") + } + let totalLength = CMBlockBufferGetDataLength(block) + guard totalLength > 0 else { + throw CaptureFailure(description: "encoded H.264 sample is empty") + } + var encoded = Data(count: totalLength) + let copyStatus = encoded.withUnsafeMutableBytes { bytes in + CMBlockBufferCopyDataBytes( + block, + atOffset: 0, + dataLength: totalLength, + destination: bytes.baseAddress! + ) + } + try require(copyStatus, "h264-block-buffer") + let bytes = [UInt8](encoded) + var output = Data() + let startCode: [UInt8] = [0, 0, 0, 1] + if keyFrame { + for parameter in try h264ParameterSets(format) { + output.append(contentsOf: startCode) + output.append(parameter) + } + } + var offset = 0 + while offset + 4 <= totalLength { + let size = Int(bytes[offset]) << 24 | + Int(bytes[offset + 1]) << 16 | + Int(bytes[offset + 2]) << 8 | + Int(bytes[offset + 3]) + offset += 4 + guard size > 0, offset + size <= totalLength else { + throw CaptureFailure(description: "invalid H.264 NAL length") + } + output.append(contentsOf: startCode) + output.append(contentsOf: bytes[offset..<(offset + size)]) + offset += size + } + guard offset == totalLength else { + throw CaptureFailure(description: "trailing H.264 sample bytes") + } + return output +} + +private final class HardwareEncoder { + private let writer: ProtocolWriter + private let done: StopSignal + private let lock = NSLock() + private var session: VTCompressionSession? + private var forceKeyFrame = true + private var active = false + + init(writer: ProtocolWriter, done: StopSignal) throws { + self.writer = writer + self.done = done + let specification = [ + kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder as String: true, + ] as CFDictionary + var created: VTCompressionSession? + try require( + VTCompressionSessionCreate( + allocator: nil, + width: Int32(width), + height: Int32(height), + codecType: kCMVideoCodecType_H264, + encoderSpecification: specification, + imageBufferAttributes: nil, + compressedDataAllocator: nil, + outputCallback: nil, + refcon: nil, + compressionSessionOut: &created + ), + "videotoolbox-create" + ) + guard let created else { + throw CaptureFailure(description: "VideoToolbox returned no encoder") + } + session = created + try set(kVTCompressionPropertyKey_RealTime, kCFBooleanTrue) + try set(kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse) + try set(kVTCompressionPropertyKey_ProfileLevel, + kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel) + try set(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: bitrate)) + try set(kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: frameRate)) + try set(kVTCompressionPropertyKey_MaxKeyFrameInterval, NSNumber(value: 60)) + try require(VTCompressionSessionPrepareToEncodeFrames(created), + "videotoolbox-prepare") + } + + private func set(_ key: CFString, _ value: CFTypeRef) throws { + guard let session else { + throw CaptureFailure(description: "VideoToolbox encoder is closed") + } + try require(VTSessionSetProperty(session, key: key, value: value), + "videotoolbox-property") + } + + func requestKeyFrame() { + lock.lock() + forceKeyFrame = true + lock.unlock() + } + + func encode(_ image: CVImageBuffer, timestamp: CMTime) { + lock.lock() + let force = forceKeyFrame + forceKeyFrame = false + let session = session + lock.unlock() + guard let session else { return } + let properties = force + ? [kVTEncodeFrameOptionKey_ForceKeyFrame as String: true] as CFDictionary + : nil + var synchronousFlags: VTEncodeInfoFlags = [] + let status = VTCompressionSessionEncodeFrame( + session, + imageBuffer: image, + presentationTimeStamp: timestamp, + duration: CMTime(value: 1, timescale: frameRate), + frameProperties: properties, + infoFlagsOut: &synchronousFlags + ) { [weak self] status, flags, sample in + guard let self else { return } + do { + try require(status, "videotoolbox-output") + if flags.contains(.frameDropped) { + if force { requestKeyFrame() } + return + } + guard let sample, CMSampleBufferDataIsReady(sample) else { + throw CaptureFailure(description: "VideoToolbox output is not ready") + } + let keyFrame = isKeyFrame(sample) + lock.lock() + let needsFirstKeyFrame = !active && !keyFrame + if (force && !keyFrame) || needsFirstKeyFrame { + forceKeyFrame = true + } + lock.unlock() + if needsFirstKeyFrame { return } + let payload = try annexB(sample, keyFrame: keyFrame) + let seconds = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sample)) + guard seconds.isFinite, seconds >= 0 else { + throw CaptureFailure(description: "invalid H.264 timestamp") + } + try writer.writeH264( + payload, + keyFrame: keyFrame, + timestamp: UInt64(seconds * 10_000_000) + ) + lock.lock() + let publishActive = !active + active = true + lock.unlock() + if publishActive { + try writer.writeStatus(ActiveStatus()) + } + } catch { + done.signal(error) + } + } + if status != noErr { + done.signal(CaptureFailure( + description: "videotoolbox-input failed (\(status))" + )) + } else if synchronousFlags.contains(.frameDropped), force { + requestKeyFrame() + } + } + + func close() { + lock.lock() + let current = session + session = nil + lock.unlock() + guard let current else { return } + _ = VTCompressionSessionCompleteFrames( + current, + untilPresentationTimeStamp: .invalid + ) + VTCompressionSessionInvalidate(current) + } +} + +private final class EnvelopeCollector { + private let lock = NSLock() + private var values: [Data] = [] + + func append(_ value: Data) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [Data] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +private func syntheticPixelBuffer() throws -> CVPixelBuffer { + let attributes = [ + kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary, + ] as CFDictionary + var value: CVPixelBuffer? + try require( + CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attributes, + &value + ), + "pixel-buffer-create" + ) + guard let value, CVPixelBufferGetPlaneCount(value) == 2 else { + throw CaptureFailure(description: "synthetic pixel buffer is invalid") + } + try require(CVPixelBufferLockBaseAddress(value, []), "pixel-buffer-lock") + for plane in 0..<2 { + guard let base = CVPixelBufferGetBaseAddressOfPlane(value, plane) else { + CVPixelBufferUnlockBaseAddress(value, []) + throw CaptureFailure(description: "synthetic pixel plane is unavailable") + } + let byte = plane == 0 ? 16 : 128 + memset( + base, + Int32(byte), + CVPixelBufferGetBytesPerRowOfPlane(value, plane) * + CVPixelBufferGetHeightOfPlane(value, plane) + ) + } + CVPixelBufferUnlockBaseAddress(value, []) + return value +} + +private func containsRequiredH264Units(_ envelope: Data) -> Bool { + let bytes = [UInt8](envelope) + guard bytes.count > 32, bytes[5] == 2, bytes[6] & 1 == 1 else { + return false + } + var types = Set() + for index in 28..<(bytes.count - 4) where + bytes[index] == 0 && bytes[index + 1] == 0 && + bytes[index + 2] == 0 && bytes[index + 3] == 1 { + types.insert(bytes[index + 4] & 0x1f) + } + return types.isSuperset(of: [7, 8, 5]) +} + +private func selfTest() throws { + let collector = EnvelopeCollector() + let writer = ProtocolWriter(output: { collector.append($0) }) + let encoder = try HardwareEncoder(writer: writer, done: StopSignal()) + encoder.encode( + try syntheticPixelBuffer(), + timestamp: CMTime(value: 1, timescale: frameRate) + ) + encoder.close() + guard collector.snapshot().contains(where: containsRequiredH264Units) else { + throw CaptureFailure(description: "hardware H.264 self-test produced no IDR") + } +} + +private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { + private let encoder: HardwareEncoder + private let done: StopSignal + private let queue: DispatchQueue + private var lastImage: CVImageBuffer? + private var lastTimestamp = CMTime.invalid + private var lastRecoveryTime = CMTime.invalid + + init( + encoder: HardwareEncoder, + done: StopSignal, + queue: DispatchQueue + ) { + self.encoder = encoder + self.done = done + self.queue = queue + } + + func requestKeyFrame() { + queue.async { [weak self] in + guard let self else { return } + encoder.requestKeyFrame() + guard let image = lastImage else { return } + let now = CMClockGetTime(CMClockGetHostTimeClock()) + guard now.isValid else { return } + if lastRecoveryTime.isValid, + CMTimeCompare( + now, + CMTimeAdd(lastRecoveryTime, frameInterval) + ) < 0 { + return + } + lastRecoveryTime = now + let next = lastTimestamp.isValid + ? later( + now, + CMTimeAdd(lastTimestamp, timestampStep) + ) + : now + lastTimestamp = next + encoder.encode(image, timestamp: next) + } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of outputType: SCStreamOutputType + ) { + guard outputType == .screen, sampleBuffer.isValid, + let attachments = CMSampleBufferGetSampleAttachmentsArray( + sampleBuffer, + createIfNecessary: false + ) as? [[SCStreamFrameInfo: Any]], + let rawStatus = attachments.first?[.status] as? Int, + SCFrameStatus(rawValue: rawStatus) == .complete, + let image = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + let capturedTimestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + guard capturedTimestamp.isValid else { + return + } + let timestamp = lastTimestamp.isValid + ? later( + capturedTimestamp, + CMTimeAdd(lastTimestamp, timestampStep) + ) + : capturedTimestamp + lastImage = image + lastTimestamp = timestamp + encoder.encode(image, timestamp: timestamp) + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + done.signal(error) + } +} + +private func capture(_ arguments: [String]) async throws { + guard arguments.count == 11, + arguments[1] == "--capture-video", + let sourceID = UInt32(arguments[3]), sourceID > 0, + let pid = UInt32(arguments[4]), + arguments[6] == "--adapter-index", arguments[7] == "0", + arguments[8] == "--mft-index", arguments[9] == "0", + arguments[10] == "--protocol-v3" else { + throw CaptureFailure(description: "invalid capture arguments") + } + let kind = arguments[2] + let creationTime = arguments[5] + let content = try await shareableContent() + let filter: SCContentFilter + if kind == "window" { + guard pid > 0, creationTime != "0", + let selected = shareableWindows(content).first(where: { + $0.0.windowID == sourceID && $0.1.pid == pid && + $0.1.creationTime == creationTime + })?.0 else { + throw CaptureFailure(description: "capture target identity changed") + } + filter = SCContentFilter(desktopIndependentWindow: selected) + } else if kind == "display" { + guard pid == 0, creationTime == "0", + let selected = content.displays.first(where: { + $0.displayID == sourceID + }) else { + throw CaptureFailure(description: "capture target identity changed") + } + filter = SCContentFilter(display: selected, excludingWindows: []) + } else { + throw CaptureFailure(description: "capture target kind is unsupported") + } + + let writer = ProtocolWriter() + let done = StopSignal() + let encoder = try HardwareEncoder(writer: writer, done: done) + defer { encoder.close() } + let captureQueue = DispatchQueue(label: "screener.capture.video") + let output = CaptureOutput( + encoder: encoder, + done: done, + queue: captureQueue + ) + let configuration = SCStreamConfiguration() + configuration.width = width + configuration.height = height + configuration.minimumFrameInterval = CMTime(value: 1, timescale: frameRate) + configuration.queueDepth = 5 + configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + configuration.showsCursor = true + let stream = SCStream( + filter: filter, + configuration: configuration, + delegate: output + ) + try stream.addStreamOutput( + output, + type: .screen, + sampleHandlerQueue: captureQueue + ) + try writer.writeStatus(StartingStatus()) + + DispatchQueue.global(qos: .userInitiated).async { + while true { + let data = FileHandle.standardInput.readData(ofLength: 64) + if data.isEmpty { + done.signal() + return + } + for byte in data { + if byte == 75 { + output.requestKeyFrame() + } else if byte == 81 || byte == 10 || byte == 13 { + done.signal() + return + } + } + } + } + + try await stream.startCapture() + do { + try done.wait() + } catch { + try? await stream.stopCapture() + encoder.close() + throw error + } + try await stream.stopCapture() + encoder.close() +} + +@main +private struct ScreenerCapture { + static func main() async { + do { + let arguments = CommandLine.arguments + if arguments.count == 2, arguments[1] == "--probe" { + try probe() + } else if arguments.count == 2, arguments[1] == "--self-test" { + try selfTest() + } else if arguments.count == 2, arguments[1] == "--list" { + try await listSources() + } else { + try await capture(arguments) + } + } catch { + let message = "Screener capture unavailable: \(error)\n" + FileHandle.standardError.write(message.data(using: .utf8) ?? Data()) + Darwin.exit(2) + } + } +} diff --git a/native/client/platform/windows/capture/README.md b/native/client/platform/windows/capture/README.md new file mode 100644 index 0000000..76f8f2e --- /dev/null +++ b/native/client/platform/windows/capture/README.md @@ -0,0 +1,39 @@ +# Windows Capture Process + +This isolated Windows process is the capture candidate for Screener Client. It +enumerates local displays and visible top-level windows, binds each window to +its PID and process creation time, and has independent modes for: + +- capability discovery (`--probe`); +- process-tree or default-device loopback PCM (`--capture-audio`); or +- WGC/D3D11 screen/window video with adapter-bound, hardware-only Media Foundation + H.264 output (`--capture-video`). +- bounded 160x90 BMP source previews (`--preview`). + +Video and audio run as separate bounded child processes. A source whose audio +loopback cannot be initialized keeps video available and reports audio +unavailable instead of failing the whole source. Process loopback is probed by +activation rather than inferred from a Windows build number; display sources +use the standard render-device loopback available on Windows 10 and later. + +It has no software encoder, alternate codec, or network fallback. The Client +consumes the selected process or system-audio stream through its native media +edge when the capability probe reports support. Build it outside the repository +for a bounded capability run: + +```powershell +$out = Join-Path ([IO.Path]::GetTempPath()) 'screener-native-capture' +./native/client/platform/windows/capture/build.ps1 -OutputDirectory $out +``` + +The implementation follows Microsoft's MIT-licensed reference samples and +official API contracts without copying their WIL framework. The retained MF +fixture compiles the same encoder source with `SCREENER_H264_FIXTURE`; there is +not a second product MFT implementation. + +- +- +- +- +- +- diff --git a/native/window-capture-helper/build.ps1 b/native/client/platform/windows/capture/build.ps1 similarity index 75% rename from native/window-capture-helper/build.ps1 rename to native/client/platform/windows/capture/build.ps1 index bb3f978..06a60e5 100644 --- a/native/window-capture-helper/build.ps1 +++ b/native/client/platform/windows/capture/build.ps1 @@ -25,16 +25,19 @@ if ([string]::IsNullOrWhiteSpace($installationPath)) { $developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' $mainSourcePath = Join-Path $helperDirectory 'main.cpp' $audioSourcePath = Join-Path $helperDirectory 'process_audio.cpp' -$executablePath = Join-Path $outputPath 'screener-window-capture.exe' +$targetSourcePath = Join-Path $helperDirectory 'capture_target.cpp' +$executablePath = Join-Path $outputPath 'screener-client-capture.exe' $mainObjectPath = Join-Path $outputPath 'window-capture.obj' $audioObjectPath = Join-Path $outputPath 'process-audio.obj' +$targetObjectPath = Join-Path $outputPath 'capture-target.obj' New-Item -ItemType Directory -Path $outputPath -Force | Out-Null $compile = @( 'call "{0}" -arch=x64 -host_arch=x64 >nul' -f $developerCommand 'cl.exe /nologo /c /std:c++20 /EHsc /W4 /WX /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A "{0}" /Fo:"{1}"' -f $mainSourcePath, $mainObjectPath 'cl.exe /nologo /c /std:c++20 /EHsc /W4 /WX /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A "{0}" /Fo:"{1}"' -f $audioSourcePath, $audioObjectPath - 'link.exe /nologo /out:"{0}" "{1}" "{2}" ole32.lib mmdevapi.lib runtimeobject.lib user32.lib mfplat.lib mf.lib mfuuid.lib d3d11.lib dxgi.lib dxguid.lib evr.lib oleaut32.lib windowsapp.lib' -f $executablePath, $mainObjectPath, $audioObjectPath + 'cl.exe /nologo /c /std:c++20 /EHsc /W4 /WX /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A "{0}" /Fo:"{1}"' -f $targetSourcePath, $targetObjectPath + 'link.exe /nologo /out:"{0}" "{1}" "{2}" "{3}" ole32.lib mmdevapi.lib runtimeobject.lib user32.lib gdi32.lib mfplat.lib mf.lib mfuuid.lib d3d11.lib dxgi.lib dxguid.lib evr.lib oleaut32.lib windowsapp.lib' -f $executablePath, $mainObjectPath, $audioObjectPath, $targetObjectPath ) -join ' && ' & cmd.exe /d /s /c $compile diff --git a/native/client/platform/windows/capture/capture_target.cpp b/native/client/platform/windows/capture/capture_target.cpp new file mode 100644 index 0000000..33ee49a --- /dev/null +++ b/native/client/platform/windows/capture/capture_target.cpp @@ -0,0 +1,355 @@ +#define NOMINMAX +#include + +#include "capture_target.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace screener::capture { +namespace { + +constexpr size_t kMaxSources = 100; +constexpr LONG kPreviewWidth = 160; +constexpr LONG kPreviewHeight = 90; +constexpr DWORD kBitmapHeaderBytes = 54; + +struct SourceTarget { + TargetKind kind; + UINT64 source_id; + DWORD pid; + UINT64 creation_time; + std::string title; + bool primary; +}; + +HRESULT ReadProcessCreationTime(HANDLE process, UINT64* creation_time) { + FILETIME created{}, exited{}, kernel{}, user{}; + if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) { + return HRESULT_FROM_WIN32(GetLastError()); + } + ULARGE_INTEGER value{}; + value.LowPart = created.dwLowDateTime; + value.HighPart = created.dwHighDateTime; + if (value.QuadPart == 0) return HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + *creation_time = value.QuadPart; + return S_OK; +} + +std::string Utf8(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) return {}; + std::string result(static_cast(size), '\0'); + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), size, + nullptr, nullptr) != size) { + return {}; + } + return result; +} + +std::string JsonString(const std::string& value) { + static constexpr char hex[] = "0123456789abcdef"; + std::string result = "\""; + for (const unsigned char character : value) { + switch (character) { + case '\\': result += "\\\\"; break; + case '"': result += "\\\""; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (character < 0x20) { + result += "\\u00"; + result += hex[character >> 4]; + result += hex[character & 0xf]; + } else { + result.push_back(static_cast(character)); + } + } + } + result += '"'; + return result; +} + +BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) { + auto* targets = reinterpret_cast*>(parameter); + if (targets->size() >= kMaxSources) return FALSE; + if (!IsWindowVisible(window) || GetWindow(window, GW_OWNER) != nullptr) { + return TRUE; + } + const int length = GetWindowTextLengthW(window); + if (length <= 0 || length > 512) return TRUE; + DWORD pid = 0; + GetWindowThreadProcessId(window, &pid); + if (pid == 0 || pid == GetCurrentProcessId()) return TRUE; + std::wstring title(static_cast(length) + 1, L'\0'); + const int copied = GetWindowTextW(window, title.data(), length + 1); + if (copied <= 0) return TRUE; + title.resize(static_cast(copied)); + std::string utf8 = Utf8(title); + if (utf8.empty()) return TRUE; + HANDLE process = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) return TRUE; + UINT64 creation_time = 0; + const HRESULT result = ReadProcessCreationTime(process, &creation_time); + CloseHandle(process); + if (SUCCEEDED(result)) { + targets->push_back({ + TargetKind::window, + static_cast(reinterpret_cast(window)), pid, + creation_time, std::move(utf8), false}); + } + return TRUE; +} + +BOOL CALLBACK CollectDisplay(HMONITOR monitor, HDC, LPRECT, LPARAM parameter) { + auto* targets = reinterpret_cast*>(parameter); + if (targets->size() >= kMaxSources) return FALSE; + MONITORINFOEXW info{}; + info.cbSize = sizeof(info); + if (!GetMonitorInfoW(monitor, &info)) return TRUE; + std::string title = Utf8(info.szDevice); + if (title.empty()) return TRUE; + targets->push_back({ + TargetKind::display, + static_cast(reinterpret_cast(monitor)), 0, 0, + std::move(title), (info.dwFlags & MONITORINFOF_PRIMARY) != 0}); + return TRUE; +} + +void PutUint16LE(BYTE* output, UINT16 value) { + output[0] = static_cast(value & 0xff); + output[1] = static_cast((value >> 8) & 0xff); +} + +void PutUint32LE(BYTE* output, UINT32 value) { + for (size_t index = 0; index < 4; ++index) { + output[index] = static_cast(value & 0xff); + value >>= 8; + } +} + +HRESULT SourceRect(TargetKind kind, UINT64 source_id, RECT* rect) { + if (rect == nullptr || source_id == 0 || + source_id > static_cast( + std::numeric_limits::max())) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + if (kind == TargetKind::window) { + HWND window = reinterpret_cast(static_cast(source_id)); + if (!IsWindow(window) || IsIconic(window) || !GetWindowRect(window, rect)) { + return HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE); + } + return S_OK; + } + HMONITOR monitor = + reinterpret_cast(static_cast(source_id)); + MONITORINFO info{}; + info.cbSize = sizeof(info); + if (!GetMonitorInfoW(monitor, &info)) { + return HRESULT_FROM_WIN32(ERROR_INVALID_MONITOR_HANDLE); + } + *rect = info.rcMonitor; + return S_OK; +} + +HRESULT CapturePreview(TargetKind kind, UINT64 source_id, + std::vector* output) { + RECT source{}; + HRESULT result = SourceRect(kind, source_id, &source); + if (FAILED(result)) return result; + const LONG source_width = source.right - source.left; + const LONG source_height = source.bottom - source.top; + if (source_width <= 0 || source_height <= 0) { + return HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + } + + HDC screen = GetDC(nullptr); + if (screen == nullptr) return HRESULT_FROM_WIN32(GetLastError()); + HDC target = CreateCompatibleDC(screen); + if (target == nullptr) { + result = HRESULT_FROM_WIN32(GetLastError()); + ReleaseDC(nullptr, screen); + return result; + } + BITMAPINFO bitmap{}; + bitmap.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap.bmiHeader.biWidth = kPreviewWidth; + bitmap.bmiHeader.biHeight = -kPreviewHeight; + bitmap.bmiHeader.biPlanes = 1; + bitmap.bmiHeader.biBitCount = 32; + bitmap.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; + HBITMAP image = CreateDIBSection(screen, &bitmap, DIB_RGB_COLORS, &pixels, + nullptr, 0); + if (image == nullptr || pixels == nullptr) { + result = HRESULT_FROM_WIN32(GetLastError()); + if (image != nullptr) DeleteObject(image); + DeleteDC(target); + ReleaseDC(nullptr, screen); + return result; + } + HGDIOBJ previous = SelectObject(target, image); + RECT background{0, 0, kPreviewWidth, kPreviewHeight}; + FillRect(target, &background, static_cast(GetStockObject(BLACK_BRUSH))); + + const double scale = std::min( + static_cast(kPreviewWidth) / source_width, + static_cast(kPreviewHeight) / source_height); + const LONG width = std::max(1, static_cast(source_width * scale)); + const LONG height = + std::max(1, static_cast(source_height * scale)); + const LONG left = (kPreviewWidth - width) / 2; + const LONG top = (kPreviewHeight - height) / 2; + SetStretchBltMode(target, HALFTONE); + SetBrushOrgEx(target, 0, 0, nullptr); + if (!StretchBlt(target, left, top, width, height, screen, source.left, + source.top, source_width, source_height, + SRCCOPY | CAPTUREBLT)) { + result = HRESULT_FROM_WIN32(GetLastError()); + } + + if (SUCCEEDED(result)) { + constexpr UINT32 row_bytes = static_cast(kPreviewWidth) * 3; + constexpr UINT32 pixel_bytes = row_bytes * kPreviewHeight; + output->assign(kBitmapHeaderBytes + pixel_bytes, 0); + BYTE* bytes = output->data(); + bytes[0] = 'B'; + bytes[1] = 'M'; + PutUint32LE(bytes + 2, static_cast(output->size())); + PutUint32LE(bytes + 10, kBitmapHeaderBytes); + PutUint32LE(bytes + 14, 40); + PutUint32LE(bytes + 18, static_cast(kPreviewWidth)); + PutUint32LE(bytes + 22, static_cast(-kPreviewHeight)); + PutUint16LE(bytes + 26, 1); + PutUint16LE(bytes + 28, 24); + PutUint32LE(bytes + 34, pixel_bytes); + const auto* bgra = static_cast(pixels); + BYTE* bgr = bytes + kBitmapHeaderBytes; + for (LONG index = 0; index < kPreviewWidth * kPreviewHeight; ++index) { + bgr[index * 3] = bgra[index * 4]; + bgr[index * 3 + 1] = bgra[index * 4 + 1]; + bgr[index * 3 + 2] = bgra[index * 4 + 2]; + } + } + + SelectObject(target, previous); + DeleteObject(image); + DeleteDC(target); + ReleaseDC(nullptr, screen); + return result; +} + +} // namespace + +int WriteSourceList() { + std::vector targets; + if (!EnumDisplayMonitors(nullptr, nullptr, CollectDisplay, + reinterpret_cast(&targets)) || + !EnumWindows(CollectWindow, reinterpret_cast(&targets))) { + return 2; + } + std::stable_sort(targets.begin(), targets.end(), + [](const SourceTarget& left, const SourceTarget& right) { + if (left.kind != right.kind) { + return left.kind == TargetKind::display; + } + if (left.primary != right.primary) return left.primary; + return left.title < right.title; + }); + std::cout << '['; + for (size_t index = 0; index < targets.size(); ++index) { + if (index != 0) std::cout << ','; + const SourceTarget& target = targets[index]; + std::cout << "{\"kind\":\"" + << (target.kind == TargetKind::display ? "display" : "window") + << "\",\"sourceId\":\"" << target.source_id << '\"'; + if (target.kind == TargetKind::window) { + std::cout << ",\"pid\":" << target.pid << ",\"creationTime\":\"" + << target.creation_time << '\"'; + } + std::cout << ",\"title\":" << JsonString(target.title) << '}'; + } + std::cout << ']'; + return std::cout.good() ? 0 : 2; +} + +HRESULT WriteSourcePreview(TargetKind kind, UINT64 source_id, DWORD pid, + UINT64 expected_creation_time) { + HRESULT result = kind == TargetKind::window + ? ValidateWindowTarget(source_id, pid, + expected_creation_time) + : ValidateDisplayTarget(source_id); + if (FAILED(result)) return result; + std::vector bitmap; + result = CapturePreview(kind, source_id, &bitmap); + if (FAILED(result)) return result; + DWORD written = 0; + HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); + if (!WriteFile(output, bitmap.data(), static_cast(bitmap.size()), + &written, nullptr) || written != bitmap.size()) { + return HRESULT_FROM_WIN32(GetLastError()); + } + return S_OK; +} + +HRESULT ValidateProcessTarget(DWORD pid, UINT64 expected_creation_time) { + if (pid == 0 || expected_creation_time == 0) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) return HRESULT_FROM_WIN32(GetLastError()); + UINT64 actual_creation_time = 0; + HRESULT result = ReadProcessCreationTime(process, &actual_creation_time); + CloseHandle(process); + if (SUCCEEDED(result) && actual_creation_time != expected_creation_time) { + result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + } + return result; +} + +HRESULT ValidateWindowTarget(UINT64 source_id, DWORD pid, + UINT64 expected_creation_time) { + if (source_id == 0 || + source_id > + static_cast(std::numeric_limits::max())) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + HWND window = reinterpret_cast(static_cast(source_id)); + DWORD window_pid = 0; + if (!IsWindow(window) || GetWindowThreadProcessId(window, &window_pid) == 0 || + window_pid != pid) { + return HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE); + } + return ValidateProcessTarget(pid, expected_creation_time); +} + +HRESULT ValidateDisplayTarget(UINT64 source_id) { + if (source_id == 0 || + source_id > + static_cast(std::numeric_limits::max())) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + HMONITOR monitor = + reinterpret_cast(static_cast(source_id)); + MONITORINFO info{}; + info.cbSize = sizeof(info); + return GetMonitorInfoW(monitor, &info) + ? S_OK + : HRESULT_FROM_WIN32(ERROR_INVALID_MONITOR_HANDLE); +} + +} // namespace screener::capture diff --git a/native/client/platform/windows/capture/capture_target.h b/native/client/platform/windows/capture/capture_target.h new file mode 100644 index 0000000..418e9fd --- /dev/null +++ b/native/client/platform/windows/capture/capture_target.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +namespace screener::capture { + +enum class TargetKind { window, display }; + +int WriteSourceList(); +HRESULT WriteSourcePreview(TargetKind kind, UINT64 source_id, DWORD pid, + UINT64 expected_creation_time); +HRESULT ValidateProcessTarget(DWORD pid, UINT64 expected_creation_time); +HRESULT ValidateWindowTarget(UINT64 source_id, DWORD pid, + UINT64 expected_creation_time); +HRESULT ValidateDisplayTarget(UINT64 source_id); + +} // namespace screener::capture diff --git a/native/window-capture-helper/main.cpp b/native/client/platform/windows/capture/main.cpp similarity index 85% rename from native/window-capture-helper/main.cpp rename to native/client/platform/windows/capture/main.cpp index e10768e..ec30aba 100644 --- a/native/window-capture-helper/main.cpp +++ b/native/client/platform/windows/capture/main.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,7 @@ #include #include +#include "capture_target.h" #include "process_audio.h" #include @@ -65,6 +67,8 @@ constexpr UINT32 kMaxEventsPerPump = 64; constexpr DWORD kMaxEncodedSampleBytes = 4 * 1024 * 1024; #ifdef SCREENER_H264_FIXTURE constexpr UINT32 kFrameCount = 360; +constexpr UINT32 kRateProbeBitRate = kBitRate / 2; +constexpr UINT32 kRateProbeFrames = kFrameCount / 3; constexpr UINT32 kMaxInFlight = 8; constexpr DWORD kMaxPdhArrayBytes = 4 * 1024 * 1024; constexpr DWORD kMaxPdhArrayItems = 16 * 1024; @@ -282,7 +286,8 @@ std::wstring GetOptionalString(IMFAttributes* attributes, const GUID& key) { return value; } -ActivationList EnumerateHardwareEncoders(const Adapter& adapter) { +ActivationList EnumerateHardwareEncoders(const Adapter& adapter, + bool require_candidate = true) { ComPtr attributes; Check(MFCreateAttributes(&attributes, 1), "mft-enumeration-attributes"); Check(attributes->SetBlob(MFT_ENUM_ADAPTER_LUID, @@ -298,7 +303,7 @@ ActivationList EnumerateHardwareEncoders(const Adapter& adapter) { MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_SORTANDFILTER, &input, &output, attributes.Get(), &list.values, &list.count), "mft-hardware-enumeration"); - if (list.count == 0) { + if (require_candidate && list.count == 0) { Fail("mft-hardware-enumeration", "selected adapter exposed no hardware NV12-to-H264 MFT"); } @@ -867,6 +872,7 @@ struct RunEvidence final { UINT32 max_in_flight = 0; UINT32 recovery_units = 0; UINT64 output_bytes = 0; + std::array rate_phase_bytes{}; std::vector latencies_ms; std::vector video_encode; std::string profile_level_id; @@ -1072,6 +1078,8 @@ RunEvidence RunEncoder(const Adapter& adapter, const DeviceContext& device, received[frame] = true; ++evidence.outputs; evidence.output_bytes += bytes.size(); + evidence.rate_phase_bytes[std::min( + frame / kRateProbeFrames, 2)] += bytes.size(); auto now = std::chrono::steady_clock::now(); evidence.latencies_ms.push_back( std::chrono::duration(now - submitted[frame]) @@ -1088,6 +1096,13 @@ RunEvidence RunEncoder(const Adapter& adapter, const DeviceContext& device, evidence.inputs - evidence.outputs < kMaxInFlight && now >= next_input) { UINT32 frame = evidence.inputs; + if (frame == kRateProbeFrames) { + SetU32(selected.codec.Get(), CODECAPI_AVEncCommonMeanBitRate, + kRateProbeBitRate, "codec-runtime-bitrate-low"); + } else if (frame == kRateProbeFrames * 2) { + SetU32(selected.codec.Get(), CODECAPI_AVEncCommonMeanBitRate, + kBitRate, "codec-runtime-bitrate-high"); + } if (IsRecoveryFrame(frame)) { ForceKeyFrame(selected.codec.Get()); } @@ -1161,6 +1176,12 @@ RunEvidence RunEncoder(const Adapter& adapter, const DeviceContext& device, << "evidence_recovery_units=" << evidence.recovery_units << '\n' << "evidence_max_in_flight=" << evidence.max_in_flight << '\n' << "evidence_output_bytes=" << evidence.output_bytes << '\n' + << "evidence_rate_phase_high_1_bytes=" + << evidence.rate_phase_bytes[0] << '\n' + << "evidence_rate_phase_low_bytes=" + << evidence.rate_phase_bytes[1] << '\n' + << "evidence_rate_phase_high_2_bytes=" + << evidence.rate_phase_bytes[2] << '\n' << "evidence_profile_level_id=" << evidence.profile_level_id << '\n' << std::fixed << std::setprecision(3) << "evidence_latency_p95_ms=" @@ -1178,13 +1199,14 @@ RunEvidence RunEncoder(const Adapter& adapter, const DeviceContext& device, Fail("videoencode-attribution", "no process-and-adapter-attributed VideoEncode activity was observed"); } - constexpr const char* default_pion_fmtp[] = { - "42001f", "42e01f", "4d001f", "64001f", - }; - if (std::find(std::begin(default_pion_fmtp), std::end(default_pion_fmtp), - *observed_profile) == std::end(default_pion_fmtp)) { + if (!(evidence.rate_phase_bytes[1] < evidence.rate_phase_bytes[0] && + evidence.rate_phase_bytes[2] > evidence.rate_phase_bytes[1])) { + Fail("codec-runtime-bitrate-effect", + "live bitrate update did not lower and restore encoded output"); + } + if (*observed_profile != "42c01f") { Fail("bitstream-pinned-fmtp", - "SPS profile-level-id is not in the default Pion mode-1 fmtp set"); + "SPS profile-level-id differs from the native media contract"); } return evidence; } @@ -1598,6 +1620,18 @@ winrt::Windows::Graphics::Capture::GraphicsCaptureItem CreateCaptureItem( return item; } +winrt::Windows::Graphics::Capture::GraphicsCaptureItem CreateCaptureItem( + HMONITOR monitor) { + using winrt::Windows::Graphics::Capture::GraphicsCaptureItem; + auto interop = winrt::get_activation_factory(); + GraphicsCaptureItem item{nullptr}; + Check(interop->CreateForMonitor(monitor, winrt::guid_of(), + winrt::put_abi(item)), + "capture-item-display"); + return item; +} + ComPtr CaptureTexture( const winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame& frame) { auto access = frame.Surface().as< @@ -1624,23 +1658,40 @@ std::string JSONString(const std::string& value) { return output; } -bool ConsumeKeyFrameRequest() { +struct ControlSignal final { + bool key_frame = false; + bool stop = false; +}; + +ControlSignal ConsumeControlSignal() { HANDLE input = GetStdHandle(STD_INPUT_HANDLE); DWORD available = 0; - if (input == INVALID_HANDLE_VALUE || input == nullptr || - !PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr) || - available == 0) { - return false; + if (input == INVALID_HANDLE_VALUE || input == nullptr) { + return {}; + } + if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) { + DWORD error = GetLastError(); + return {.stop = error == ERROR_BROKEN_PIPE}; + } + if (available == 0) { + return {}; } std::array bytes{}; DWORD read = 0; if (!ReadFile(input, bytes.data(), std::min(available, static_cast(bytes.size())), &read, nullptr)) { - return false; + return {.stop = GetLastError() == ERROR_BROKEN_PIPE}; } - return std::find(bytes.begin(), bytes.begin() + read, - static_cast('K')) != bytes.begin() + read; + ControlSignal signal; + for (auto current = bytes.begin(); current != bytes.begin() + read; + ++current) { + signal.key_frame = signal.key_frame || *current == static_cast('K'); + signal.stop = signal.stop || *current == static_cast('Q') || + *current == static_cast('\n') || + *current == static_cast('\r'); + } + return signal; } UINT64 ParseUint64(const wchar_t* value, const std::string& stage) { @@ -1656,11 +1707,33 @@ UINT64 ParseUint64(const wchar_t* value, const std::string& stage) { } } +UINT64 ParseNonNegativeUint64(const wchar_t* value, const std::string& stage) { + try { + size_t consumed = 0; + unsigned long long parsed = std::stoull(value, &consumed, 10); + if (value[consumed] != L'\0') { + Fail(stage, "value is not a bounded integer"); + } + return static_cast(parsed); + } catch (const std::exception&) { + Fail(stage, "value is not a bounded integer"); + } +} + +screener::capture::TargetKind ParseTargetKind(const wchar_t* value) { + const std::wstring kind(value); + if (kind == L"window") return screener::capture::TargetKind::window; + if (kind == L"display") return screener::capture::TargetKind::display; + Fail("argument-target-kind", "capture target kind is unsupported"); +} + struct ProductArguments final { - enum class Mode { list, audio, window } mode = Mode::list; + enum class Mode { list, probe, preview, audio, video } mode = Mode::list; + screener::capture::TargetKind target_kind = + screener::capture::TargetKind::window; DWORD pid = 0; UINT64 creation_time = 0; - UINT64 window_handle = 0; + UINT64 source_id = 0; UINT adapter_index = 0; UINT mft_index = 0; }; @@ -1668,25 +1741,55 @@ struct ProductArguments final { ProductArguments ParseProductArguments(int count, wchar_t** values) { ProductArguments arguments; if (count == 2 && std::wstring(values[1]) == L"--list") return arguments; - if (count == 4 && std::wstring(values[1]) == L"--capture-audio") { + if (count == 2 && std::wstring(values[1]) == L"--probe") { + arguments.mode = ProductArguments::Mode::probe; + return arguments; + } + if (count == 6 && std::wstring(values[1]) == L"--preview") { + arguments.mode = ProductArguments::Mode::preview; + arguments.target_kind = ParseTargetKind(values[2]); + arguments.source_id = ParseUint64(values[3], "argument-source"); + UINT64 pid = ParseNonNegativeUint64(values[4], "argument-pid"); + if (pid > std::numeric_limits::max()) { + Fail("argument-pid", "PID is outside the Windows process range"); + } + arguments.pid = static_cast(pid); + arguments.creation_time = + ParseNonNegativeUint64(values[5], "argument-creation-time"); + return arguments; + } + if (count == 5 && std::wstring(values[1]) == L"--capture-audio") { arguments.mode = ProductArguments::Mode::audio; - } else if (count == 10 && std::wstring(values[1]) == L"--capture-window" && - std::wstring(values[5]) == L"--adapter-index" && - std::wstring(values[7]) == L"--mft-index" && - std::wstring(values[9]) == L"--protocol-v1") { - arguments.mode = ProductArguments::Mode::window; - arguments.window_handle = ParseUint64(values[4], "argument-window"); - arguments.adapter_index = ParseIndex(values[6], "argument-adapter"); - arguments.mft_index = ParseIndex(values[8], "argument-mft"); + arguments.target_kind = ParseTargetKind(values[2]); + } else if (count == 11 && std::wstring(values[1]) == L"--capture-video" && + std::wstring(values[6]) == L"--adapter-index" && + std::wstring(values[8]) == L"--mft-index" && + std::wstring(values[10]) == L"--protocol-v3") { + arguments.mode = ProductArguments::Mode::video; + arguments.target_kind = ParseTargetKind(values[2]); + arguments.source_id = ParseUint64(values[3], "argument-source"); + arguments.adapter_index = ParseIndex(values[7], "argument-adapter"); + arguments.mft_index = ParseIndex(values[9], "argument-mft"); } else { Fail("arguments", "unsupported or incomplete command-line argument"); } - UINT64 pid = ParseUint64(values[2], "argument-pid"); + const int pid_index = arguments.mode == ProductArguments::Mode::audio ? 3 : 4; + const int creation_index = pid_index + 1; + UINT64 pid = ParseNonNegativeUint64(values[pid_index], "argument-pid"); if (pid > std::numeric_limits::max()) { Fail("argument-pid", "PID is outside the Windows process range"); } arguments.pid = static_cast(pid); - arguments.creation_time = ParseUint64(values[3], "argument-creation-time"); + arguments.creation_time = + ParseNonNegativeUint64(values[creation_index], "argument-creation-time"); + if (arguments.target_kind == screener::capture::TargetKind::window && + (arguments.pid == 0 || arguments.creation_time == 0)) { + Fail("argument-window-identity", "window identity is incomplete"); + } + if (arguments.target_kind == screener::capture::TargetKind::display && + (arguments.pid != 0 || arguments.creation_time != 0)) { + Fail("argument-display-identity", "display identity is invalid"); + } return arguments; } @@ -1694,18 +1797,111 @@ HRESULT RunAudioCapture(const ProductArguments& arguments) { UniqueHandle stop(CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (stop.get() == nullptr) return HRESULT_FROM_WIN32(GetLastError()); ProtocolWriter writer; + auto ready = [&writer]() { + return writer.WriteStatus("{\"state\":\"active\",\"audio\":true}"); + }; + auto pcm = [&writer](UINT64 timestamp100ns, const BYTE* data, DWORD size) { + return writer.Write(OutputKind::pcm, 0, timestamp100ns, + screener::capture::kAudioChunkDuration100ns, data, + size); + }; + if (arguments.target_kind == screener::capture::TargetKind::display) { + return screener::capture::CaptureSystemAudio( + stop.get(), []() { return ConsumeControlSignal().stop; }, ready, pcm); + } return screener::capture::CaptureProcessAudio( arguments.pid, arguments.creation_time, stop.get(), - [&writer](UINT64 timestamp100ns, const BYTE* data, DWORD size) { - return writer.Write(OutputKind::pcm, 0, timestamp100ns, - screener::capture::kAudioChunkDuration100ns, data, - size); - }); + []() { return ConsumeControlSignal().stop; }, + ready, pcm); } -void RunWindowCapture(const ProductArguments& arguments) { - HRESULT identity = screener::capture::ValidateWindowTarget( - arguments.window_handle, arguments.pid, arguments.creation_time); +UINT32 WindowsBuild() { + HMODULE module = GetModuleHandleW(L"ntdll.dll"); + if (module == nullptr) { + Fail("windows-version", "ntdll.dll is unavailable"); + } + using RtlGetVersionFunction = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + auto get_version = reinterpret_cast( + GetProcAddress(module, "RtlGetVersion")); + if (get_version == nullptr) { + Fail("windows-version", "RtlGetVersion is unavailable"); + } + RTL_OSVERSIONINFOW version = {}; + version.dwOSVersionInfoSize = sizeof(version); + if (get_version(&version) < 0 || version.dwMajorVersion != 10) { + Fail("windows-version", "unsupported Windows version"); + } + return version.dwBuildNumber; +} + +void WriteCapabilityProbe() { + constexpr UINT32 kCreateForWindowMinimumBuild = 18'362; + constexpr UINT32 kProcessLoopbackMinimumBuild = 19'041; + const UINT32 build = WindowsBuild(); + bool window_capture = false; + if (build >= kCreateForWindowMinimumBuild) { + try { + window_capture = + winrt::Windows::Graphics::Capture::GraphicsCaptureSession::IsSupported(); + } catch (const winrt::hresult_error&) { + window_capture = false; + } + } + const bool process_audio = + build >= kProcessLoopbackMinimumBuild && + screener::capture::ProcessAudioAvailable(); + const bool system_audio = screener::capture::SystemAudioAvailable(); + + std::vector adapters = EnumerateAdapters(); + std::ostringstream output; + output << "{\"protocol\":3,\"platform\":\"windows\",\"platformBuild\":" + << JSONString(std::to_string(build)) + << ",\"videoCapture\":" << (window_capture ? "true" : "false") + << ",\"processAudio\":" + << (process_audio ? "true" : "false") + << ",\"systemAudio\":" << (system_audio ? "true" : "false") + << ",\"adapters\":["; + for (size_t adapter_index = 0; adapter_index < adapters.size(); + ++adapter_index) { + if (adapter_index != 0) output << ','; + const Adapter& adapter = adapters[adapter_index]; + ActivationList encoders = EnumerateHardwareEncoders(adapter, false); + output << "{\"index\":" << adapter.index << ",\"name\":" + << JSONString(NarrowAscii(adapter.description.Description)) + << ",\"identity\":" + << JSONString(LuidString(adapter.description.AdapterLuid)) + << ",\"hardwareH264\":["; + for (UINT32 encoder_index = 0; encoder_index < encoders.count; + ++encoder_index) { + if (encoder_index != 0) output << ','; + IMFActivate* encoder = encoders.values[encoder_index]; + std::wstring name = + GetOptionalString(encoder, MFT_FRIENDLY_NAME_Attribute); + GUID clsid = {}; + Check(encoder->GetGUID(MFT_TRANSFORM_CLSID_Attribute, &clsid), + "mft-probe-clsid"); + output << "{\"index\":" << encoder_index << ",\"name\":" + << JSONString(NarrowAscii(name)) << ",\"identity\":" + << JSONString(GuidString(clsid)) << '}'; + } + output << "]}"; + } + output << "]}"; + std::cout << output.str(); + if (!std::cout.good()) { + Fail("capability-output", "could not write the capability result"); + } +} + +void RunVideoCapture(const ProductArguments& arguments) { + const bool window_target = + arguments.target_kind == screener::capture::TargetKind::window; + HRESULT identity = window_target + ? screener::capture::ValidateWindowTarget( + arguments.source_id, arguments.pid, + arguments.creation_time) + : screener::capture::ValidateDisplayTarget( + arguments.source_id); Check(identity, "target-identity"); std::vector adapters = EnumerateAdapters(); @@ -1723,19 +1919,27 @@ void RunWindowCapture(const ProductArguments& arguments) { if (!GraphicsCaptureSession::IsSupported()) { Fail("capture-support", "Windows Graphics Capture is unavailable"); } - UniqueHandle process(OpenProcess(SYNCHRONIZE, FALSE, arguments.pid)); - if (process.get() == nullptr) { + UniqueHandle process(window_target + ? OpenProcess(SYNCHRONIZE, FALSE, arguments.pid) + : nullptr); + if (window_target && process.get() == nullptr) { Check(HRESULT_FROM_WIN32(GetLastError()), "target-process-handle"); } - Check(screener::capture::ValidateWindowTarget( - arguments.window_handle, arguments.pid, arguments.creation_time), + Check(window_target + ? screener::capture::ValidateWindowTarget( + arguments.source_id, arguments.pid, arguments.creation_time) + : screener::capture::ValidateDisplayTarget(arguments.source_id), "target-identity-before-capture"); - HWND window = reinterpret_cast( - static_cast(arguments.window_handle)); auto capture_device = CreateCaptureDevice(device.device.Get()); - GraphicsCaptureItem item = CreateCaptureItem(window); - Check(screener::capture::ValidateWindowTarget( - arguments.window_handle, arguments.pid, arguments.creation_time), + GraphicsCaptureItem item = window_target + ? CreateCaptureItem(reinterpret_cast( + static_cast(arguments.source_id))) + : CreateCaptureItem(reinterpret_cast( + static_cast(arguments.source_id))); + Check(window_target + ? screener::capture::ValidateWindowTarget( + arguments.source_id, arguments.pid, arguments.creation_time) + : screener::capture::ValidateDisplayTarget(arguments.source_id), "target-identity-after-item"); auto initial_size = item.Size(); if (initial_size.Width <= 0 || initial_size.Height <= 0) { @@ -1752,8 +1956,6 @@ void RunWindowCapture(const ProductArguments& arguments) { Check(HRESULT_FROM_WIN32(GetLastError()), "capture-events"); } - std::atomic audio_result{S_OK}; - std::atomic audio_finished{false}; std::atomic item_closed{false}; auto frame_token = pool.FrameArrived( [event = frame_ready.get()](const Direct3D11CaptureFramePool&, @@ -1766,19 +1968,6 @@ void RunWindowCapture(const ProductArguments& arguments) { item_closed.store(true); SetEvent(event); }); - std::thread audio([&]() { - HRESULT result = screener::capture::CaptureProcessAudio( - arguments.pid, arguments.creation_time, shutdown.get(), - [&writer](UINT64 timestamp100ns, const BYTE* data, DWORD size) { - return writer.Write(OutputKind::pcm, 0, timestamp100ns, - screener::capture::kAudioChunkDuration100ns, - data, size); - }); - audio_result.store(result); - audio_finished.store(true); - SetEvent(shutdown.get()); - }); - auto cleanup = [&]() noexcept { SetEvent(shutdown.get()); try { @@ -1797,7 +1986,6 @@ void RunWindowCapture(const ProductArguments& arguments) { pool.Close(); } catch (...) { } - if (audio.joinable()) audio.join(); }; try { @@ -1806,12 +1994,12 @@ void RunWindowCapture(const ProductArguments& arguments) { << "\"adapterIndex\":" << adapter.index << ",\"adapterName\":" << JSONString(NarrowAscii(adapter.description.Description)) - << ",\"adapterLuid\":" + << ",\"adapterIdentity\":" << JSONString(LuidString(adapter.description.AdapterLuid)) - << ",\"mftIndex\":" << arguments.mft_index - << ",\"mftName\":" + << ",\"encoderIndex\":" << arguments.mft_index + << ",\"encoderName\":" << JSONString(encoder.selected().name) - << ",\"mftClsid\":" + << ",\"encoderIdentity\":" << JSONString(encoder.selected().clsid) << '}'; Check(writer.WriteStatus(starting.str()), "capture-status-starting"); capture_session.StartCapture(); @@ -1820,27 +2008,30 @@ void RunWindowCapture(const ProductArguments& arguments) { UINT64 encoded_frames = 0; bool active_status_written = false; auto pool_size = initial_size; - const HANDLE waits[] = {process.get(), shutdown.get(), frame_ready.get()}; + const HANDLE window_waits[] = { + process.get(), shutdown.get(), frame_ready.get()}; + const HANDLE display_waits[] = {shutdown.get(), frame_ready.get()}; for (;;) { - DWORD wait = WaitForMultipleObjects(3, waits, FALSE, 5'000); - if (wait == WAIT_OBJECT_0) { + DWORD wait = window_target + ? WaitForMultipleObjects(3, window_waits, FALSE, 5'000) + : WaitForMultipleObjects(2, display_waits, FALSE, 5'000); + if (window_target && wait == WAIT_OBJECT_0) { Fail("target-exited", "selected target process exited"); } - if (wait == WAIT_OBJECT_0 + 1) { + const DWORD shutdown_index = + window_target ? WAIT_OBJECT_0 + 1 : WAIT_OBJECT_0; + const DWORD frame_index = + window_target ? WAIT_OBJECT_0 + 2 : WAIT_OBJECT_0 + 1; + if (wait == shutdown_index) { if (item_closed.load()) { - Fail("capture-closed", "selected window stopped capture"); + Fail("capture-closed", "selected source stopped capture"); } - HRESULT result = static_cast(audio_result.load()); - if (audio_finished.load() && result != HRESULT_FROM_WIN32(ERROR_CANCELLED)) { - throw GateFailure("process-audio", "target process audio stopped", - result); - } - Fail("capture-stopped", "window capture stopped"); + Fail("capture-stopped", "source capture stopped"); } if (wait == WAIT_TIMEOUT) { Fail("capture-frame-timeout", "selected window produced no frame in five seconds"); } - if (wait != WAIT_OBJECT_0 + 2) { + if (wait != frame_index) { Check(HRESULT_FROM_WIN32(GetLastError()), "capture-wait"); } @@ -1883,9 +2074,13 @@ void RunWindowCapture(const ProductArguments& arguments) { source_description.Height, static_cast(content_size.Height)); ComPtr nv12 = converter.Convert( source.Get(), content_width, content_height); - bool requested_key_frame = ConsumeKeyFrameRequest(); + ControlSignal control = ConsumeControlSignal(); + if (control.stop) { + cleanup(); + return; + } bool key_frame = encoded_frames == 0 || encoded_frames % kGopFrames == 0 || - requested_key_frame; + control.key_frame; EncodedAccessUnit access_unit = encoder.Encode(nv12.Get(), timestamp, key_frame); if (access_unit.bytes.size() > kMaxProductAccessUnitBytes) { @@ -2035,17 +2230,29 @@ int wmain(int argc, wchar_t** argv) { try { ProductArguments arguments = ParseProductArguments(argc, argv); if (arguments.mode == ProductArguments::Mode::list) { - return screener::capture::WriteWindowList(); + return screener::capture::WriteSourceList(); + } + if (arguments.mode == ProductArguments::Mode::probe) { + Runtime runtime; + WriteCapabilityProbe(); + return 0; } if (_setmode(_fileno(stdout), _O_BINARY) == -1) { Fail("stdout-binary", "could not switch stdout to binary mode"); } + if (arguments.mode == ProductArguments::Mode::preview) { + Check(screener::capture::WriteSourcePreview( + arguments.target_kind, arguments.source_id, arguments.pid, + arguments.creation_time), + "capture-preview"); + return 0; + } if (arguments.mode == ProductArguments::Mode::audio) { Check(RunAudioCapture(arguments), "process-audio-capture"); return 0; } Runtime runtime; - RunWindowCapture(arguments); + RunVideoCapture(arguments); return 0; } catch (const GateFailure& error) { std::cerr << "result=window-capture-failed\n" diff --git a/native/window-capture-helper/process_audio.cpp b/native/client/platform/windows/capture/process_audio.cpp similarity index 50% rename from native/window-capture-helper/process_audio.cpp rename to native/client/platform/windows/capture/process_audio.cpp index 85af9c9..4480b77 100644 --- a/native/window-capture-helper/process_audio.cpp +++ b/native/client/platform/windows/capture/process_audio.cpp @@ -9,8 +9,10 @@ #include #include +#include "capture_target.h" #include "process_audio.h" +#include #include #include #include @@ -26,8 +28,6 @@ using Microsoft::WRL::ClassicCom; namespace screener::capture { namespace { -constexpr size_t kMaxWindows = 100; - class ActivationHandler final : public RuntimeClass< RuntimeClassFlags, FtmBase, IActivateAudioInterfaceCompletionHandler> { public: @@ -52,110 +52,8 @@ class ActivationHandler final : public RuntimeClass< ComPtr client_; }; -struct WindowTarget { - UINT64 windowHandle; - DWORD pid; - UINT64 creationTime; - std::string title; -}; - -HRESULT ReadProcessCreationTime(HANDLE process, UINT64* creationTime) { - FILETIME created{}, exited{}, kernel{}, user{}; - if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) { - return HRESULT_FROM_WIN32(GetLastError()); - } - ULARGE_INTEGER value{}; - value.LowPart = created.dwLowDateTime; - value.HighPart = created.dwHighDateTime; - if (value.QuadPart == 0) return HRESULT_FROM_WIN32(ERROR_INVALID_DATA); - *creationTime = value.QuadPart; - return S_OK; -} - -std::string Utf8(const std::wstring& value) { - if (value.empty()) return {}; - const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), - static_cast(value.size()), nullptr, 0, nullptr, nullptr); - if (size <= 0) return {}; - std::string result(static_cast(size), '\0'); - if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), - static_cast(value.size()), result.data(), size, nullptr, nullptr) != size) { - return {}; - } - return result; -} - -std::string JsonString(const std::string& value) { - static constexpr char hex[] = "0123456789abcdef"; - std::string result = "\""; - for (const unsigned char character : value) { - switch (character) { - case '\\': result += "\\\\"; break; - case '"': result += "\\\""; break; - case '\b': result += "\\b"; break; - case '\f': result += "\\f"; break; - case '\n': result += "\\n"; break; - case '\r': result += "\\r"; break; - case '\t': result += "\\t"; break; - default: - if (character < 0x20) { - result += "\\u00"; - result += hex[character >> 4]; - result += hex[character & 0xf]; - } else { - result.push_back(static_cast(character)); - } - } - } - result += '"'; - return result; -} - -BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) { - auto* targets = reinterpret_cast*>(parameter); - if (targets->size() >= kMaxWindows) return TRUE; - if (!IsWindowVisible(window) || GetWindow(window, GW_OWNER) != nullptr) return TRUE; - const int length = GetWindowTextLengthW(window); - if (length <= 0 || length > 512) return TRUE; - DWORD pid = 0; - GetWindowThreadProcessId(window, &pid); - if (pid == 0 || pid == GetCurrentProcessId()) return TRUE; - std::wstring title(static_cast(length) + 1, L'\0'); - const int copied = GetWindowTextW(window, title.data(), length + 1); - if (copied <= 0) return TRUE; - title.resize(static_cast(copied)); - std::string utf8 = Utf8(title); - if (utf8.empty()) return TRUE; - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (process == nullptr) return TRUE; - UINT64 creationTime = 0; - const HRESULT result = ReadProcessCreationTime(process, &creationTime); - CloseHandle(process); - if (SUCCEEDED(result)) { - targets->push_back({static_cast(reinterpret_cast(window)), - pid, creationTime, std::move(utf8)}); - } - return TRUE; -} - -} // namespace - -int WriteWindowList() { - std::vector targets; - if (!EnumWindows(CollectWindow, reinterpret_cast(&targets))) return 2; - std::cout << '['; - for (size_t index = 0; index < targets.size(); ++index) { - if (index != 0) std::cout << ','; - std::cout << "{\"windowHandle\":" << targets[index].windowHandle - << ",\"pid\":" << targets[index].pid << ",\"creationTime\":" - << targets[index].creationTime << ",\"title\":" - << JsonString(targets[index].title) << '}'; - } - std::cout << ']'; - return std::cout.good() ? 0 : 2; -} - -HRESULT ActivateProcessLoopback(DWORD pid, HANDLE completed, ComPtr* client) { +HRESULT ActivateProcessLoopback(DWORD pid, HANDLE completed, + ComPtr* client) { AUDIOCLIENT_ACTIVATION_PARAMS parameters{}; parameters.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK; parameters.ProcessLoopbackParams.TargetProcessId = pid; @@ -179,59 +77,34 @@ HRESULT ActivateProcessLoopback(DWORD pid, HANDLE completed, ComPtr static_cast(std::numeric_limits::max())) { - return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); +HRESULT ActivateSystemLoopback(ComPtr* client) { + ComPtr enumerator; + HRESULT result = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, + CLSCTX_ALL, IID_PPV_ARGS(&enumerator)); + ComPtr device; + if (SUCCEEDED(result)) { + result = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); } - HWND window = reinterpret_cast(static_cast(window_handle)); - DWORD window_pid = 0; - if (!IsWindow(window) || GetWindowThreadProcessId(window, &window_pid) == 0 || - window_pid != pid) { - return HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE); - } - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (process == nullptr) return HRESULT_FROM_WIN32(GetLastError()); - UINT64 actual_creation_time = 0; - HRESULT result = ReadProcessCreationTime(process, &actual_creation_time); - CloseHandle(process); - if (SUCCEEDED(result) && actual_creation_time != expected_creation_time) { - result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + if (SUCCEEDED(result)) { + result = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(client->GetAddressOf())); } return result; } -HRESULT CaptureProcessAudio(DWORD pid, UINT64 expectedCreationTime, - HANDLE stop_event, const PCMWriter& writer) { - if (stop_event == nullptr || !writer) { +HRESULT CaptureLoopbackAudio(ComPtr client, HANDLE process, + HANDLE stop_event, const StopProbe& stop_probe, + const ReadyWriter& ready_writer, + const PCMWriter& writer) { + if (stop_event == nullptr || !stop_probe || !ready_writer || !writer) { return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); } - HRESULT com_result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); - if (FAILED(com_result)) return com_result; - HANDLE process = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); - if (process == nullptr) { - HRESULT result = HRESULT_FROM_WIN32(GetLastError()); - CoUninitialize(); - return result; - } - HANDLE completed = CreateEventW(nullptr, FALSE, FALSE, nullptr); HANDLE sampleReady = CreateEventW(nullptr, FALSE, FALSE, nullptr); - if (completed == nullptr || sampleReady == nullptr) { + if (sampleReady == nullptr) { const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); - if (completed != nullptr) CloseHandle(completed); - if (sampleReady != nullptr) CloseHandle(sampleReady); - CloseHandle(process); - CoUninitialize(); return result; } - UINT64 creationTime = 0; - HRESULT result = ReadProcessCreationTime(process, &creationTime); - if (SUCCEEDED(result) && creationTime != expectedCreationTime) { - result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA); - } - ComPtr client; - if (SUCCEEDED(result)) result = ActivateProcessLoopback(pid, completed, &client); + HRESULT result = S_OK; ComPtr capture; WAVEFORMATEX format{}; format.wFormatTag = WAVE_FORMAT_PCM; @@ -249,23 +122,41 @@ HRESULT CaptureProcessAudio(DWORD pid, UINT64 expectedCreationTime, if (SUCCEEDED(result)) result = client->GetService(IID_PPV_ARGS(&capture)); if (SUCCEEDED(result)) result = client->SetEventHandle(sampleReady); if (SUCCEEDED(result)) result = client->Start(); + if (SUCCEEDED(result)) result = ready_writer(); + if (SUCCEEDED(result)) { + std::array silence{}; + result = writer(0, silence.data(), static_cast(silence.size())); + } std::vector pending; size_t consumed = 0; UINT64 nextTimestamp = 0; - const HANDLE waits[] = {process, sampleReady, stop_event}; + const HANDLE process_waits[] = {process, sampleReady, stop_event}; + const HANDLE system_waits[] = {sampleReady, stop_event}; while (SUCCEEDED(result)) { - const DWORD wait = WaitForMultipleObjects(3, waits, FALSE, 1'000); - if (wait == WAIT_OBJECT_0) { + if (stop_probe()) { + result = S_OK; + break; + } + const DWORD wait = process != nullptr + ? WaitForMultipleObjects(3, process_waits, FALSE, + 1'000) + : WaitForMultipleObjects(2, system_waits, FALSE, + 1'000); + if (process != nullptr && wait == WAIT_OBJECT_0) { result = HRESULT_FROM_WIN32(ERROR_PROCESS_ABORTED); break; } if (wait == WAIT_TIMEOUT) continue; - if (wait == WAIT_OBJECT_0 + 2) { - result = HRESULT_FROM_WIN32(ERROR_CANCELLED); + const DWORD sample_index = process != nullptr ? WAIT_OBJECT_0 + 1 + : WAIT_OBJECT_0; + const DWORD stop_index = process != nullptr ? WAIT_OBJECT_0 + 2 + : WAIT_OBJECT_0 + 1; + if (wait == stop_index) { + result = S_OK; break; } - if (wait != WAIT_OBJECT_0 + 1) { + if (wait != sample_index) { result = HRESULT_FROM_WIN32(GetLastError()); break; } @@ -313,8 +204,68 @@ HRESULT CaptureProcessAudio(DWORD pid, UINT64 expectedCreationTime, } if (client) client->Stop(); CloseHandle(sampleReady); + return result; +} + +} // namespace + +bool ProcessAudioAvailable() { + HANDLE completed = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (completed == nullptr) return false; + ComPtr client; + const HRESULT result = + ActivateProcessLoopback(GetCurrentProcessId(), completed, &client); CloseHandle(completed); - CloseHandle(process); + return SUCCEEDED(result) && client != nullptr; +} + +bool SystemAudioAvailable() { + ComPtr client; + return SUCCEEDED(ActivateSystemLoopback(&client)) && client != nullptr; +} + +HRESULT CaptureProcessAudio(DWORD pid, UINT64 expectedCreationTime, + HANDLE stop_event, const StopProbe& stop_probe, + const ReadyWriter& ready_writer, + const PCMWriter& writer) { + HRESULT com_result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(com_result)) return com_result; + HRESULT result = ValidateProcessTarget(pid, expectedCreationTime); + HANDLE process = nullptr; + HANDLE completed = nullptr; + ComPtr client; + if (SUCCEEDED(result)) { + process = OpenProcess(SYNCHRONIZE, FALSE, pid); + if (process == nullptr) result = HRESULT_FROM_WIN32(GetLastError()); + } + if (SUCCEEDED(result)) { + completed = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (completed == nullptr) result = HRESULT_FROM_WIN32(GetLastError()); + } + if (SUCCEEDED(result)) { + result = ActivateProcessLoopback(pid, completed, &client); + } + if (SUCCEEDED(result)) { + result = CaptureLoopbackAudio(client, process, stop_event, stop_probe, + ready_writer, writer); + } + if (completed != nullptr) CloseHandle(completed); + if (process != nullptr) CloseHandle(process); + CoUninitialize(); + return result; +} + +HRESULT CaptureSystemAudio(HANDLE stop_event, const StopProbe& stop_probe, + const ReadyWriter& ready_writer, + const PCMWriter& writer) { + HRESULT com_result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(com_result)) return com_result; + ComPtr client; + HRESULT result = ActivateSystemLoopback(&client); + if (SUCCEEDED(result)) { + result = CaptureLoopbackAudio(client, nullptr, stop_event, stop_probe, + ready_writer, writer); + } CoUninitialize(); return result; } diff --git a/native/window-capture-helper/process_audio.h b/native/client/platform/windows/capture/process_audio.h similarity index 54% rename from native/window-capture-helper/process_audio.h rename to native/client/platform/windows/capture/process_audio.h index d2a95d4..bc5a115 100644 --- a/native/window-capture-helper/process_audio.h +++ b/native/client/platform/windows/capture/process_audio.h @@ -17,11 +17,17 @@ constexpr UINT64 kAudioChunkDuration100ns = 200'000; using PCMWriter = std::function; +using ReadyWriter = std::function; +using StopProbe = std::function; -int WriteWindowList(); -HRESULT ValidateWindowTarget(UINT64 window_handle, DWORD pid, - UINT64 expected_creation_time); +bool ProcessAudioAvailable(); +bool SystemAudioAvailable(); HRESULT CaptureProcessAudio(DWORD pid, UINT64 expected_creation_time, - HANDLE stop_event, const PCMWriter& writer); + HANDLE stop_event, const StopProbe& stop_probe, + const ReadyWriter& ready_writer, + const PCMWriter& writer); +HRESULT CaptureSystemAudio(HANDLE stop_event, const StopProbe& stop_probe, + const ReadyWriter& ready_writer, + const PCMWriter& writer); } // namespace screener::capture diff --git a/native/fixtures/mf-h264-hardware/README.md b/native/fixtures/mf-h264-hardware/README.md index c138b3b..cb24617 100644 --- a/native/fixtures/mf-h264-hardware/README.md +++ b/native/fixtures/mf-h264-hardware/README.md @@ -1,7 +1,8 @@ # Media Foundation H.264 Hardware Fixture This is an offline Windows-only decision fixture. Its build compiles the same -Media Foundation encoder source used by `native/window-capture-helper`, under a +Media Foundation encoder source used by Screener Client's Windows capture +process, under a fixture-only macro for synthetic input and telemetry. The generated fixture is not linked into the sender and does not contact a room, signaling service, Viewer, or production endpoint. @@ -16,6 +17,8 @@ fallback. Exit code zero requires all of these checks in one 360-frame run: readback, plus Baseline media-type request/readback and output-order no-reordering checks; - 360 GPU-surface NV12 inputs and 360 ordered outputs; +- one live `3 Mbps -> 1.5 Mbps -> 3 Mbps` rate change whose three equal phases + lower and then restore actual encoded bytes without recreating the MFT; - Annex-B SPS/PPS/IDR at frames 0, 60, 120, 180, 240, and 300; - an SPS `profile-level-id` exactly present in the fixture's default Pion mode-1 fmtp set; and diff --git a/native/fixtures/mf-h264-hardware/build.ps1 b/native/fixtures/mf-h264-hardware/build.ps1 index 9bb0864..d0a0da0 100644 --- a/native/fixtures/mf-h264-hardware/build.ps1 +++ b/native/fixtures/mf-h264-hardware/build.ps1 @@ -28,7 +28,7 @@ if ([string]::IsNullOrWhiteSpace($installationPath)) { } $developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' -$sourcePath = Join-Path $fixtureDirectory '..\..\window-capture-helper\main.cpp' +$sourcePath = Join-Path $fixtureDirectory '..\..\client\platform\windows\capture\main.cpp' $executablePath = Join-Path $outputPath 'screener-mf-h264-fixture.exe' $objectPath = Join-Path $outputPath 'main.obj' diff --git a/native/sender/README.md b/native/sender/README.md deleted file mode 100644 index 33bd3b4..0000000 --- a/native/sender/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Screener Native Sender Research - -This directory retains Native sender and shared-encode evidence. It is not a -current product or distribution surface and its private wire is not compatible -with the current Browser/server contract. Do not package or distribute it. - -[ADR-0006](../../docs/adr/0006-fixed-high-native-sender-canary.md) owns the stop -line; [Native sender research](../../docs/research/native-sender.md) owns the -retained evidence and the gates required before this work can resume. - -The retained Go checks remain available from `native/sender`: - -```sh -go test ./... -go vet ./... -``` diff --git a/native/sender/cmd/screener-one-viewer-gate/main.go b/native/sender/cmd/screener-one-viewer-gate/main.go deleted file mode 100644 index 7c72f34..0000000 --- a/native/sender/cmd/screener-one-viewer-gate/main.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/app" -) - -func main() { - sender, err := app.New() - if err != nil { - fmt.Fprintln(os.Stderr, "sender initialization failed") - os.Exit(1) - } - defer sender.Close() - - launchURL, err := sender.Start() - if err != nil { - fmt.Fprintln(os.Stderr, "sender startup failed") - os.Exit(1) - } - if err = json.NewEncoder(os.Stdout).Encode(map[string]string{"launchUrl": launchURL}); err != nil { - fmt.Fprintln(os.Stderr, "sender handoff failed") - os.Exit(1) - } - - _, _ = bufio.NewReader(os.Stdin).ReadByte() -} diff --git a/native/sender/cmd/screener-sender/main.go b/native/sender/cmd/screener-sender/main.go deleted file mode 100644 index aa29b80..0000000 --- a/native/sender/cmd/screener-sender/main.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "os/signal" - "runtime" - "syscall" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/app" -) - -var buildRevision = "development" - -func main() { - if len(os.Args) == 2 { - switch os.Args[1] { - case "--version": - fmt.Printf("Screener sender %s\n", buildRevision) - return - case "--help", "-h": - fmt.Println("Usage: screener-sender.exe [--version|--help]") - return - } - } - if len(os.Args) > 1 { - fmt.Fprintln(os.Stderr, "Usage: screener-sender.exe [--version|--help]") - os.Exit(2) - } - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, "Screener sender stopped:", err) - os.Exit(1) - } -} - -func run() error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - sender, err := app.New() - if err != nil { - return err - } - defer sender.Close() - launchURL, err := sender.Start() - if err != nil { - return err - } - - if err = openBrowser(launchURL); err != nil { - return fmt.Errorf("open the local sender UI: %w", err) - } - fmt.Println("Screener sender is running. Close this window to stop sharing.") - <-ctx.Done() - return nil -} - -func openBrowser(url string) error { - var command *exec.Cmd - switch runtime.GOOS { - case "windows": - command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - case "darwin": - command = exec.Command("open", url) - default: - command = exec.Command("xdg-open", url) - } - return command.Start() -} diff --git a/native/sender/internal/app/security.go b/native/sender/internal/app/security.go deleted file mode 100644 index 7afea92..0000000 --- a/native/sender/internal/app/security.go +++ /dev/null @@ -1,59 +0,0 @@ -package app - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" - "errors" - "net" - "net/http" - "net/url" - "strings" -) - -const processTokenBytes = 32 - -func newOpaqueToken() (string, error) { - buffer := make([]byte, processTokenBytes) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buffer), nil -} - -func tokenMatches(actual, expected string) bool { - if len(actual) != len(expected) { - return false - } - return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 -} - -func normalizeRemoteBase(raw string) (*url.URL, error) { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return nil, errors.New("server URL is invalid") - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || - (parsed.Path != "" && parsed.Path != "/") || parsed.Hostname() == "" { - return nil, errors.New("server URL must contain only an origin") - } - if parsed.Scheme != "https" { - if parsed.Scheme != "http" || !isLoopbackHost(parsed.Hostname()) { - return nil, errors.New("server URL must use HTTPS (HTTP is allowed only for loopback development)") - } - } - parsed.Path = "" - return parsed, nil -} - -func isLoopbackHost(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -func exactRequest(request *http.Request, expectedHost, expectedOrigin string) bool { - return request.Host == expectedHost && request.Header.Get("Origin") == expectedOrigin -} diff --git a/native/sender/internal/app/security_test.go b/native/sender/internal/app/security_test.go deleted file mode 100644 index 7c60e30..0000000 --- a/native/sender/internal/app/security_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package app - -import ( - "net/http/httptest" - "testing" -) - -func TestNormalizeRemoteBaseAllowsHTTPSAndLoopbackHTTPOnly(t *testing.T) { - valid := []string{ - "https://share.example.test", - "http://127.0.0.1:3000", - "http://[::1]:3000", - "http://localhost:3000", - } - for _, value := range valid { - if _, err := normalizeRemoteBase(value); err != nil { - t.Fatalf("valid remote base %q: %v", value, err) - } - } - invalid := []string{ - "http://share.example.test", - "https://user@example.test", - "https://example.test/path", - "https://example.test/?query=1", - "https://example.test/#fragment", - } - for _, value := range invalid { - if _, err := normalizeRemoteBase(value); err == nil { - t.Fatalf("invalid remote base %q was accepted", value) - } - } -} - -func TestExactRequestRequiresBothHostAndOrigin(t *testing.T) { - request := httptest.NewRequest("GET", "http://127.0.0.1:9000/media", nil) - request.Host = "127.0.0.1:9000" - request.Header.Set("Origin", "http://127.0.0.1:9000") - if !exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { - t.Fatal("exact loopback request was rejected") - } - request.Header.Set("Origin", "http://localhost:9000") - if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { - t.Fatal("alternate loopback origin was accepted") - } - request.Header.Set("Origin", "http://127.0.0.1:9000") - request.Host = "localhost:9000" - if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") { - t.Fatal("alternate Host was accepted") - } -} - -func TestProcessTokenComparisonIsExact(t *testing.T) { - token := "0123456789abcdef0123456789abcdef0123456789abcdef" - if !tokenMatches(token, token) { - t.Fatal("equal token did not match") - } - if tokenMatches(token+"x", token) || tokenMatches(token[:len(token)-1]+"x", token) { - t.Fatal("different token matched") - } -} diff --git a/native/sender/internal/app/server.go b/native/sender/internal/app/server.go deleted file mode 100644 index f179ff0..0000000 --- a/native/sender/internal/app/server.go +++ /dev/null @@ -1,731 +0,0 @@ -package app - -import ( - "bytes" - "context" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "strings" - "sync" - "time" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/TNTcraftHIM/Screener/native/sender/internal/remote" - "github.com/TNTcraftHIM/Screener/native/sender/internal/windowcapture" - "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" -) - -type videoSource string - -const ( - videoSourceBrowser videoSource = "browser" - videoSourceNativeWindow videoSource = "native-window-h264" -) - -const ( - processTokenHeader = "X-Screener-Process-Token" - maxLocalJSONBytes = 8 << 10 - localWriteTimeout = 2 * time.Second - mediaAttachTimeout = 10 * time.Second - mediaConfigTimeout = 5 * time.Second -) - -//go:embed ui/index.html ui/app.js ui/hardware-status.js ui/styles.css -var uiFiles embed.FS - -type App struct { - ctx context.Context - cancel context.CancelFunc - token string - attachTimeout time.Duration - - mu sync.Mutex - writeMu sync.Mutex - server *http.Server - listener net.Listener - expectedHost string - origin string - starting bool - generation uint64 - startCancel context.CancelFunc - startFailure string - session *remote.Session - sessionCancel context.CancelFunc - room remote.Room - codec media.Codec - mediaConn *websocket.Conn - mediaClaimed bool - mediaClaimID uint64 - nextClaimID uint64 - attachTimer *time.Timer - configTimeout time.Duration - activeViewers int - waitingViewers int - captureProvider windowcapture.Provider - windowTargets map[string]windowcapture.Target - windowTarget *windowcapture.Target - videoSource videoSource - keyFrameRequests chan struct{} -} - -type startRequest struct { - ServerURL string `json:"serverUrl"` - Password string `json:"password"` - Codec string `json:"codec,omitempty"` - VideoSource string `json:"videoSource,omitempty"` - WindowTargetID string `json:"windowTargetId,omitempty"` -} - -type encoderConfig struct { - Kind string `json:"kind"` - Codec string `json:"codec"` - VideoSource string `json:"videoSource"` - Width int `json:"width"` - Height int `json:"height"` - FPS int `json:"fps"` - Bitrate int `json:"bitrate"` - EncoderInstances int `json:"encoderInstances"` - Audio bool `json:"audio"` -} - -type localWindowTarget struct { - ID string `json:"id"` - Title string `json:"title"` -} - -func New() (*App, error) { - token, err := newOpaqueToken() - if err != nil { - return nil, fmt.Errorf("create process token: %w", err) - } - ctx, cancel := context.WithCancel(context.Background()) - return &App{ - ctx: ctx, cancel: cancel, token: token, - attachTimeout: mediaAttachTimeout, - configTimeout: mediaConfigTimeout, - codec: media.CodecVP8, - videoSource: videoSourceBrowser, - captureProvider: windowcapture.NewProvider(), - windowTargets: make(map[string]windowcapture.Target), - }, nil -} - -func (app *App) Start() (string, error) { - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - return "", fmt.Errorf("listen on loopback: %w", err) - } - address, ok := listener.Addr().(*net.TCPAddr) - if !ok { - listener.Close() - return "", errors.New("loopback listener returned an unexpected address") - } - app.listener = listener - app.expectedHost = fmt.Sprintf("127.0.0.1:%d", address.Port) - app.origin = "http://" + app.expectedHost - app.server = &http.Server{ - Handler: app, - ReadHeaderTimeout: 5 * time.Second, - IdleTimeout: 30 * time.Second, - } - go func() { - _ = app.server.Serve(listener) - }() - return app.origin + "/#token=" + app.token, nil -} - -func (app *App) Close() { - app.stopActive() - app.cancel() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if app.server != nil { - _ = app.server.Shutdown(ctx) - } -} - -func (app *App) ServeHTTP(response http.ResponseWriter, request *http.Request) { - setSecurityHeaders(response) - if request.Host != app.expectedHost { - http.NotFound(response, request) - return - } - switch request.URL.Path { - case "/": - app.serveAsset(response, request, "ui/index.html", "text/html; charset=utf-8") - case "/app.js": - app.serveAsset(response, request, "ui/app.js", "text/javascript; charset=utf-8") - case "/hardware-status.js": - app.serveAsset(response, request, "ui/hardware-status.js", "text/javascript; charset=utf-8") - case "/styles.css": - app.serveAsset(response, request, "ui/styles.css", "text/css; charset=utf-8") - case "/api/start": - app.handleStart(response, request) - case "/api/stop": - app.handleStop(response, request) - case "/api/window-targets": - app.handleWindowTargets(response, request) - case "/media": - app.handleMedia(response, request) - default: - http.NotFound(response, request) - } -} - -func (app *App) serveAsset(response http.ResponseWriter, request *http.Request, name, contentType string) { - if request.Method != http.MethodGet { - response.Header().Set("Allow", http.MethodGet) - http.Error(response, "method not allowed", http.StatusMethodNotAllowed) - return - } - contents, err := uiFiles.ReadFile(name) - if err != nil { - http.Error(response, "asset unavailable", http.StatusInternalServerError) - return - } - response.Header().Set("Content-Type", contentType) - response.Header().Set("Content-Length", fmt.Sprint(len(contents))) - response.WriteHeader(http.StatusOK) - _, _ = response.Write(contents) -} - -func (app *App) handleStart(response http.ResponseWriter, request *http.Request) { - if !app.authorizeAPI(response, request, http.MethodPost) { - return - } - var input startRequest - if err := decodeLocalJSON(request, &input); err != nil { - writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - codec, validCodec := media.ParseCodec(strings.TrimSpace(input.Codec)) - if input.Codec != "" && !validCodec { - writeJSON(response, http.StatusBadRequest, map[string]string{"error": "unsupported native codec"}) - return - } - source, validSource := parseVideoSource(strings.TrimSpace(input.VideoSource)) - if !validSource { - writeJSON(response, http.StatusBadRequest, map[string]string{"error": "unsupported video source"}) - return - } - if source == videoSourceNativeWindow && codec != media.CodecH264 { - writeJSON(response, http.StatusBadRequest, map[string]string{"error": "native window capture requires H.264"}) - return - } - baseURL, err := normalizeRemoteBase(input.ServerURL) - if err != nil { - writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - app.mu.Lock() - var windowTarget *windowcapture.Target - if input.WindowTargetID != "" { - selected, ok := app.windowTargets[input.WindowTargetID] - if !ok { - app.mu.Unlock() - writeJSON(response, http.StatusBadRequest, map[string]string{"error": "selected window target is no longer available"}) - return - } - windowTarget = &selected - } - if source == videoSourceNativeWindow && windowTarget == nil { - app.mu.Unlock() - writeJSON(response, http.StatusBadRequest, map[string]string{"error": "native window capture requires a selected window"}) - return - } - if app.starting || app.session != nil { - app.mu.Unlock() - writeJSON(response, http.StatusConflict, map[string]string{"error": "A sharing session is already active"}) - return - } - app.starting = true - app.generation++ - generation := app.generation - app.startFailure = "" - startContext, startCancel := context.WithCancel(app.ctx) - app.startCancel = startCancel - app.mu.Unlock() - - session, room, err := remote.Start(startContext, remote.StartOptions{ - BaseURL: baseURL, - SiteAccessPassword: strings.TrimSpace(input.Password), - Codec: codec, - EnableAudio: windowTarget != nil, - OnEvent: func(event remote.Event) { app.emit(generation, event) }, - }) - app.mu.Lock() - app.starting = false - app.startCancel = nil - startFailure := app.startFailure - app.startFailure = "" - startCanceled := startContext.Err() != nil - currentGeneration := app.generation == generation - if err == nil && currentGeneration && !startCanceled && startFailure == "" { - app.session = session - app.sessionCancel = startCancel - app.room = room - app.codec = codec - app.videoSource = source - app.windowTarget = windowTarget - app.attachTimer = time.AfterFunc(app.attachTimeout, func() { - app.mu.Lock() - expired := app.generation == generation && app.session == session && app.mediaConn == nil - app.mu.Unlock() - if expired { - session.Fail(errors.New("local media did not attach before the bounded deadline")) - } - }) - } - app.mu.Unlock() - if err == nil && (!currentGeneration || startCanceled) { - err = errors.New("sharing start was canceled") - } - if err == nil && startFailure != "" { - err = errors.New(startFailure) - } - if err != nil { - startCancel() - if session != nil { - session.Close() - } - writeJSON(response, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - writeJSON(response, http.StatusCreated, room) -} - -func (app *App) handleStop(response http.ResponseWriter, request *http.Request) { - if !app.authorizeAPI(response, request, http.MethodPost) { - return - } - app.stopActive() - response.WriteHeader(http.StatusNoContent) -} - -func (app *App) handleWindowTargets(response http.ResponseWriter, request *http.Request) { - if !app.authorizeAPI(response, request, http.MethodPost) { - return - } - targets, err := app.captureProvider.List(request.Context()) - if err != nil { - writeJSON(response, http.StatusOK, map[string]any{ - "supported": false, "targets": []localWindowTarget{}, - "reason": "Windows 11 window capture helper is unavailable", - }) - return - } - localTargets := make([]localWindowTarget, 0, len(targets)) - resolved := make(map[string]windowcapture.Target, len(targets)) - for _, target := range targets { - id, tokenErr := newOpaqueToken() - if tokenErr != nil { - writeJSON(response, http.StatusInternalServerError, map[string]string{"error": "create local window target identity failed"}) - return - } - resolved[id] = target - localTargets = append(localTargets, localWindowTarget{ID: id, Title: target.Title}) - } - app.mu.Lock() - app.windowTargets = resolved - app.mu.Unlock() - writeJSON(response, http.StatusOK, map[string]any{"supported": true, "targets": localTargets}) -} - -func (app *App) handleMedia(response http.ResponseWriter, request *http.Request) { - if !exactRequest(request, app.expectedHost, app.origin) || request.Method != http.MethodGet { - http.Error(response, "forbidden", http.StatusForbidden) - return - } - protocol := "screener.token." + app.token - app.mu.Lock() - if app.session == nil || app.mediaClaimed { - app.mu.Unlock() - http.Error(response, "media session unavailable", http.StatusConflict) - return - } - session := app.session - generation := app.generation - app.nextClaimID++ - claimID := app.nextClaimID - app.mediaClaimed = true - app.mediaClaimID = claimID - app.mu.Unlock() - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{ - Subprotocols: []string{protocol}, - }) - if err != nil { - app.releaseMediaClaim(generation, claimID, nil) - return - } - if connection.Subprotocol() != protocol { - connection.CloseNow() - app.releaseMediaClaim(generation, claimID, nil) - return - } - connection.SetReadLimit(media.MaxMediaPayload + media.EnvelopeHeaderBytes) - app.mu.Lock() - if app.generation != generation || app.session != session || app.mediaClaimID != claimID { - app.mu.Unlock() - connection.CloseNow() - app.releaseMediaClaim(generation, claimID, nil) - return - } - app.mediaConn = connection - if app.attachTimer != nil { - app.attachTimer.Stop() - app.attachTimer = nil - } - room := app.room - activeViewers := app.activeViewers - waitingViewers := app.waitingViewers - app.mu.Unlock() - defer func() { - app.releaseMediaClaim(generation, claimID, connection) - app.stopActiveGeneration(generation) - }() - if session == nil { - return - } - if err = app.writeLocal(connection, map[string]any{ - "kind": "ready", - "roomId": room.ID, - "inviteUrl": room.InviteURL, - "activeViewers": activeViewers, - "waitingViewers": waitingViewers, - }); err != nil { - session.Fail(fmt.Errorf("write local media readiness: %w", err)) - return - } - - configContext, cancelConfig := context.WithTimeout(app.ctx, app.configTimeout) - messageType, payload, err := connection.Read(configContext) - cancelConfig() - if err != nil { - session.Fail(fmt.Errorf("read local encoder configuration: %w", err)) - return - } - if messageType != websocket.MessageText { - session.Fail(errors.New("local encoder configuration was not text")) - return - } - var config encoderConfig - app.mu.Lock() - source := app.videoSource - target := app.windowTarget - app.mu.Unlock() - if err = decodeLocalPayload(payload, &config); err != nil || - !validEncoderConfigForSource(config, session.Codec(), source) || config.Audio != session.AudioEnabled() { - session.Fail(errors.New("local encoder configuration is invalid")) - _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoder config") - return - } - if err = app.writeLocal(connection, map[string]string{"kind": "config-accepted"}); err != nil { - session.Fail(fmt.Errorf("acknowledge local encoder configuration: %w", err)) - return - } - captureContext, cancelCapture := context.WithCancel(app.ctx) - captureDone := make(chan struct{}) - keyFrameRequests := make(chan struct{}, 1) - if source == videoSourceNativeWindow { - app.mu.Lock() - if app.generation == generation && app.session == session { - app.keyFrameRequests = keyFrameRequests - } - app.mu.Unlock() - } - writePCM := func(chunk windowcapture.PCMChunk) error { - envelope, encodeErr := media.EncodePacket(media.Packet{ - Kind: media.KindPCM, Timestamp100ns: chunk.Timestamp100ns, - Duration100ns: chunk.Duration100ns, Data: chunk.Data, - }) - if encodeErr != nil { - return encodeErr - } - return app.writeLocalBinary(connection, envelope) - } - if target != nil { - _ = app.writeLocal(connection, map[string]string{"kind": "audio-state", "state": "starting"}) - go func(selected windowcapture.Target) { - defer close(captureDone) - var captureErr error - if source == videoSourceNativeWindow { - captureErr = app.captureProvider.CaptureWindow( - captureContext, selected, keyFrameRequests, writePCM, - func(accessUnit windowcapture.H264AccessUnit) error { - packet := media.Packet{ - Kind: media.KindVideo, KeyFrame: accessUnit.KeyFrame, - Timestamp100ns: accessUnit.Timestamp100ns, - Duration100ns: accessUnit.Duration100ns, Data: accessUnit.Data, - } - if writeErr := session.WriteMedia(packet); writeErr != nil { - return writeErr - } - envelope, encodeErr := media.EncodePacket(packet) - if encodeErr != nil { - return encodeErr - } - return app.writeLocalBinary(connection, envelope) - }, - func(status windowcapture.Status) error { - return app.writeLocal(connection, map[string]any{ - "kind": "native-video-state", "status": status, - }) - }, - ) - } else { - captureErr = app.captureProvider.CaptureAudio(captureContext, selected, writePCM) - } - if captureErr != nil && captureContext.Err() == nil { - if source == videoSourceNativeWindow { - session.Fail(errors.New("native window capture stopped")) - } else { - _ = app.writeLocal(connection, map[string]string{ - "kind": "audio-state", "state": "unavailable", - "message": "Target audio stopped; system audio was not substituted", - }) - } - } - }(*target) - } else { - close(captureDone) - } - defer func() { - app.mu.Lock() - if app.generation == generation && app.keyFrameRequests == keyFrameRequests { - app.keyFrameRequests = nil - } - app.mu.Unlock() - cancelCapture() - <-captureDone - }() - for { - messageType, payload, err = connection.Read(app.ctx) - if err != nil { - if failure := postConfigMediaReadFailure(app.ctx, err); failure != nil { - session.Fail(failure) - } - return - } - if messageType != websocket.MessageBinary { - session.Fail(errors.New("local media bridge received a non-binary frame")) - _ = connection.Close(websocket.StatusUnsupportedData, "binary frames required") - return - } - packet, decodeErr := media.DecodePacket(payload) - if decodeErr != nil { - session.Fail(fmt.Errorf("decode local encoded media: %w", decodeErr)) - _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoded media") - return - } - validKind := packet.Kind == media.KindOpus || - (source == videoSourceBrowser && packet.Kind == media.KindVideo) - if !validKind { - session.Fail(errors.New("local media bridge received an invalid encoded media kind")) - _ = connection.Close(websocket.StatusPolicyViolation, "encoded media required") - return - } - if err = session.WriteMedia(packet); err != nil { - return - } - } -} - -func postConfigMediaReadFailure(appContext context.Context, err error) error { - if err == nil || appContext.Err() != nil { - return nil - } - switch websocket.CloseStatus(err) { - case websocket.StatusNormalClosure, websocket.StatusGoingAway: - return nil - default: - return errors.New("local media bridge read failed") - } -} - -func (app *App) authorizeAPI(response http.ResponseWriter, request *http.Request, method string) bool { - if !exactRequest(request, app.expectedHost, app.origin) || - !tokenMatches(request.Header.Get(processTokenHeader), app.token) { - http.Error(response, "not found", http.StatusNotFound) - return false - } - if request.Method != method { - response.Header().Set("Allow", method) - http.Error(response, "method not allowed", http.StatusMethodNotAllowed) - return false - } - return true -} - -func (app *App) emit(generation uint64, event remote.Event) { - app.mu.Lock() - if app.generation != generation { - app.mu.Unlock() - return - } - if event.Kind == "viewer-count" { - app.activeViewers = event.ActiveViewers - app.waitingViewers = event.WaitingViewers - } - if event.Kind == "fatal" && app.starting && app.session == nil { - app.startFailure = event.Message - } - connection := app.mediaConn - session := app.session - keyFrameRequests := app.keyFrameRequests - app.mu.Unlock() - if event.Kind == "request-keyframe" && keyFrameRequests != nil { - select { - case keyFrameRequests <- struct{}{}: - default: - } - } - if connection != nil { - if err := app.writeLocal(connection, event); err != nil && event.Kind != "fatal" && session != nil { - go session.Fail(fmt.Errorf("write local sender event: %w", err)) - } - } - if event.Kind == "fatal" { - go app.stopActiveGeneration(generation) - } -} - -func (app *App) writeLocal(connection *websocket.Conn, value any) error { - app.writeMu.Lock() - defer app.writeMu.Unlock() - ctx, cancel := context.WithTimeout(app.ctx, localWriteTimeout) - defer cancel() - return wsjson.Write(ctx, connection, value) -} - -func (app *App) writeLocalBinary(connection *websocket.Conn, payload []byte) error { - app.writeMu.Lock() - defer app.writeMu.Unlock() - ctx, cancel := context.WithTimeout(app.ctx, localWriteTimeout) - defer cancel() - return connection.Write(ctx, websocket.MessageBinary, payload) -} - -func (app *App) releaseMediaClaim(generation, claimID uint64, connection *websocket.Conn) { - app.mu.Lock() - defer app.mu.Unlock() - if app.generation == generation && app.mediaClaimed && app.mediaClaimID == claimID && - (connection == nil || app.mediaConn == connection) { - app.mediaConn = nil - app.mediaClaimed = false - app.mediaClaimID = 0 - } -} - -func (app *App) stopActive() { - app.stopActiveGeneration(0) -} - -func (app *App) stopActiveGeneration(expectedGeneration uint64) { - app.mu.Lock() - if expectedGeneration != 0 && app.generation != expectedGeneration { - app.mu.Unlock() - return - } - app.generation++ - startCancel := app.startCancel - app.startCancel = nil - app.startFailure = "" - session := app.session - app.session = nil - sessionCancel := app.sessionCancel - app.sessionCancel = nil - connection := app.mediaConn - app.mediaConn = nil - app.mediaClaimed = false - app.mediaClaimID = 0 - if app.attachTimer != nil { - app.attachTimer.Stop() - app.attachTimer = nil - } - app.room = remote.Room{} - app.codec = media.CodecVP8 - app.videoSource = videoSourceBrowser - app.windowTarget = nil - app.keyFrameRequests = nil - app.activeViewers = 0 - app.waitingViewers = 0 - app.mu.Unlock() - if startCancel != nil { - startCancel() - } - if session != nil { - session.Close() - } - if sessionCancel != nil { - sessionCancel() - } - if connection != nil { - _ = connection.Close(websocket.StatusNormalClosure, "sharing stopped") - } -} - -func validEncoderConfig(config encoderConfig) bool { - return validEncoderConfigForSource(config, media.CodecVP8, videoSourceBrowser) -} - -func validEncoderConfigForCodec(config encoderConfig, expected media.Codec) bool { - return validEncoderConfigForSource(config, expected, videoSourceBrowser) -} - -func validEncoderConfigForSource(config encoderConfig, expected media.Codec, source videoSource) bool { - return config.Kind == "config" && config.Codec == string(expected) && - config.VideoSource == string(source) && - config.Width == 1280 && config.Height == 720 && config.FPS == 30 && - config.Bitrate == 3_000_000 && config.EncoderInstances == 1 -} - -func parseVideoSource(value string) (videoSource, bool) { - if value == "" || value == string(videoSourceBrowser) { - return videoSourceBrowser, true - } - if value == string(videoSourceNativeWindow) { - return videoSourceNativeWindow, true - } - return videoSourceBrowser, false -} - -func decodeLocalJSON(request *http.Request, target any) error { - payload, err := io.ReadAll(io.LimitReader(request.Body, maxLocalJSONBytes+1)) - if err != nil || len(payload) > maxLocalJSONBytes { - return errors.New("request is too large") - } - return decodeLocalPayload(payload, target) -} - -func decodeLocalPayload(payload []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return errors.New("payload is not valid JSON") - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return errors.New("payload contains multiple JSON values") - } - return nil -} - -func writeJSON(response http.ResponseWriter, status int, value any) { - payload, _ := json.Marshal(value) - response.Header().Set("Content-Type", "application/json; charset=utf-8") - response.WriteHeader(status) - _, _ = response.Write(payload) -} - -func setSecurityHeaders(response http.ResponseWriter) { - response.Header().Set("Cache-Control", "no-store") - response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") - response.Header().Set("Referrer-Policy", "no-referrer") - response.Header().Set("X-Content-Type-Options", "nosniff") - response.Header().Set("X-Frame-Options", "DENY") -} diff --git a/native/sender/internal/app/server_test.go b/native/sender/internal/app/server_test.go deleted file mode 100644 index e016275..0000000 --- a/native/sender/internal/app/server_test.go +++ /dev/null @@ -1,725 +0,0 @@ -package app - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/TNTcraftHIM/Screener/native/sender/internal/remote" - "github.com/TNTcraftHIM/Screener/native/sender/internal/windowcapture" - "github.com/coder/websocket" -) - -func TestEncoderConfigUsesTheFixedHighContract(t *testing.T) { - config := encoderConfig{ - Kind: "config", Codec: "vp8", VideoSource: "browser", Width: 1280, Height: 720, - FPS: 30, Bitrate: 3_000_000, EncoderInstances: 1, - } - if !validEncoderConfig(config) { - t.Fatal("fixed 720p30/3 Mbps HIGH config was rejected") - } - config.Bitrate = 4_000_000 - if validEncoderConfig(config) { - t.Fatal("unowned 4 Mbps config was accepted") - } -} - -func TestEncoderConfigAcceptsExplicitH264OnlyForAnH264Session(t *testing.T) { - config := encoderConfig{ - Kind: "config", Codec: "h264", VideoSource: "browser", Width: 1280, Height: 720, - FPS: 30, Bitrate: 3_000_000, EncoderInstances: 1, - } - if !validEncoderConfigForCodec(config, media.CodecH264) { - t.Fatal("explicit H.264 config was rejected") - } - if validEncoderConfigForCodec(config, media.CodecVP8) { - t.Fatal("H.264 config crossed the VP8 session boundary") - } -} - -func TestNativeWindowConfigRequiresExactH264Source(t *testing.T) { - config := encoderConfig{ - Kind: "config", Codec: "h264", VideoSource: "native-window-h264", - Width: 1280, Height: 720, FPS: 30, Bitrate: 3_000_000, EncoderInstances: 1, - } - if !validEncoderConfigForSource(config, media.CodecH264, videoSourceNativeWindow) { - t.Fatal("native window H.264 config was rejected") - } - if validEncoderConfigForSource(config, media.CodecH264, videoSourceBrowser) || - validEncoderConfigForSource(config, media.CodecVP8, videoSourceNativeWindow) { - t.Fatal("native window config crossed a source or codec boundary") - } -} - -func TestDecodeLocalPayloadIsStrictAndSingleValued(t *testing.T) { - valid := []byte(`{"kind":"config","codec":"vp8","videoSource":"browser","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1}`) - var config encoderConfig - if err := decodeLocalPayload(valid, &config); err != nil { - t.Fatal(err) - } - invalid := [][]byte{ - []byte(`{"kind":"config","codec":"vp8","videoSource":"browser","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1,"extra":true}`), - append(append([]byte(nil), valid...), valid...), - } - for _, payload := range invalid { - if err := decodeLocalPayload(payload, &encoderConfig{}); err == nil { - t.Fatalf("invalid local config was accepted: %s", payload) - } - } -} - -func TestWindowTargetListingKeepsBoundIdentityBehindAnOpaqueLocalID(t *testing.T) { - application, err := New() - if err != nil { - t.Fatal(err) - } - application.captureProvider = fakeCaptureProvider{targets: []windowcapture.Target{{ - WindowHandle: 123_456, PID: 424_242, CreationTime: 987_654_321, Title: "Game Window", - }}} - if _, err = application.Start(); err != nil { - t.Fatal(err) - } - defer application.Close() - - request, err := http.NewRequest(http.MethodPost, application.origin+"/api/window-targets", nil) - if err != nil { - t.Fatal(err) - } - request.Header.Set("Origin", application.origin) - request.Header.Set(processTokenHeader, application.token) - response, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - var result struct { - Supported bool `json:"supported"` - Targets []localWindowTarget `json:"targets"` - } - if json.NewDecoder(response.Body).Decode(&result) != nil || !result.Supported || len(result.Targets) != 1 { - t.Fatalf("window target response = %+v", result) - } - if result.Targets[0].ID == "" || result.Targets[0].Title != "Game Window" || - strings.Contains(result.Targets[0].ID, "424242") || strings.Contains(result.Targets[0].ID, "987654321") { - t.Fatalf("window target was not locally anonymized: %+v", result.Targets[0]) - } - application.mu.Lock() - resolved := application.windowTargets[result.Targets[0].ID] - application.mu.Unlock() - if resolved.WindowHandle != 123_456 || resolved.PID != 424_242 || resolved.CreationTime != 987_654_321 { - t.Fatal("opaque target did not resolve to its bound identity in process memory") - } -} - -func TestStaleSessionGenerationCannotMutateOrStopTheCurrentSession(t *testing.T) { - application := &App{generation: 7, activeViewers: 1, waitingViewers: 2} - application.emit(6, remote.Event{Kind: "viewer-count", ActiveViewers: 9, WaitingViewers: 9}) - application.emit(6, remote.Event{Kind: "fatal", Message: "stale failure"}) - application.stopActiveGeneration(6) - application.mu.Lock() - defer application.mu.Unlock() - if application.generation != 7 || application.activeViewers != 1 || application.waitingViewers != 2 { - t.Fatalf("stale generation mutated current state: generation=%d active=%d waiting=%d", - application.generation, application.activeViewers, application.waitingViewers) - } -} - -func TestStaleMediaClaimCannotReleaseTheCurrentClaim(t *testing.T) { - application := &App{generation: 7, mediaClaimed: true, mediaClaimID: 11} - application.releaseMediaClaim(6, 11, nil) - application.releaseMediaClaim(7, 10, nil) - application.mu.Lock() - if !application.mediaClaimed || application.mediaClaimID != 11 { - application.mu.Unlock() - t.Fatal("stale media claim released the current claim") - } - application.mu.Unlock() - application.releaseMediaClaim(7, 11, nil) - application.mu.Lock() - defer application.mu.Unlock() - if application.mediaClaimed || application.mediaClaimID != 0 { - t.Fatal("current media claim was not released") - } -} - -func TestMediaAttachDeadlineAbandonsTheFreshRoom(t *testing.T) { - remoteDone := make(chan []byte, 1) - remoteServer := newAppRemoteServer(t, remoteDone) - defer remoteServer.Close() - - application, err := New() - if err != nil { - t.Fatal(err) - } - application.attachTimeout = 25 * time.Millisecond - _, err = application.Start() - if err != nil { - t.Fatal(err) - } - defer application.Close() - - body, _ := json.Marshal(startRequest{ServerURL: remoteServer.URL}) - request, err := http.NewRequest(http.MethodPost, application.origin+"/api/start", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - request.Header.Set("Origin", application.origin) - request.Header.Set(processTokenHeader, application.token) - request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != http.StatusCreated { - t.Fatalf("start status = %d", response.StatusCode) - } - - select { - case payload := <-remoteDone: - if string(payload) != `{"type":"abandon-room"}` { - t.Fatalf("deadline terminal wire = %q", payload) - } - case <-time.After(2 * time.Second): - t.Fatal("media attach deadline did not abandon the room") - } -} - -func TestSuccessfulStartKeepsTheSessionAliveUntilStop(t *testing.T) { - remoteDone := make(chan []byte, 1) - remoteServer := newAppRemoteServer(t, remoteDone) - defer remoteServer.Close() - - application := startTestApplication(t, remoteServer.URL, time.Second, time.Second) - select { - case payload := <-remoteDone: - t.Fatalf("live session ended before stop: %q", payload) - case <-time.After(50 * time.Millisecond): - } - stopTestApplication(t, application) - assertAbandonRoom(t, remoteDone) -} - -func TestInvalidMediaSubprotocolLeavesTheAttachDeadlineArmed(t *testing.T) { - remoteDone := make(chan []byte, 1) - remoteServer := newAppRemoteServer(t, remoteDone) - defer remoteServer.Close() - - application := startTestApplication(t, remoteServer.URL, 25*time.Millisecond, time.Second) - connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{ - HTTPHeader: http.Header{"Origin": []string{application.origin}}, - Subprotocols: []string{"screener.token.wrong"}, - }) - if err == nil { - connection.CloseNow() - } - assertAbandonRoom(t, remoteDone) -} - -func TestAcceptedMediaWithoutConfigFailsWithinTheHandshakeDeadline(t *testing.T) { - remoteDone := make(chan []byte, 1) - remoteServer := newAppRemoteServer(t, remoteDone) - defer remoteServer.Close() - - application := startTestApplication(t, remoteServer.URL, time.Second, 25*time.Millisecond) - connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{ - HTTPHeader: http.Header{"Origin": []string{application.origin}}, - Subprotocols: []string{"screener.token." + application.token}, - }) - if err != nil { - t.Fatal(err) - } - defer connection.CloseNow() - readContext, cancelRead := context.WithTimeout(context.Background(), time.Second) - defer cancelRead() - if _, _, err = connection.Read(readContext); err != nil { - t.Fatalf("read local ready message: %v", err) - } - assertAbandonRoom(t, remoteDone) -} - -func TestConfiguredMediaFramesWithCaptureJitterReachFanout(t *testing.T) { - application, events, terminal := startObservedTestApplication(t) - connection := openConfiguredMedia(t, application) - - payload, err := media.EncodePacket(media.Packet{ - Kind: media.KindVideo, KeyFrame: true, Timestamp100ns: 10_000_000, - Duration100ns: 333_330, Data: []byte{0x10, 0x00, 0x00, 0x00}, - }) - if err != nil { - t.Fatal(err) - } - writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second) - err = connection.Write(writeContext, websocket.MessageBinary, payload) - cancelWrite() - if err != nil { - t.Fatalf("write encoded frame: %v", err) - } - payload, err = media.EncodePacket(media.Packet{ - Kind: media.KindVideo, Timestamp100ns: 10_330_000, - Duration100ns: 333_330, Data: []byte{0x10, 0x00, 0x00, 0x00}, - }) - if err != nil { - t.Fatal(err) - } - writeContext, cancelWrite = context.WithTimeout(context.Background(), time.Second) - err = connection.Write(writeContext, websocket.MessageBinary, payload) - cancelWrite() - if err != nil { - t.Fatalf("write jittered encoded frame: %v", err) - } - - diagnostics := awaitRemoteEvent(t, events, "diagnostics", 5*time.Second) - if diagnostics.Media == nil || diagnostics.Media.FramesWritten < 2 || - diagnostics.Media.SourceRTPPacketsWritten == 0 { - t.Fatalf("binary frames did not reach fanout: %+v", diagnostics.Media) - } - if err = connection.Close(websocket.StatusNormalClosure, "test complete"); err != nil { - t.Fatalf("close configured media: %v", err) - } - waitForSessionStop(t, application) - assertAbandonRoom(t, terminal) - assertNoFatalEvents(t, events) -} - -func TestAbnormalPostConfigMediaReadUsesFixedFailure(t *testing.T) { - application, events, terminal := startObservedTestApplication(t) - connection := openConfiguredMedia(t, application) - const privateReason = "transport detail that must not escape" - - if err := connection.Close(websocket.StatusPolicyViolation, privateReason); err != nil { - t.Fatalf("close configured media abnormally: %v", err) - } - fatal := awaitRemoteEvent(t, events, "fatal", 2*time.Second) - if fatal.Message != "local media bridge read failed" { - t.Fatalf("fatal message = %q", fatal.Message) - } - if strings.Contains(fatal.Message, privateReason) { - t.Fatalf("fatal message exposed the WebSocket close reason: %q", fatal.Message) - } - waitForSessionStop(t, application) - assertAbandonRoom(t, terminal) -} - -func TestApplicationShutdownDoesNotReportMediaReadFailure(t *testing.T) { - application, events, terminal := startObservedTestApplication(t) - connection := openConfiguredMedia(t, application) - - closed := make(chan struct{}) - go func() { - application.Close() - close(closed) - }() - readContext, cancelRead := context.WithTimeout(context.Background(), time.Second) - _, _, err := connection.Read(readContext) - cancelRead() - if websocket.CloseStatus(err) != websocket.StatusNormalClosure { - t.Fatalf("application close status = %v, err = %v", websocket.CloseStatus(err), err) - } - select { - case <-closed: - case <-time.After(2 * time.Second): - t.Fatal("application shutdown did not complete") - } - assertAbandonRoom(t, terminal) - assertNoFatalEvents(t, events) -} - -func TestPostConfigMediaReadFailureClassification(t *testing.T) { - canceledContext, cancel := context.WithCancel(context.Background()) - cancel() - tests := []struct { - name string - ctx context.Context - err error - wantErr bool - }{ - {name: "transport-error", ctx: context.Background(), err: errors.New("private transport detail"), wantErr: true}, - {name: "policy-close", ctx: context.Background(), err: websocket.CloseError{Code: websocket.StatusPolicyViolation, Reason: "private close detail"}, wantErr: true}, - {name: "normal-close", ctx: context.Background(), err: websocket.CloseError{Code: websocket.StatusNormalClosure}}, - {name: "going-away", ctx: context.Background(), err: websocket.CloseError{Code: websocket.StatusGoingAway}}, - {name: "application-shutdown", ctx: canceledContext, err: errors.New("private shutdown detail")}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - failure := postConfigMediaReadFailure(test.ctx, test.err) - if test.wantErr { - if failure == nil || failure.Error() != "local media bridge read failed" { - t.Fatalf("failure = %v", failure) - } - return - } - if failure != nil { - t.Fatalf("failure = %v", failure) - } - }) - } -} - -func TestStopDuringDelayedStartCannotResurrectTheSession(t *testing.T) { - authReceived := make(chan struct{}) - releaseAuthentication := make(chan struct{}) - remoteServer := newDelayedAppRemoteServer(t, authReceived, releaseAuthentication) - defer remoteServer.Close() - - application, err := New() - if err != nil { - t.Fatal(err) - } - _, err = application.Start() - if err != nil { - t.Fatal(err) - } - defer application.Close() - - type requestResult struct { - status int - err error - } - startResult := make(chan requestResult, 1) - go func() { - status, requestErr := doLocalPost(application, "/api/start", startRequest{ServerURL: remoteServer.URL}) - startResult <- requestResult{status: status, err: requestErr} - }() - select { - case <-authReceived: - case <-time.After(2 * time.Second): - t.Fatal("remote signaling authentication was not received") - } - if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent { - t.Fatalf("stop status = %d", status) - } - close(releaseAuthentication) - select { - case result := <-startResult: - if result.err != nil { - t.Fatal(result.err) - } - if result.status == http.StatusCreated { - t.Fatal("a canceled delayed start returned success") - } - case <-time.After(2 * time.Second): - t.Fatal("canceled delayed start did not finish") - } - application.mu.Lock() - defer application.mu.Unlock() - if application.session != nil || application.sessionCancel != nil || application.starting { - t.Fatal("canceled delayed start resurrected an active session") - } -} - -func startTestApplication(t *testing.T, remoteURL string, attachTimeout, configTimeout time.Duration) *App { - t.Helper() - application, err := New() - if err != nil { - t.Fatal(err) - } - application.attachTimeout = attachTimeout - application.configTimeout = configTimeout - _, err = application.Start() - if err != nil { - t.Fatal(err) - } - t.Cleanup(application.Close) - if status := postLocal(t, application, "/api/start", startRequest{ServerURL: remoteURL}); status != http.StatusCreated { - t.Fatalf("start status = %d", status) - } - return application -} - -func startObservedTestApplication(t *testing.T) (*App, <-chan remote.Event, <-chan []byte) { - t.Helper() - terminal := make(chan []byte, 1) - remoteServer := newAppRemoteServer(t, terminal) - t.Cleanup(remoteServer.Close) - - application, err := New() - if err != nil { - t.Fatal(err) - } - if _, err = application.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(application.Close) - - baseURL, err := normalizeRemoteBase(remoteServer.URL) - if err != nil { - t.Fatal(err) - } - sessionContext, sessionCancel := context.WithCancel(application.ctx) - events := make(chan remote.Event, 32) - session, room, err := remote.Start(sessionContext, remote.StartOptions{ - BaseURL: baseURL, - OnEvent: func(event remote.Event) { - events <- event - }, - }) - if err != nil { - sessionCancel() - t.Fatal(err) - } - application.mu.Lock() - application.generation++ - application.session = session - application.sessionCancel = sessionCancel - application.room = room - application.mu.Unlock() - return application, events, terminal -} - -func openConfiguredMedia(t *testing.T, application *App) *websocket.Conn { - t.Helper() - connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{ - HTTPHeader: http.Header{"Origin": []string{application.origin}}, - Subprotocols: []string{"screener.token." + application.token}, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = connection.CloseNow() }) - readLocalKind(t, connection, "ready") - config := []byte(`{"kind":"config","codec":"vp8","videoSource":"browser","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1}`) - writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second) - err = connection.Write(writeContext, websocket.MessageText, config) - cancelWrite() - if err != nil { - t.Fatalf("write encoder config: %v", err) - } - readLocalKind(t, connection, "config-accepted") - return connection -} - -func readLocalKind(t *testing.T, connection *websocket.Conn, want string) { - t.Helper() - readContext, cancelRead := context.WithTimeout(context.Background(), time.Second) - defer cancelRead() - messageType, payload, err := connection.Read(readContext) - if err != nil { - t.Fatalf("read local %s event: %v", want, err) - } - if messageType != websocket.MessageText { - t.Fatalf("local %s event was not text", want) - } - var event struct { - Kind string `json:"kind"` - } - if err = json.Unmarshal(payload, &event); err != nil { - t.Fatalf("decode local %s event: %v", want, err) - } - if event.Kind != want { - t.Fatalf("local event kind = %q, want %q", event.Kind, want) - } -} - -func awaitRemoteEvent(t *testing.T, events <-chan remote.Event, kind string, timeout time.Duration) remote.Event { - t.Helper() - timer := time.NewTimer(timeout) - defer timer.Stop() - for { - select { - case event := <-events: - if event.Kind == kind { - return event - } - case <-timer.C: - t.Fatalf("remote %s event was not emitted", kind) - } - } -} - -func waitForSessionStop(t *testing.T, application *App) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - application.mu.Lock() - stopped := application.session == nil - application.mu.Unlock() - if stopped { - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("media session did not stop") -} - -func assertNoFatalEvents(t *testing.T, events <-chan remote.Event) { - t.Helper() - for { - select { - case event := <-events: - if event.Kind == "fatal" { - t.Fatalf("unexpected fatal event: %q", event.Message) - } - default: - return - } - } -} - -func stopTestApplication(t *testing.T, application *App) { - t.Helper() - if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent { - t.Fatalf("stop status = %d", status) - } -} - -func postLocal(t *testing.T, application *App, path string, body any) int { - t.Helper() - status, err := doLocalPost(application, path, body) - if err != nil { - t.Fatal(err) - } - return status -} - -func doLocalPost(application *App, path string, body any) (int, error) { - var payload []byte - if body != nil { - payload, _ = json.Marshal(body) - } - request, err := http.NewRequest(http.MethodPost, application.origin+path, bytes.NewReader(payload)) - if err != nil { - return 0, err - } - request.Header.Set("Origin", application.origin) - request.Header.Set(processTokenHeader, application.token) - request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) - if err != nil { - return 0, err - } - defer response.Body.Close() - return response.StatusCode, nil -} - -func assertAbandonRoom(t *testing.T, terminal <-chan []byte) { - t.Helper() - select { - case payload := <-terminal: - if string(payload) != `{"type":"abandon-room"}` { - t.Fatalf("terminal wire = %q", payload) - } - case <-time.After(2 * time.Second): - t.Fatal("fresh room was not abandoned") - } -} - -func newAppRemoteServer(t *testing.T, terminal chan<- []byte) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { - _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) - }) - mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { - response.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL)) - }) - mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - defer connection.CloseNow() - if _, _, err = connection.Read(request.Context()); err != nil { - return - } - authenticated := []byte(`{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"endpointMediaCopyCapacity":2,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`) - if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil { - return - } - for { - messageType, payload, readErr := connection.Read(request.Context()) - if readErr != nil { - return - } - if messageType != websocket.MessageText { - continue - } - var message struct { - Type string `json:"type"` - } - _ = json.Unmarshal(payload, &message) - if message.Type == "abandon-room" { - terminal <- append([]byte(nil), payload...) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - _ = connection.Write(ctx, websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`)) - cancel() - return - } - } - }) - return server -} - -func newDelayedAppRemoteServer( - t *testing.T, - authReceived chan<- struct{}, - releaseAuthentication <-chan struct{}, -) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { - _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true}) - }) - mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { - response.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL)) - }) - mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - defer connection.CloseNow() - if _, _, err = connection.Read(request.Context()); err != nil { - return - } - close(authReceived) - <-releaseAuthentication - writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second) - defer cancelWrite() - _ = connection.Write(writeContext, websocket.MessageText, []byte(`{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"endpointMediaCopyCapacity":2,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`)) - }) - return server -} - -func appRoomResponse(origin string) map[string]any { - expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) - grant := "g1.1." + strconv.FormatInt(expiresAt.Unix(), 10) + ".aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - return map[string]any{ - "roomId": "1", "hostToken": "0123456789abcdef0123456789abcdef", - "inviteUrl": origin + "/r/1#v=" + grant, - "viewerPolicy": "private-link", "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339), - } -} - -type fakeCaptureProvider struct{ targets []windowcapture.Target } - -func (provider fakeCaptureProvider) List(context.Context) ([]windowcapture.Target, error) { - return provider.targets, nil -} - -func (fakeCaptureProvider) CaptureAudio(context.Context, windowcapture.Target, func(windowcapture.PCMChunk) error) error { - return nil -} - -func (fakeCaptureProvider) CaptureWindow( - context.Context, - windowcapture.Target, - <-chan struct{}, - func(windowcapture.PCMChunk) error, - func(windowcapture.H264AccessUnit) error, - func(windowcapture.Status) error, -) error { - return nil -} diff --git a/native/sender/internal/app/ui/app.js b/native/sender/internal/app/ui/app.js deleted file mode 100644 index 9a3206a..0000000 --- a/native/sender/internal/app/ui/app.js +++ /dev/null @@ -1,822 +0,0 @@ -import { - HARDWARE_PREFERENCE, - HARDWARE_STATUS, - retainsHardwarePreference, -} from "./hardware-status.js"; - -const WIDTH = 1280; -const HEIGHT = 720; -const FPS = 30; -const BITRATE = 3_000_000; -const FRAME_DURATION = Math.round(1_000_000 / FPS); -const MAX_ENCODER_QUEUE = 2; -const MAX_SOCKET_BUFFER = 512 * 1024; -const MAX_FRAME_BYTES = 1 << 20; -const ENVELOPE_HEADER_BYTES = 20; -const MEDIA_KIND_VIDEO = 1; -const MEDIA_KIND_PCM = 2; -const MEDIA_KIND_OPUS = 3; - -const fragment = new URLSearchParams(location.hash.slice(1)); -const processToken = fragment.get("token") || ""; -history.replaceState(null, "", location.pathname); - -const elements = { - serverURL: document.querySelector("#server-url"), - password: document.querySelector("#password"), - codec: document.querySelector("#codec"), - windowTarget: document.querySelector("#window-target"), - refreshWindows: document.querySelector("#refresh-windows"), - start: document.querySelector("#start"), - stop: document.querySelector("#stop"), - preview: document.querySelector("#preview"), - nativePreview: document.querySelector("#native-preview"), - empty: document.querySelector("#empty-state"), - invite: document.querySelector("#invite"), - inviteLink: document.querySelector("#invite-link"), - copy: document.querySelector("#copy"), - status: document.querySelector("#status"), - viewerCount: document.querySelector("#viewer-count"), - error: document.querySelector("#error"), - captureDiagnostic: document.querySelector("#capture-diagnostic"), - encoderDiagnostic: document.querySelector("#encoder-diagnostic"), - bridgeDiagnostic: document.querySelector("#bridge-diagnostic"), - helperDiagnostic: document.querySelector("#helper-diagnostic"), - edgeDiagnostic: document.querySelector("#edge-diagnostic"), - audioStatus: document.querySelector("#audio-status"), -}; - -let stream = null; -let processorReader = null; -let encoder = null; -let videoDecoder = null; -let audioEncoder = null; -let silentAudioChunks = 0; -let mediaSocket = null; -let sharing = false; -let stopping = false; -let startInFlight = false; -let roomGeneration = 0; -let forceKeyFrame = true; -let frameNumber = 0; -let captureSettings = null; -let nativeDiagnostics = null; -let nativeVideoStatus = null; -let metrics = emptyMetrics(); -let startGeneration = 0; -let diagnosticsTimer = null; -let pendingStartGeneration = 0; -let activeVideoSource = "browser"; - -if (!processToken) { - showError("本地发送端令牌已失效,请重新启动 sender.exe"); - elements.start.disabled = true; -} - -elements.start.addEventListener("click", () => void startSharing()); -elements.stop.addEventListener("click", () => void stopSharing(startGeneration)); -elements.copy.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(elements.inviteLink.href); - elements.copy.textContent = "✓"; - setTimeout(() => { elements.copy.textContent = "⧉"; }, 1200); - } catch { - showError("无法复制链接,请直接打开邀请链接"); - } -}); -elements.refreshWindows.addEventListener("click", () => void loadWindowTargets()); -if (processToken) void loadWindowTargets(); - -async function startSharing() { - if (sharing || stopping || startInFlight) return; - showError(""); - const videoSource = selectedVideoSource(); - const nativeWindow = videoSource === "native-window-h264"; - if (!nativeWindow && (!("VideoEncoder" in window) || !("MediaStreamTrackProcessor" in window))) { - showError("此浏览器缺少 WebCodecs 屏幕编码支持,请使用当前版 Chrome 或 Edge"); - return; - } - if (nativeWindow && !("VideoDecoder" in window)) { - showError("此浏览器缺少原生 H.264 本地预览支持,请使用当前版 Chrome 或 Edge"); - return; - } - startInFlight = true; - elements.start.disabled = true; - const generation = ++startGeneration; - const codec = selectedCodec(); - metrics.codec = codec; - const windowTargetId = elements.windowTarget.value; - activeVideoSource = videoSource; - if (nativeWindow && !windowTargetId) { - showError("原生 H.264 模式需要选择一个目标窗口"); - startInFlight = false; - elements.start.disabled = false; - return; - } - if (windowTargetId && (!("AudioEncoder" in window) || !("AudioData" in window))) { - showError("当前浏览器缺少 WebCodecs Opus 编码支持,请关闭目标进程音频或使用当前版 Chrome / Edge"); - startInFlight = false; - elements.start.disabled = false; - return; - } - try { - let videoTrack = null; - if (nativeWindow) { - await startNativeDecoder(generation); - captureSettings = { width: WIDTH, height: HEIGHT, frameRate: FPS }; - elements.preview.hidden = true; - elements.nativePreview.hidden = false; - } else { - setStatus("选择屏幕"); - const capturedStream = await navigator.mediaDevices.getDisplayMedia({ - video: { - width: { ideal: WIDTH, max: WIDTH }, - height: { ideal: HEIGHT, max: HEIGHT }, - frameRate: { ideal: FPS, max: FPS }, - }, - audio: false, - }); - assertCurrentStart(generation, mediaSocket, capturedStream); - stream = capturedStream; - videoTrack = stream.getVideoTracks()[0]; - if (!videoTrack) throw new Error("没有可用的视频轨道"); - await videoTrack.applyConstraints({ width: WIDTH, height: HEIGHT, frameRate: FPS }); - assertCurrentStart(generation); - captureSettings = videoTrack.getSettings(); - videoTrack.addEventListener("ended", () => void stopSharing(generation), { once: true }); - elements.preview.srcObject = stream; - elements.preview.hidden = false; - elements.nativePreview.hidden = true; - } - updateDiagnostics(); - elements.empty.hidden = true; - - setStatus("创建房间"); - pendingStartGeneration = generation; - const room = await localRequest("/api/start", { - serverUrl: elements.serverURL.value, - password: elements.password.value, - codec, - videoSource, - windowTargetId, - }); - assertCurrentStart(generation); - if (pendingStartGeneration === generation) pendingStartGeneration = 0; - roomGeneration = generation; - elements.password.value = ""; - elements.inviteLink.href = room.inviteUrl; - elements.inviteLink.textContent = room.inviteUrl; - elements.invite.hidden = false; - - if (windowTargetId) await startAudioEncoder(generation); - await connectMediaSocket(generation, codec, videoSource, Boolean(windowTargetId)); - assertCurrentStart(generation); - let activeEncoder = null; - if (!nativeWindow) { - activeEncoder = await startEncoder(generation, codec); - assertCurrentStart(generation); - } - sharing = true; - elements.stop.hidden = false; - setStatus("正在分享"); - if (!nativeWindow) void readFrames(videoTrack, generation, activeEncoder, mediaSocket); - } catch (error) { - if (generation === startGeneration && !(error instanceof StaleStartError)) { - showError(readableError(error, "无法开始分享")); - } - await stopSharing(generation); - } finally { - startInFlight = false; - elements.start.disabled = sharing || stopping; - } -} - -async function connectMediaSocket(generation, codec, videoSource, audio) { - const url = new URL("/media", location.href); - url.protocol = "ws:"; - const socket = new WebSocket(url, [`screener.token.${processToken}`]); - mediaSocket = socket; - socket.binaryType = "arraybuffer"; - socket.addEventListener("message", (event) => handleLocalEvent(generation, socket, event)); - socket.addEventListener("close", () => { - if (generation === startGeneration && !stopping && mediaSocket === socket) { - void failSharing(generation, "本地媒体连接意外断开"); - } - }); - - const ready = waitForLocalKind(socket, "ready", "等待本地媒体服务超时"); - await waitForSocketOpen(socket); - assertCurrentStart(generation, socket); - await ready; - assertCurrentStart(generation, socket); - const accepted = waitForLocalKind(socket, "config-accepted", "本地编码配置确认超时"); - socket.send(JSON.stringify({ - kind: "config", - codec, - videoSource, - width: WIDTH, - height: HEIGHT, - fps: FPS, - bitrate: BITRATE, - encoderInstances: 1, - audio, - })); - await accepted; - assertCurrentStart(generation, socket); -} - -async function startEncoder(generation, codec) { - metrics.codec = codec; - const preferred = { - codec: codec === "h264" ? "avc1.42c01f" : "vp8", - width: WIDTH, - height: HEIGHT, - bitrate: BITRATE, - framerate: FPS, - latencyMode: "realtime", - bitrateMode: "variable", - hardwareAcceleration: HARDWARE_PREFERENCE, - }; - if (codec === "h264") preferred.avc = { format: "annexb" }; - let support = await VideoEncoder.isConfigSupported(preferred); - assertCurrentStart(generation, mediaSocket); - if (!retainsHardwarePreference(support)) { - const fallback = { ...preferred }; - delete fallback.hardwareAcceleration; - support = await VideoEncoder.isConfigSupported(fallback); - assertCurrentStart(generation, mediaSocket); - metrics.hardwarePreference = support.supported - ? HARDWARE_STATUS.FALLBACK - : HARDWARE_STATUS.UNSUPPORTED; - } else { - metrics.hardwarePreference = HARDWARE_STATUS.PREFERENCE_ACCEPTED; - } - metrics.hardwareEvidence = HARDWARE_STATUS.UNVERIFIED; - if (!support.supported) { - throw new Error(`此设备不支持 ${codecLabel(codec)} 720p30 编码`); - } - let activeEncoder; - activeEncoder = new VideoEncoder({ - output: (chunk) => sendEncodedChunk(generation, activeEncoder, chunk), - error: (error) => void failSharing(generation, `编码器失败:${error.message}`), - }); - encoder = activeEncoder; - activeEncoder.configure(support.config); - updateDiagnostics(); - return activeEncoder; -} - -async function startNativeDecoder(generation) { - const config = { - codec: "avc1.42c01f", - codedWidth: WIDTH, - codedHeight: HEIGHT, - optimizeForLatency: true, - }; - const support = await VideoDecoder.isConfigSupported(config); - assertCurrentStart(generation); - if (!support.supported) throw new Error("此浏览器不支持 H.264 42c01f 本地预览"); - let activeDecoder; - activeDecoder = new VideoDecoder({ - output: (frame) => { - try { - if (generation === startGeneration && videoDecoder === activeDecoder) { - const context = elements.nativePreview.getContext("2d"); - context?.drawImage(frame, 0, 0, WIDTH, HEIGHT); - } - } finally { - frame.close(); - } - }, - error: (error) => void failSharing(generation, `原生 H.264 本地预览失败:${error.message}`), - }); - videoDecoder = activeDecoder; - activeDecoder.configure(support.config); -} - -async function readFrames(videoTrack, generation, activeEncoder, activeSocket) { - const processor = new MediaStreamTrackProcessor({ track: videoTrack }); - const reader = processor.readable.getReader(); - if (generation !== startGeneration || encoder !== activeEncoder || mediaSocket !== activeSocket) { - await reader.cancel(); - return; - } - processorReader = reader; - try { - while (generation === startGeneration && sharing && encoder === activeEncoder && - mediaSocket === activeSocket && activeSocket?.readyState === WebSocket.OPEN) { - const { value: frame, done } = await reader.read(); - if (done) { - if (generation === startGeneration && sharing && !stopping) { - await failSharing(generation, "屏幕画面读取意外结束"); - } - return; - } - if (generation !== startGeneration || !sharing || encoder !== activeEncoder || - mediaSocket !== activeSocket || processorReader !== reader) { - frame.close(); - return; - } - metrics.framesRead += 1; - try { - const queueSize = activeEncoder.encodeQueueSize; - metrics.encoderQueuePeak = Math.max(metrics.encoderQueuePeak, queueSize); - if (activeEncoder.state === "configured" && queueSize < MAX_ENCODER_QUEUE) { - const keyFrame = forceKeyFrame || frameNumber % (FPS * 2) === 0; - forceKeyFrame = false; - activeEncoder.encode(frame, { keyFrame }); - frameNumber += 1; - metrics.framesSubmitted += 1; - } else { - metrics.encoderQueueDrops += 1; - forceKeyFrame = true; - } - } finally { - frame.close(); - } - scheduleDiagnostics(); - } - } catch (error) { - if (generation === startGeneration && sharing && !stopping && encoder === activeEncoder) { - await failSharing(generation, readableError(error, "读取屏幕画面失败")); - } - } -} - -function sendEncodedChunk(generation, activeEncoder, chunk) { - if (generation !== startGeneration || stopping || !sharing || encoder !== activeEncoder) return; - try { - if (!mediaSocket || mediaSocket.readyState !== WebSocket.OPEN) { - if (!stopping) void failSharing(generation, "本地媒体连接不可用"); - return; - } - if (mediaSocket.bufferedAmount > MAX_SOCKET_BUFFER) { - void failSharing(generation, "本地媒体桥接积压超过安全上限"); - return; - } - if (chunk.byteLength < 1 || chunk.byteLength > MAX_FRAME_BYTES) { - void failSharing(generation, "编码帧大小超出本地媒体契约"); - return; - } - const data = new Uint8Array(chunk.byteLength); - chunk.copyTo(data); - const message = mediaEnvelope( - MEDIA_KIND_VIDEO, - chunk.type === "key" ? 1 : 0, - BigInt(Math.max(0, chunk.timestamp)) * 10n, - BigInt(chunk.duration || FRAME_DURATION) * 10n, - data, - ); - mediaSocket.send(message); - metrics.encoderOutputs += 1; - metrics.encodedBytes += data.byteLength; - metrics.socketBufferedPeak = Math.max(metrics.socketBufferedPeak, mediaSocket.bufferedAmount); - scheduleDiagnostics(); - } catch (error) { - void failSharing(generation, readableError(error, "发送编码帧失败")); - } -} - -function handleLocalEvent(generation, socket, event) { - if (generation !== startGeneration || socket !== mediaSocket) return; - if (event.data instanceof ArrayBuffer) { - const kind = event.data.byteLength > 1 ? new DataView(event.data).getUint8(1) : 0; - if (kind === MEDIA_KIND_PCM) { - handlePCM(generation, event.data); - } else if (kind === MEDIA_KIND_VIDEO && activeVideoSource === "native-window-h264") { - handleNativeVideo(generation, event.data); - } else { - void failSharing(generation, "本地媒体桥接返回了意外的二进制数据"); - } - return; - } - if (typeof event.data !== "string") return; - const message = parseLocalMessage(event.data); - if (!message) return; - if (message.kind === "request-keyframe") forceKeyFrame = true; - if (message.kind === "viewer-count" || message.kind === "ready") { - showViewerCounts(message.activeViewers, message.waitingViewers); - } - if (message.kind === "diagnostics") { - nativeDiagnostics = message; - updateDiagnostics(); - } - if (message.kind === "warning" && message.message) showError(message.message); - if (message.kind === "audio-state") { - elements.audioStatus.textContent = message.state === "unavailable" - ? "不可用(未回退系统声音)" - : "正在连接目标进程"; - } - if (message.kind === "native-video-state") { - nativeVideoStatus = { ...nativeVideoStatus, ...message.status }; - updateDiagnostics(); - setStatus(message.status?.state === "active" ? "正在分享(原生 H.264)" : "正在启动原生硬件采集"); - } - if (message.kind === "fatal") { - void failSharing(generation, message.message || "分享会话已失败并停止"); - } -} - -function handleNativeVideo(generation, message) { - if (generation !== startGeneration || !videoDecoder || videoDecoder.state !== "configured") return; - try { - const view = new DataView(message); - const flags = view.getUint8(2); - if (message.byteLength <= ENVELOPE_HEADER_BYTES || view.getUint8(0) !== 1 || - view.getUint8(1) !== MEDIA_KIND_VIDEO || flags > 1 || view.getUint8(3) !== 0) { - throw new Error("本地 H.264 envelope 无效"); - } - const data = new Uint8Array(message, ENVELOPE_HEADER_BYTES); - if (data.byteLength < 1 || data.byteLength > MAX_FRAME_BYTES) { - throw new Error("本地 H.264 access unit 长度无效"); - } - videoDecoder.decode(new EncodedVideoChunk({ - type: flags === 1 ? "key" : "delta", - timestamp: Number(view.getBigUint64(4, false) / 10n), - duration: Number(view.getBigUint64(12, false) / 10n), - data, - })); - } catch (error) { - void failSharing(generation, readableError(error, "处理本地 H.264 预览失败")); - } -} - -async function failSharing(generation, message) { - if (generation !== startGeneration || stopping) return; - showError(message); - await stopSharing(generation); -} - -async function stopSharing(expectedGeneration = startGeneration) { - if (expectedGeneration !== startGeneration || stopping) return; - stopping = true; - startGeneration += 1; - sharing = false; - setStatus("正在停止"); - let stopError = null; - const rememberError = (error) => { - if (stopError === null) stopError = error; - }; - try { - const reader = processorReader; - processorReader = null; - try { - stream?.getTracks().forEach((track) => track.stop()); - } catch (error) { - rememberError(error); - } - stream = null; - elements.preview.srcObject = null; - try { - await reader?.cancel(); - } catch (error) { - rememberError(error); - } - try { - if (encoder && encoder.state !== "closed") encoder.close(); - } catch (error) { - rememberError(error); - } - encoder = null; - try { - if (videoDecoder && videoDecoder.state !== "closed") videoDecoder.close(); - } catch (error) { - rememberError(error); - } - videoDecoder = null; - elements.nativePreview.getContext("2d")?.clearRect(0, 0, WIDTH, HEIGHT); - elements.nativePreview.hidden = true; - elements.preview.hidden = false; - try { - if (audioEncoder && audioEncoder.state !== "closed") audioEncoder.close(); - } catch (error) { - rememberError(error); - } - audioEncoder = null; - const socket = mediaSocket; - mediaSocket = null; - try { - if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "sender stopped"); - } catch (error) { - rememberError(error); - } - if (roomGeneration === expectedGeneration || pendingStartGeneration === expectedGeneration) { - try { - await stopLocalSession(expectedGeneration); - } catch (error) { - rememberError(error); - } - } - if (stopError !== null) showError(readableError(stopError, "停止分享失败")); - } finally { - frameNumber = 0; - forceKeyFrame = true; - silentAudioChunks = 0; - captureSettings = null; - nativeDiagnostics = null; - nativeVideoStatus = null; - activeVideoSource = "browser"; - metrics = emptyMetrics(); - if (diagnosticsTimer !== null) { - clearTimeout(diagnosticsTimer); - diagnosticsTimer = null; - } - updateDiagnostics(); - elements.empty.hidden = false; - elements.invite.hidden = true; - elements.stop.hidden = true; - elements.start.disabled = startInFlight; - showViewerCounts(0, 0); - setStatus("未开始"); - elements.audioStatus.textContent = "关闭"; - stopping = false; - } -} - -async function stopLocalSession(generation) { - if (roomGeneration === generation) roomGeneration = 0; - if (pendingStartGeneration === generation) pendingStartGeneration = 0; - await localRequest("/api/stop"); -} - -function waitForSocketOpen(socket) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("本地媒体连接超时")), 5000); - socket.addEventListener("open", () => { - clearTimeout(timeout); - resolve(); - }, { once: true }); - socket.addEventListener("error", () => { - clearTimeout(timeout); - reject(new Error("本地媒体连接失败")); - }, { once: true }); - }); -} - -function waitForLocalKind(socket, kind, timeoutMessage) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => finish(reject, new Error(timeoutMessage)), 5000); - const onMessage = (event) => { - if (typeof event.data !== "string") return; - const message = parseLocalMessage(event.data); - if (!message) return; - if (message.kind === kind) finish(resolve, message); - if (message.kind === "fatal") finish(reject, new Error(message.message || "分享会话已失败")); - }; - const onClose = () => finish(reject, new Error("本地媒体连接在确认前关闭")); - const finish = (callback, value) => { - clearTimeout(timeout); - socket.removeEventListener("message", onMessage); - socket.removeEventListener("close", onClose); - callback(value); - }; - socket.addEventListener("message", onMessage); - socket.addEventListener("close", onClose, { once: true }); - }); -} - -async function localRequest(path, body) { - const response = await fetch(path, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - "X-Screener-Process-Token": processToken, - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (response.status === 204) return undefined; - let result; - try { - result = await response.json(); - } catch { - throw new Error(`本地发送端返回 HTTP ${response.status}`); - } - if (!response.ok) throw new Error(result.error || `本地发送端返回 HTTP ${response.status}`); - return result; -} - -async function loadWindowTargets() { - elements.refreshWindows.disabled = true; - const previous = elements.windowTarget.value; - try { - const result = await localRequest("/api/window-targets"); - elements.windowTarget.replaceChildren(new Option("关闭窗口音频", "")); - for (const target of Array.isArray(result.targets) ? result.targets : []) { - if (target?.id && target?.title) elements.windowTarget.add(new Option(target.title, target.id)); - } - if ([...elements.windowTarget.options].some((option) => option.value === previous)) { - elements.windowTarget.value = previous; - } - elements.windowTarget.disabled = !result.supported; - elements.audioStatus.textContent = result.supported ? "关闭" : "Windows 11 helper 不可用"; - } catch (error) { - elements.windowTarget.disabled = true; - elements.audioStatus.textContent = readableError(error, "窗口列表不可用"); - } finally { - elements.refreshWindows.disabled = false; - } -} - -async function startAudioEncoder(generation) { - const config = { codec: "opus", sampleRate: 48_000, numberOfChannels: 2, bitrate: 128_000 }; - const support = await AudioEncoder.isConfigSupported(config); - assertCurrentStart(generation); - if (!support.supported) throw new Error("当前浏览器不支持 WebCodecs Opus 编码"); - let active; - active = new AudioEncoder({ - output: (chunk) => sendEncodedAudio(generation, active, chunk), - error: (error) => void failSharing(generation, `音频编码器失败:${error.message}`), - }); - audioEncoder = active; - active.configure(support.config); - elements.audioStatus.textContent = "等待目标进程音频"; -} - -function handlePCM(generation, message) { - if (generation !== startGeneration || !audioEncoder || audioEncoder.state !== "configured") return; - if (audioEncoder.encodeQueueSize >= 4) return; - try { - const view = new DataView(message); - if (message.byteLength <= ENVELOPE_HEADER_BYTES || view.getUint8(0) !== 1 || - view.getUint8(1) !== MEDIA_KIND_PCM || view.getUint8(2) !== 0 || view.getUint8(3) !== 0) { - throw new Error("本地 PCM envelope 无效"); - } - const data = new Uint8Array(message, ENVELOPE_HEADER_BYTES); - if (data.byteLength % 4 !== 0) throw new Error("本地 PCM 帧长度无效"); - let audible = false; - for (const byte of data) if (byte !== 0) { audible = true; break; } - silentAudioChunks = audible ? 0 : silentAudioChunks + 1; - elements.audioStatus.textContent = silentAudioChunks >= 100 - ? "目标进程当前无声(未回退系统声音)" - : "目标进程树音频"; - const audio = new AudioData({ - format: "s16", sampleRate: 48_000, numberOfChannels: 2, - numberOfFrames: data.byteLength / 4, - timestamp: Number(view.getBigUint64(4, false) / 10n), data, - }); - audioEncoder.encode(audio); - audio.close(); - } catch (error) { - void failSharing(generation, readableError(error, "处理目标进程音频失败")); - } -} - -function sendEncodedAudio(generation, activeEncoder, chunk) { - if (generation !== startGeneration || stopping || audioEncoder !== activeEncoder || - !mediaSocket || mediaSocket.readyState !== WebSocket.OPEN) return; - if (chunk.byteLength < 1 || chunk.byteLength > 64 * 1024 || - mediaSocket.bufferedAmount > MAX_SOCKET_BUFFER) { - void failSharing(generation, "本地音频桥接超过安全上限"); - return; - } - const data = new Uint8Array(chunk.byteLength); - chunk.copyTo(data); - mediaSocket.send(mediaEnvelope( - MEDIA_KIND_OPUS, 0, BigInt(Math.max(0, chunk.timestamp)) * 10n, - BigInt(chunk.duration || 20_000) * 10n, data, - )); -} - -function mediaEnvelope(kind, flags, timestamp100ns, duration100ns, data) { - const message = new ArrayBuffer(ENVELOPE_HEADER_BYTES + data.byteLength); - const view = new DataView(message); - view.setUint8(0, 1); - view.setUint8(1, kind); - view.setUint8(2, flags); - view.setBigUint64(4, timestamp100ns, false); - view.setBigUint64(12, duration100ns, false); - new Uint8Array(message, ENVELOPE_HEADER_BYTES).set(data); - return message; -} - -function showViewerCounts(activeValue, waitingValue) { - const active = boundedCount(activeValue); - const waiting = boundedCount(waitingValue); - elements.viewerCount.textContent = waiting > 0 - ? `${active} 位已分配/连接中,${waiting} 位等待` - : `${active} 位已分配/连接中`; -} - -function updateDiagnostics() { - const width = captureSettings?.width ?? "未知"; - const height = captureSettings?.height ?? "未知"; - const rate = captureSettings?.frameRate ?? "未知"; - elements.captureDiagnostic.textContent = `${width} × ${height} · ${rate} FPS`; - if (activeVideoSource === "native-window-h264") { - const mftState = nativeVideoStatus?.state === "active" - ? "hardware-only MFT 已绑定并输出" - : nativeVideoStatus?.state === "starting" - ? "hardware-only MFT 已绑定,等待首帧" - : "hardware-only MFT 等待状态"; - elements.encoderDiagnostic.textContent = `H.264 · 3 Mbps 上限 · 单 MF 编码器 · ${mftState}`; - } else { - elements.encoderDiagnostic.textContent = `${codecLabel(metrics.codec)} · 3 Mbps 上限 · 单对象 · 硬件偏好 ${hardwarePreferenceLabel(metrics.hardwarePreference)} · 硬件证据 ${hardwareEvidenceLabel(metrics.hardwareEvidence)}`; - } - elements.bridgeDiagnostic.textContent = `读取 ${metrics.framesRead} · 提交 ${metrics.framesSubmitted} · 输出 ${metrics.encoderOutputs} · 编码队列丢弃 ${metrics.encoderQueueDrops} · 编码队列峰值 ${metrics.encoderQueuePeak} 帧 · socket 峰值 ${metrics.socketBufferedPeak} B`; - const helper = nativeDiagnostics?.media; - const queue = helper?.queue; - elements.helperDiagnostic.textContent = helper && queue - ? `帧 ${helper.framesWritten} · 源 RTP ${helper.sourceRtpPacketsWritten} 包 / ${formatBytes(helper.sourceRtpBytesWritten)} · 队列 ${queue.depth}/${queue.capacity},峰值 ${queue.maxDepth} · 丢弃 ${queue.droppedFrames} · 过载 ${queue.overloadEvents} · 恢复 ${queue.completedRecoveries}` - : "尚无数据"; - const peers = Array.isArray(nativeDiagnostics?.peers) ? nativeDiagnostics.peers : []; - elements.edgeDiagnostic.textContent = peers.length === 0 - ? "无活动媒体边" - : peers.map((peer) => { - const sent = peer.bitrateBps === undefined ? "码率未知" : formatBitrate(peer.bitrateBps); - const rtt = peer.currentRoundTripTimeMs === undefined ? "RTT 未知" : `RTT ${peer.currentRoundTripTimeMs} ms`; - return `边 ${peer.slot}: ${peer.connectionState || "未知"} / ${peer.route || "未知路径"} / ${sent} / ${rtt} / PLI ${peer.pliRequests ?? 0} / FIR ${peer.firRequests ?? 0}`; - }).join(";"); -} - -function scheduleDiagnostics() { - if (diagnosticsTimer !== null) return; - diagnosticsTimer = setTimeout(() => { - diagnosticsTimer = null; - updateDiagnostics(); - }, 500); -} - -function formatBitrate(value) { - const bitrate = Number(value); - return Number.isFinite(bitrate) ? `${(bitrate / 1_000_000).toFixed(2)} Mbps` : "码率未知"; -} - -function formatBytes(value) { - const bytes = Number(value); - return Number.isFinite(bytes) ? `${(bytes / (1024 * 1024)).toFixed(2)} MiB` : "未知"; -} - -function parseLocalMessage(value) { - try { - const message = JSON.parse(value); - return message && typeof message === "object" ? message : null; - } catch { - return null; - } -} - -function boundedCount(value) { - const number = Number(value); - return Number.isSafeInteger(number) && number >= 0 ? number : 0; -} - -function selectedCodec() { - return elements.codec?.value === "h264" || elements.codec?.value === "native-h264" ? "h264" : "vp8"; -} - -function selectedVideoSource() { - return elements.codec?.value === "native-h264" ? "native-window-h264" : "browser"; -} - -function emptyMetrics() { - return { - codec: "vp8", - framesRead: 0, - framesSubmitted: 0, - encoderOutputs: 0, - encodedBytes: 0, - encoderQueueDrops: 0, - encoderQueuePeak: 0, - socketBufferedPeak: 0, - hardwarePreference: HARDWARE_STATUS.UNREQUESTED, - hardwareEvidence: HARDWARE_STATUS.UNVERIFIED, - }; -} - -function codecLabel(codec) { - return codec === "h264" ? "H.264" : "VP8"; -} - -function hardwarePreferenceLabel(value) { - return { - [HARDWARE_STATUS.UNREQUESTED]: "未请求", - [HARDWARE_STATUS.PREFERENCE_ACCEPTED]: "偏好已接受", - [HARDWARE_STATUS.FALLBACK]: "已回退", - [HARDWARE_STATUS.UNSUPPORTED]: "不支持", - }[value] || "未知"; -} - -function hardwareEvidenceLabel(value) { - return value === HARDWARE_STATUS.UNVERIFIED ? "未验证" : "未知"; -} - -function readableError(error, fallback) { - return error instanceof Error && error.message ? error.message : fallback; -} - -function assertCurrentStart(generation, socket = mediaSocket, staleStream = null) { - if (!isCurrentStart(generation, socket)) { - staleStream?.getTracks().forEach((track) => track.stop()); - throw new StaleStartError(); - } -} - -function isCurrentStart(generation, socket = mediaSocket) { - return generation === startGeneration && !stopping && (!socket || socket === mediaSocket); -} - -class StaleStartError extends Error {} - -function setStatus(value) { elements.status.textContent = value; } -function showError(value) { elements.error.textContent = value; } diff --git a/native/sender/internal/app/ui/hardware-status.d.ts b/native/sender/internal/app/ui/hardware-status.d.ts deleted file mode 100644 index 09e8064..0000000 --- a/native/sender/internal/app/ui/hardware-status.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export declare const HARDWARE_PREFERENCE: "prefer-hardware"; - -export declare const HARDWARE_STATUS: Readonly<{ - UNREQUESTED: "unrequested"; - PREFERENCE_ACCEPTED: "preference-accepted"; - FALLBACK: "fallback"; - UNSUPPORTED: "unsupported"; - UNVERIFIED: "hardware-unverified"; -}>; - -export declare function retainsHardwarePreference(support: unknown): boolean; diff --git a/native/sender/internal/app/ui/hardware-status.js b/native/sender/internal/app/ui/hardware-status.js deleted file mode 100644 index 84a59fa..0000000 --- a/native/sender/internal/app/ui/hardware-status.js +++ /dev/null @@ -1,14 +0,0 @@ -export const HARDWARE_PREFERENCE = "prefer-hardware"; - -export const HARDWARE_STATUS = Object.freeze({ - UNREQUESTED: "unrequested", - PREFERENCE_ACCEPTED: "preference-accepted", - FALLBACK: "fallback", - UNSUPPORTED: "unsupported", - UNVERIFIED: "hardware-unverified", -}); - -export function retainsHardwarePreference(support) { - return support?.supported === true && - support.config?.hardwareAcceleration === HARDWARE_PREFERENCE; -} diff --git a/native/sender/internal/app/ui/index.html b/native/sender/internal/app/ui/index.html deleted file mode 100644 index 5191958..0000000 --- a/native/sender/internal/app/ui/index.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - Screener Sender - - - -
-
SScreener Sender
- 未开始 -
-
-
-
-
-

屏幕分享

-

0 位已分配/连接中

-
- -
-
- - -
选择屏幕后,邀请链接会显示在这里
-
- -
- -
- - - diff --git a/native/sender/internal/app/ui/styles.css b/native/sender/internal/app/ui/styles.css deleted file mode 100644 index 769d948..0000000 --- a/native/sender/internal/app/ui/styles.css +++ /dev/null @@ -1,70 +0,0 @@ -:root { - color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #f1f3f1; - background: #101310; - letter-spacing: 0; - --surface: #171b18; - --surface-raised: #1d221e; - --border: #343a35; - --muted: #a8afa9; - --green: #45d483; - --green-strong: #2fbd70; - --red: #ff9797; -} - -* { box-sizing: border-box; } -html, body { min-width: 320px; min-height: 100%; margin: 0; } -body { min-height: 100vh; background: #101310; } -button, input, select { font: inherit; letter-spacing: 0; } -button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } - -.app-header { - display: flex; - min-height: 58px; - align-items: center; - justify-content: space-between; - padding: 9px clamp(16px, 3vw, 40px); - border-bottom: 1px solid var(--border); -} -.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; } -.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #326645; border-radius: 6px; color: var(--green); background: #14271b; } -.status { color: var(--muted); font-size: 13px; } - -main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; width: min(1400px, 100%); min-height: calc(100vh - 58px); margin: 0 auto; } -.stage { min-width: 0; padding: 28px clamp(18px, 3vw, 42px) 40px; } -.stage-heading { display: flex; min-height: 58px; align-items: flex-start; justify-content: space-between; gap: 16px; } -h1 { margin: 0; font-size: 24px; } -.stage-heading p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } -.preview-shell { position: relative; display: grid; width: 100%; margin-top: 18px; overflow: hidden; aspect-ratio: 16 / 9; place-items: center; border: 1px solid var(--border); border-radius: 6px; background: #080a08; } -video, canvas { display: block; width: 100%; height: 100%; object-fit: contain; } -.empty-state { position: absolute; color: var(--muted); font-size: 14px; } -.invite { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 8px; margin-top: 14px; } -.invite a { min-width: 0; overflow: hidden; padding: 11px 12px; border: 1px solid var(--border); border-radius: 6px; color: #c8f7d9; text-overflow: ellipsis; white-space: nowrap; } - -.controls { display: grid; align-content: start; gap: 18px; padding: 32px 24px; border-left: 1px solid var(--border); background: var(--surface); } -label { display: grid; gap: 8px; color: var(--muted); font-size: 13px; } -input, select { width: 100%; min-width: 0; min-height: 44px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 6px; color: #f1f3f1; background: #101310; } -.select-row { display: grid; grid-template-columns: minmax(0, 1fr) 44px; gap: 8px; } -.audio-note { margin: -8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; } -.button, .icon-button { min-height: 44px; border: 1px solid transparent; border-radius: 6px; color: inherit; cursor: pointer; } -.button:disabled { cursor: not-allowed; opacity: .5; } -.primary { color: #07150c; font-weight: 700; background: var(--green); } -.primary:hover { background: var(--green-strong); } -.secondary, .icon-button { border-color: var(--border); background: var(--surface-raised); } -.icon-button { font-size: 20px; } -.notice { margin: -6px 0 0; color: #e9c979; font-size: 13px; line-height: 1.45; } -.error { min-height: 20px; margin: 0; color: var(--red); font-size: 13px; line-height: 1.45; } -details { border-top: 1px solid var(--border); padding-top: 16px; color: var(--muted); font-size: 13px; } -summary { cursor: pointer; } -dl { display: grid; gap: 10px; margin: 14px 0 0; } -dl div { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 12px; } -dt, dd { margin: 0; } -dt { flex: 0 0 auto; } -dd { min-width: 0; overflow-wrap: anywhere; color: #f1f3f1; text-align: right; } - -@media (max-width: 820px) { - main { grid-template-columns: 1fr; } - .controls { grid-row: 1; border-right: 0; border-bottom: 1px solid var(--border); border-left: 0; } - .stage { padding-top: 22px; } -} diff --git a/native/sender/internal/media/audio.go b/native/sender/internal/media/audio.go deleted file mode 100644 index 513816d..0000000 --- a/native/sender/internal/media/audio.go +++ /dev/null @@ -1,178 +0,0 @@ -package media - -import ( - "context" - "errors" - "fmt" - "math" - "sync" - - "github.com/pion/rtp" - "github.com/pion/rtp/codecs" - "github.com/pion/webrtc/v4" -) - -const ( - audioClockRate = 48_000 - audioQueueDepth = 16 - OpusSDPFmtpLine = "minptime=10;useinbandfec=1" -) - -type AudioMetrics struct { - PacketsWritten uint64 `json:"packetsWritten"` - SourceRTPPacketsWritten uint64 `json:"sourceRtpPacketsWritten"` - SourceRTPBytesWritten uint64 `json:"sourceRtpBytesWritten"` - LastSourceTimestamp100ns uint64 `json:"lastSourceTimestamp100ns"` -} - -type AudioFanout struct { - track *webrtc.TrackLocalStaticRTP - writer rtpWriter - queue chan Packet - ctx context.Context - cancel context.CancelFunc - done chan struct{} - closeOnce sync.Once - fatalOnce sync.Once - onFatal func(error) - metricsMu sync.Mutex - metrics AudioMetrics -} - -func NewAudioFanout(onFatal func(error)) (*AudioFanout, error) { - capability := webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeOpus, ClockRate: audioClockRate, Channels: 2, SDPFmtpLine: OpusSDPFmtpLine, - } - track, err := webrtc.NewTrackLocalStaticRTP(capability, "audio", "screener") - if err != nil { - return nil, fmt.Errorf("create shared Opus track: %w", err) - } - return newAudioFanout(track, track, onFatal), nil -} - -func newAudioFanout(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, onFatal func(error)) *AudioFanout { - ctx, cancel := context.WithCancel(context.Background()) - fanout := &AudioFanout{ - track: track, writer: writer, queue: make(chan Packet, audioQueueDepth), - ctx: ctx, cancel: cancel, done: make(chan struct{}), onFatal: onFatal, - } - go fanout.writeLoop() - return fanout -} - -func (fanout *AudioFanout) Track() *webrtc.TrackLocalStaticRTP { return fanout.track } - -func (fanout *AudioFanout) Push(packet Packet) error { - if packet.Kind != KindOpus { - return errors.New("shared audio fanout requires Opus") - } - if err := validatePacket(packet); err != nil { - return err - } - select { - case <-fanout.ctx.Done(): - return errors.New("shared audio fanout is closed") - case fanout.queue <- packet: - return nil - default: - return errors.New("shared audio queue exceeded its bound") - } -} - -func (fanout *AudioFanout) Snapshot() AudioMetrics { - fanout.metricsMu.Lock() - defer fanout.metricsMu.Unlock() - return fanout.metrics -} - -func (fanout *AudioFanout) Close() { - fanout.stop() - <-fanout.done -} - -func (fanout *AudioFanout) writeLoop() { - defer close(fanout.done) - timeline := newAudioTimeline() - for { - select { - case <-fanout.ctx.Done(): - return - case packet := <-fanout.queue: - packets, err := timeline.Packetize(packet) - if err != nil { - fanout.fail(err) - return - } - var sourceBytes uint64 - for _, rtpPacket := range packets { - if err = fanout.writer.WriteRTP(rtpPacket); err != nil { - fanout.fail(errors.New("write shared Opus RTP packet failed")) - return - } - sourceBytes += uint64(rtpPacket.MarshalSize()) - } - fanout.metricsMu.Lock() - fanout.metrics.PacketsWritten++ - fanout.metrics.SourceRTPPacketsWritten += uint64(len(packets)) - fanout.metrics.SourceRTPBytesWritten += sourceBytes - fanout.metrics.LastSourceTimestamp100ns = packet.Timestamp100ns - fanout.metricsMu.Unlock() - } - } -} - -func (fanout *AudioFanout) fail(err error) { - fanout.fatalOnce.Do(func() { - fanout.stop() - if fanout.onFatal != nil { - go func() { <-fanout.done; fanout.onFatal(err) }() - } - }) -} - -func (fanout *AudioFanout) stop() { fanout.closeOnce.Do(fanout.cancel) } - -type audioTimeline struct { - packetizer rtp.Packetizer - hasPrevious bool - previousTimestamp uint64 -} - -func newAudioTimeline() *audioTimeline { - return &audioTimeline{packetizer: rtp.NewPacketizer( - 1200, 111, 0, &codecs.OpusPayloader{}, rtp.NewRandomSequencer(), audioClockRate, - )} -} - -func (timeline *audioTimeline) Packetize(packet Packet) ([]*rtp.Packet, error) { - if timeline.hasPrevious { - if packet.Timestamp100ns <= timeline.previousTimestamp { - return nil, errors.New("encoded audio timestamp did not increase") - } - delta, err := audioSamples(packet.Timestamp100ns - timeline.previousTimestamp) - if err != nil { - return nil, err - } - if delta == 0 { - delta = 1 - } - timeline.packetizer.SkipSamples(delta) - } - packets := timeline.packetizer.Packetize(packet.Data, 0) - if len(packets) == 0 { - return nil, errors.New("Opus access unit exceeded the RTP payload bound") - } - timeline.hasPrevious = true - timeline.previousTimestamp = packet.Timestamp100ns - return packets, nil -} - -func audioSamples(value100ns uint64) (uint32, error) { - seconds := value100ns / 10_000_000 - remainder := value100ns % 10_000_000 - samples := seconds*audioClockRate + (remainder*audioClockRate+5_000_000)/10_000_000 - if samples > math.MaxUint32 { - return 0, errors.New("encoded audio timestamp gap is too large") - } - return uint32(samples), nil -} diff --git a/native/sender/internal/media/audio_test.go b/native/sender/internal/media/audio_test.go deleted file mode 100644 index 3c63ff6..0000000 --- a/native/sender/internal/media/audio_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package media - -import ( - "testing" - "time" - - "github.com/pion/rtp" - "github.com/pion/webrtc/v4" -) - -func TestAudioFanoutUsesOneOpusTimeline(t *testing.T) { - written := make(chan *rtp.Packet, 2) - fanout := newAudioFanout(nil, rtpWriterFunc(func(packet *rtp.Packet) error { - written <- packet.Clone() - return nil - }), nil) - defer fanout.Close() - - for _, timestamp := range []uint64{100_000, 300_000} { - if err := fanout.Push(Packet{ - Kind: KindOpus, Timestamp100ns: timestamp, Duration100ns: 200_000, - Data: []byte{0xf8, 0xff, 0xfe}, - }); err != nil { - t.Fatal(err) - } - } - first := awaitAudioPacket(t, written) - second := awaitAudioPacket(t, written) - if delta := second.Timestamp - first.Timestamp; delta != 960 { - t.Fatalf("Opus RTP timestamp delta = %d, want 960", delta) - } -} - -func TestOpusTrackCapabilityIsBrowserCompatible(t *testing.T) { - fanout, err := NewAudioFanout(nil) - if err != nil { - t.Fatal(err) - } - defer fanout.Close() - codec := fanout.Track().Codec() - if codec.MimeType != webrtc.MimeTypeOpus || codec.ClockRate != 48_000 || codec.Channels != 2 || - codec.SDPFmtpLine != OpusSDPFmtpLine { - t.Fatalf("Opus capability = %+v", codec) - } -} - -func awaitAudioPacket(t *testing.T, packets <-chan *rtp.Packet) *rtp.Packet { - t.Helper() - select { - case packet := <-packets: - return packet - case <-time.After(time.Second): - t.Fatal("timed out waiting for shared Opus RTP") - return nil - } -} diff --git a/native/sender/internal/media/envelope.go b/native/sender/internal/media/envelope.go deleted file mode 100644 index 87cb53a..0000000 --- a/native/sender/internal/media/envelope.go +++ /dev/null @@ -1,104 +0,0 @@ -package media - -import ( - "encoding/binary" - "errors" - "time" -) - -const ( - EnvelopeVersion = 1 - EnvelopeHeaderBytes = 20 - MaxMediaPayload = 1 << 20 - MaxPCMBytes = 48_000 * 2 * 2 / 10 // 100 ms of 48 kHz stereo s16. - - flagKeyFrame = 1 << 0 -) - -type Kind uint8 - -const ( - KindVideo Kind = 1 - KindPCM Kind = 2 - KindOpus Kind = 3 -) - -type Packet struct { - Kind Kind - KeyFrame bool - Timestamp100ns uint64 - Duration100ns uint64 - Data []byte -} - -func EncodePacket(packet Packet) ([]byte, error) { - if err := validatePacket(packet); err != nil { - return nil, err - } - payload := make([]byte, EnvelopeHeaderBytes+len(packet.Data)) - payload[0] = EnvelopeVersion - payload[1] = byte(packet.Kind) - if packet.KeyFrame { - payload[2] = flagKeyFrame - } - binary.BigEndian.PutUint64(payload[4:12], packet.Timestamp100ns) - binary.BigEndian.PutUint64(payload[12:20], packet.Duration100ns) - copy(payload[EnvelopeHeaderBytes:], packet.Data) - return payload, nil -} - -func DecodePacket(payload []byte) (Packet, error) { - if len(payload) <= EnvelopeHeaderBytes || len(payload)-EnvelopeHeaderBytes > MaxMediaPayload { - return Packet{}, errors.New("media envelope is empty, truncated, or too large") - } - if payload[0] != EnvelopeVersion || payload[3] != 0 || payload[2]&^byte(flagKeyFrame) != 0 { - return Packet{}, errors.New("media envelope header is invalid") - } - packet := Packet{ - Kind: Kind(payload[1]), - KeyFrame: payload[2]&flagKeyFrame != 0, - Timestamp100ns: binary.BigEndian.Uint64(payload[4:12]), - Duration100ns: binary.BigEndian.Uint64(payload[12:20]), - Data: append([]byte(nil), payload[EnvelopeHeaderBytes:]...), - } - if err := validatePacket(packet); err != nil { - return Packet{}, err - } - return packet, nil -} - -func (packet Packet) VideoFrame() (Frame, error) { - if packet.Kind != KindVideo { - return Frame{}, errors.New("media envelope is not encoded video") - } - return Frame{ - KeyFrame: packet.KeyFrame, - TimestampMicros: packet.Timestamp100ns / 10, - DurationMicros: packet.Duration100ns / 10, - Data: packet.Data, - }, nil -} - -func validatePacket(packet Packet) error { - if len(packet.Data) == 0 || len(packet.Data) > MaxMediaPayload || packet.Duration100ns == 0 || - packet.Duration100ns > uint64(time.Second/(100*time.Nanosecond)) { - return errors.New("media envelope violates the bounded media contract") - } - switch packet.Kind { - case KindVideo: - if packet.Duration100ns < 10 || packet.Duration100ns%10 != 0 { - return errors.New("video envelope timestamp unit is invalid") - } - case KindPCM: - if packet.KeyFrame || len(packet.Data) > MaxPCMBytes || len(packet.Data)%4 != 0 { - return errors.New("PCM envelope is invalid") - } - case KindOpus: - if packet.KeyFrame || len(packet.Data) > 64<<10 { - return errors.New("Opus envelope is invalid") - } - default: - return errors.New("media envelope kind is invalid") - } - return nil -} diff --git a/native/sender/internal/media/fanout.go b/native/sender/internal/media/fanout.go deleted file mode 100644 index ed61346..0000000 --- a/native/sender/internal/media/fanout.go +++ /dev/null @@ -1,281 +0,0 @@ -package media - -import ( - "context" - "errors" - "fmt" - "math" - "sync" - - "github.com/pion/rtp" - "github.com/pion/rtp/codecs" - "github.com/pion/webrtc/v4" -) - -const ( - queueCapacity = 8 - videoClockRate = 90_000 -) - -// Codec selects the encoded representation carried by the native bridge. -// VP8 remains the default; H.264 is an explicit Windows/WebCodecs opt-in. -type Codec string - -const ( - CodecVP8 Codec = "vp8" - CodecH264 Codec = "h264" - - H264ProfileLevelID = "42c01f" - H264SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=" + H264ProfileLevelID -) - -func ParseCodec(value string) (Codec, bool) { - switch Codec(value) { - case CodecVP8: - return CodecVP8, true - case CodecH264: - return CodecH264, true - default: - return CodecVP8, false - } -} - -type Frame struct { - KeyFrame bool - TimestampMicros uint64 - DurationMicros uint64 - Data []byte -} - -type Metrics struct { - Queue QueueMetrics `json:"queue"` - FramesWritten uint64 `json:"framesWritten"` - SourceRTPPacketsWritten uint64 `json:"sourceRtpPacketsWritten"` - SourceRTPBytesWritten uint64 `json:"sourceRtpBytesWritten"` - SourceTimestampGapMicros uint64 `json:"sourceTimestampGapMicros"` - LastSourceTimestampMicros uint64 `json:"lastSourceTimestampMicros"` -} - -type rtpWriter interface { - WriteRTP(*rtp.Packet) error -} - -type Fanout struct { - track *webrtc.TrackLocalStaticRTP - writer rtpWriter - codec Codec - queue *frameQueue - ctx context.Context - cancel context.CancelFunc - done chan struct{} - closeOnce sync.Once - fatalOnce sync.Once - requestKeyFrame func() - onFatal func(error) - metricsMu sync.Mutex - metrics Metrics -} - -func NewFanout(requestKeyFrame func(), onFatal func(error)) (*Fanout, error) { - return NewFanoutWithCodec(CodecVP8, requestKeyFrame, onFatal) -} - -func NewFanoutWithCodec(codec Codec, requestKeyFrame func(), onFatal func(error)) (*Fanout, error) { - capability, err := trackCapability(codec) - if err != nil { - return nil, err - } - track, err := webrtc.NewTrackLocalStaticRTP(capability, "screen", "screener") - if err != nil { - return nil, fmt.Errorf("create shared %s track: %w", codec, err) - } - return newFanoutWithCodec(track, track, codec, requestKeyFrame, onFatal), nil -} - -func trackCapability(codec Codec) (webrtc.RTPCodecCapability, error) { - switch codec { - case CodecVP8: - return webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: videoClockRate}, nil - case CodecH264: - return webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, ClockRate: videoClockRate, SDPFmtpLine: H264SDPFmtpLine, - }, nil - default: - return webrtc.RTPCodecCapability{}, fmt.Errorf("unsupported native codec %q", codec) - } -} - -func newFanout(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, requestKeyFrame func(), onFatal func(error)) *Fanout { - return newFanoutWithCodec(track, writer, CodecVP8, requestKeyFrame, onFatal) -} - -func newFanoutWithCodec(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, codec Codec, requestKeyFrame func(), onFatal func(error)) *Fanout { - ctx, cancel := context.WithCancel(context.Background()) - fanout := &Fanout{ - track: track, - writer: writer, - codec: codec, - queue: newFrameQueue(queueCapacity), - ctx: ctx, - cancel: cancel, - done: make(chan struct{}), - requestKeyFrame: requestKeyFrame, - onFatal: onFatal, - } - go fanout.writeLoop() - return fanout -} - -func (fanout *Fanout) Track() *webrtc.TrackLocalStaticRTP { - return fanout.track -} - -func (fanout *Fanout) Push(frame Frame) error { - if len(frame.Data) == 0 || len(frame.Data) > MaxMediaPayload || frame.DurationMicros == 0 { - return errors.New("encoded frame violates the bounded media contract") - } - result, err := fanout.queue.Push(frame) - if err != nil { - return err - } - if result.requestKeyFrame && fanout.requestKeyFrame != nil { - fanout.requestKeyFrame() - } - return nil -} - -func (fanout *Fanout) Snapshot() Metrics { - fanout.metricsMu.Lock() - metrics := fanout.metrics - fanout.metricsMu.Unlock() - metrics.Queue = fanout.queue.Snapshot() - return metrics -} - -func (fanout *Fanout) Close() { - fanout.stop() - <-fanout.done -} - -func (fanout *Fanout) writeLoop() { - defer close(fanout.done) - timeline := newFrameTimelineForCodec(fanout.codec) - for { - frame, ok, err := fanout.queue.Pop(fanout.ctx) - if err != nil { - if !errors.Is(err, context.Canceled) { - fanout.fail(fmt.Errorf("read encoded media queue: %w", err)) - } - return - } - if !ok { - return - } - packets, gapMicros, err := timeline.Packetize(frame) - if err != nil { - fanout.fail(err) - return - } - var sourceBytes uint64 - for _, packet := range packets { - if err = fanout.writer.WriteRTP(packet); err != nil { - fanout.fail(errors.New("write shared RTP packet failed")) - return - } - sourceBytes += uint64(packet.MarshalSize()) - } - fanout.queue.MarkRecoveryWritten(frame.KeyFrame) - fanout.metricsMu.Lock() - fanout.metrics.FramesWritten++ - fanout.metrics.SourceRTPPacketsWritten += uint64(len(packets)) - fanout.metrics.SourceRTPBytesWritten += sourceBytes - fanout.metrics.SourceTimestampGapMicros += gapMicros - fanout.metrics.LastSourceTimestampMicros = frame.TimestampMicros - fanout.metricsMu.Unlock() - } -} - -func (fanout *Fanout) fail(err error) { - fanout.fatalOnce.Do(func() { - fanout.stop() - if fanout.onFatal != nil { - go func() { - <-fanout.done - fanout.onFatal(err) - }() - } - }) -} - -func (fanout *Fanout) stop() { - fanout.closeOnce.Do(func() { - fanout.cancel() - fanout.queue.Close() - }) -} - -type frameTimeline struct { - packetizer rtp.Packetizer - hasPrevious bool - previousTimestampMicros uint64 - previousDurationMicros uint64 -} - -func newFrameTimeline() *frameTimeline { - return newFrameTimelineForCodec(CodecVP8) -} - -func newFrameTimelineForCodec(codec Codec) *frameTimeline { - var payloader rtp.Payloader - if codec == CodecH264 { - payloader = &codecs.H264Payloader{} - } else { - payloader = &codecs.VP8Payloader{} - } - return &frameTimeline{packetizer: rtp.NewPacketizer( - 1200, - 96, - 0, - payloader, - rtp.NewRandomSequencer(), - videoClockRate, - )} -} - -func (timeline *frameTimeline) Packetize(frame Frame) ([]*rtp.Packet, uint64, error) { - var gapMicros uint64 - // Source timestamps own RTP cadence. WebCodecs duration is nullable and can - // extend past the next live-capture timestamp, so it is diagnostic only. - if timeline.hasPrevious { - if frame.TimestampMicros <= timeline.previousTimestampMicros { - return nil, 0, errors.New("encoded frame timestamp did not increase") - } - deltaMicros := frame.TimestampMicros - timeline.previousTimestampMicros - if deltaMicros > timeline.previousDurationMicros { - gapMicros = deltaMicros - timeline.previousDurationMicros - } - deltaSamples, err := microsToSamples(deltaMicros) - if err != nil { - return nil, 0, err - } - if deltaSamples == 0 { - deltaSamples = 1 - } - timeline.packetizer.SkipSamples(deltaSamples) - } - packets := timeline.packetizer.Packetize(frame.Data, 0) - timeline.hasPrevious = true - timeline.previousTimestampMicros = frame.TimestampMicros - timeline.previousDurationMicros = frame.DurationMicros - return packets, gapMicros, nil -} - -func microsToSamples(micros uint64) (uint32, error) { - seconds := micros / 1_000_000 - remainder := micros % 1_000_000 - samples := seconds*videoClockRate + (remainder*videoClockRate+500_000)/1_000_000 - if samples > math.MaxUint32 { - return 0, errors.New("encoded frame timestamp gap is too large") - } - return uint32(samples), nil -} diff --git a/native/sender/internal/media/fanout_test.go b/native/sender/internal/media/fanout_test.go deleted file mode 100644 index 9285b95..0000000 --- a/native/sender/internal/media/fanout_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package media - -import ( - "errors" - "runtime" - "sync" - "testing" - "time" - - "github.com/pion/rtp" - "github.com/pion/webrtc/v4" -) - -func TestFrameTimelinePreservesDroppedSourceTime(t *testing.T) { - timeline := newFrameTimeline() - first, _, err := timeline.Packetize(Frame{ - KeyFrame: true, - TimestampMicros: 0, - DurationMicros: 33_333, - Data: []byte{1, 2, 3}, - }) - if err != nil { - t.Fatal(err) - } - second, gap, err := timeline.Packetize(Frame{ - TimestampMicros: 66_666, - DurationMicros: 33_333, - Data: []byte{4, 5, 6}, - }) - if err != nil { - t.Fatal(err) - } - if gap != 33_333 { - t.Fatalf("source gap = %d, want 33333 microseconds", gap) - } - if delta := second[0].Timestamp - first[0].Timestamp; delta != 6_000 { - t.Fatalf("RTP timestamp delta = %d, want 6000 samples", delta) - } -} - -func TestFrameTimelineAcceptsCaptureJitterShorterThanReportedDuration(t *testing.T) { - timeline := newFrameTimeline() - first, _, err := timeline.Packetize(Frame{ - TimestampMicros: 100_000, - DurationMicros: 33_333, - Data: []byte{1}, - }) - if err != nil { - t.Fatal(err) - } - second, gap, err := timeline.Packetize(Frame{ - TimestampMicros: 133_000, - DurationMicros: 33_333, - Data: []byte{2}, - }) - if err != nil { - t.Fatal(err) - } - if gap != 0 { - t.Fatalf("capture jitter reported a source gap of %d microseconds", gap) - } - if delta := second[0].Timestamp - first[0].Timestamp; delta != 2_970 { - t.Fatalf("RTP timestamp delta = %d, want 2970 samples", delta) - } -} - -func TestFrameTimelineRejectsNonIncreasingSourceTime(t *testing.T) { - for _, timestamp := range []uint64{100, 99} { - timeline := newFrameTimeline() - if _, _, err := timeline.Packetize(Frame{ - TimestampMicros: 100, - DurationMicros: 1, - Data: []byte{1}, - }); err != nil { - t.Fatal(err) - } - if _, _, err := timeline.Packetize(Frame{ - TimestampMicros: timestamp, - DurationMicros: 1, - Data: []byte{2}, - }); err == nil { - t.Fatalf("non-increasing source timestamp %d was accepted", timestamp) - } - } -} - -func TestFrameTimelineAdvancesForPositiveSubsampleDelta(t *testing.T) { - timeline := newFrameTimeline() - first, _, err := timeline.Packetize(Frame{TimestampMicros: 1, DurationMicros: 1, Data: []byte{1}}) - if err != nil { - t.Fatal(err) - } - second, _, err := timeline.Packetize(Frame{TimestampMicros: 2, DurationMicros: 1, Data: []byte{2}}) - if err != nil { - t.Fatal(err) - } - if delta := second[0].Timestamp - first[0].Timestamp; delta != 1 { - t.Fatalf("RTP timestamp delta = %d, want 1 sample", delta) - } -} - -func TestH264TimelinePacketizesAnnexBWithInBandParameterSets(t *testing.T) { - timeline := newFrameTimelineForCodec(CodecH264) - accessUnit := []byte{ - 0, 0, 0, 1, 0x09, 0xf0, - 0, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1f, - 0, 0, 0, 1, 0x68, 0xce, 0x06, 0xe2, - 0, 0, 0, 1, 0x65, 0x88, 0x84, 0x21, - } - packets, _, err := timeline.Packetize(Frame{ - KeyFrame: true, TimestampMicros: 1, DurationMicros: 33_333, Data: accessUnit, - }) - if err != nil { - t.Fatal(err) - } - if len(packets) < 2 { - t.Fatalf("H.264 access unit yielded %d RTP packets", len(packets)) - } - if packets[0].Payload[0]&0x1f != 24 { - t.Fatalf("first H.264 payload type = %d, want STAP-A (24)", packets[0].Payload[0]&0x1f) - } -} - -func TestH264TrackCapabilityUsesFixtureProfile(t *testing.T) { - capability, err := trackCapability(CodecH264) - if err != nil { - t.Fatal(err) - } - if capability.MimeType != webrtc.MimeTypeH264 || capability.SDPFmtpLine != H264SDPFmtpLine { - t.Fatalf("H.264 capability = %+v", capability) - } -} - -func TestFanoutPropagatesWriterFailure(t *testing.T) { - written := make(chan struct{}, 1) - fatal := make(chan error, 1) - fanout := newFanout(nil, rtpWriterFunc(func(*rtp.Packet) error { - written <- struct{}{} - return errors.New("write failed") - }), nil, func(err error) { fatal <- err }) - - if err := fanout.Push(Frame{ - KeyFrame: true, - TimestampMicros: 1, - DurationMicros: 33_333, - Data: []byte{1}, - }); err != nil { - t.Fatal(err) - } - select { - case <-written: - case <-time.After(time.Second): - t.Fatal("writer was not called") - } - select { - case err := <-fatal: - if err == nil || err.Error() != "write shared RTP packet failed" { - t.Fatalf("fatal error = %v", err) - } - case <-time.After(time.Second): - t.Fatal("writer failure was not propagated") - } - fanout.Close() -} - -func TestFanoutCloseDropsBacklogWithoutLateWritesOrFatal(t *testing.T) { - writer := &blockingWriter{started: make(chan struct{}), release: make(chan struct{})} - fatal := make(chan error, 1) - fanout := newFanout(nil, writer, nil, func(err error) { fatal <- err }) - - if err := fanout.Push(Frame{KeyFrame: true, TimestampMicros: 1, DurationMicros: 1, Data: []byte{1}}); err != nil { - t.Fatal(err) - } - <-writer.started - for timestamp := uint64(2); timestamp <= 5; timestamp++ { - if err := fanout.Push(Frame{TimestampMicros: timestamp, DurationMicros: 1, Data: []byte{1}}); err != nil { - t.Fatal(err) - } - } - - closed := make(chan struct{}) - go func() { - fanout.Close() - close(closed) - }() - deadline := time.Now().Add(time.Second) - for fanout.Snapshot().Queue.ShutdownDroppedFrames != 4 { - if time.Now().After(deadline) { - t.Fatal("fanout close did not discard the queued backlog") - } - runtime.Gosched() - } - close(writer.release) - select { - case <-closed: - case <-time.After(time.Second): - t.Fatal("fanout close did not wait for the writer loop") - } - if writes := writer.Writes(); writes != 1 { - t.Fatalf("writes after close = %d, want only the in-flight write", writes) - } - select { - case err := <-fatal: - t.Fatalf("close reported a fatal error: %v", err) - default: - } -} - -func TestTypedEnvelopePreservesVideoTimeline(t *testing.T) { - payload, err := EncodePacket(Packet{ - Kind: KindVideo, KeyFrame: true, Timestamp100ns: 1_230, - Duration100ns: 333_330, Data: []byte{1, 2, 3}, - }) - if err != nil { - t.Fatal(err) - } - packet, err := DecodePacket(payload) - if err != nil { - t.Fatal(err) - } - frame, err := packet.VideoFrame() - if err != nil { - t.Fatal(err) - } - if !frame.KeyFrame || frame.TimestampMicros != 123 || frame.DurationMicros != 33_333 { - t.Fatalf("decoded frame = %+v", frame) - } -} - -type rtpWriterFunc func(*rtp.Packet) error - -func (write rtpWriterFunc) WriteRTP(packet *rtp.Packet) error { - return write(packet) -} - -type blockingWriter struct { - mu sync.Mutex - started chan struct{} - release chan struct{} - writes int - once sync.Once -} - -func (writer *blockingWriter) WriteRTP(*rtp.Packet) error { - writer.once.Do(func() { close(writer.started) }) - <-writer.release - writer.mu.Lock() - writer.writes++ - writer.mu.Unlock() - return nil -} - -func (writer *blockingWriter) Writes() int { - writer.mu.Lock() - defer writer.mu.Unlock() - return writer.writes -} diff --git a/native/sender/internal/media/queue.go b/native/sender/internal/media/queue.go deleted file mode 100644 index 42ad48c..0000000 --- a/native/sender/internal/media/queue.go +++ /dev/null @@ -1,157 +0,0 @@ -package media - -import ( - "context" - "errors" - "sync" -) - -type QueueMetrics struct { - Capacity int `json:"capacity"` - Depth int `json:"depth"` - MaxDepth int `json:"maxDepth"` - AcceptedFrames uint64 `json:"acceptedFrames"` - DroppedFrames uint64 `json:"droppedFrames"` - ShutdownDroppedFrames uint64 `json:"shutdownDroppedFrames"` - OverloadEvents uint64 `json:"overloadEvents"` - RecoveryKeyFrames uint64 `json:"recoveryKeyFrames"` - CompletedRecoveries uint64 `json:"completedRecoveries"` - UnrecoverableOverload uint64 `json:"unrecoverableOverload"` -} - -type queuePushResult struct { - accepted bool - requestKeyFrame bool -} - -// frameQueue owns the only helper-side media backlog. After an overload it -// discards dependent frames until a key frame restores a decodable boundary. -type frameQueue struct { - mu sync.Mutex - items []Frame - capacity int - droppingUntilKeyFrame bool - recoveryPending bool - closed bool - notify chan struct{} - metrics QueueMetrics -} - -func newFrameQueue(capacity int) *frameQueue { - if capacity < 1 { - panic("frame queue capacity must be positive") - } - return &frameQueue{ - capacity: capacity, - notify: make(chan struct{}, 1), - metrics: QueueMetrics{Capacity: capacity}, - } -} - -func (queue *frameQueue) Push(frame Frame) (queuePushResult, error) { - queue.mu.Lock() - defer queue.mu.Unlock() - if queue.closed { - return queuePushResult{}, errors.New("encoded media queue is closed") - } - - if queue.droppingUntilKeyFrame { - if !frame.KeyFrame { - queue.metrics.DroppedFrames++ - return queuePushResult{}, nil - } - queue.droppingUntilKeyFrame = false - } - - requestKeyFrame := false - if len(queue.items) >= queue.capacity { - if queue.recoveryPending { - queue.metrics.UnrecoverableOverload++ - return queuePushResult{}, errors.New("encoded media queue overloaded before key-frame recovery completed") - } - queue.metrics.DroppedFrames += uint64(len(queue.items)) - queue.metrics.OverloadEvents++ - queue.metrics.RecoveryKeyFrames++ - clear(queue.items) - queue.items = queue.items[:0] - queue.droppingUntilKeyFrame = true - queue.recoveryPending = true - requestKeyFrame = true - if !frame.KeyFrame { - queue.metrics.DroppedFrames++ - return queuePushResult{requestKeyFrame: true}, nil - } - queue.droppingUntilKeyFrame = false - } - - frame.Data = append([]byte(nil), frame.Data...) - queue.items = append(queue.items, frame) - queue.metrics.AcceptedFrames++ - if len(queue.items) > queue.metrics.MaxDepth { - queue.metrics.MaxDepth = len(queue.items) - } - queue.signal() - return queuePushResult{accepted: true, requestKeyFrame: requestKeyFrame}, nil -} - -func (queue *frameQueue) Pop(ctx context.Context) (Frame, bool, error) { - for { - queue.mu.Lock() - if len(queue.items) > 0 { - frame := queue.items[0] - queue.items[0] = Frame{} - queue.items = queue.items[1:] - queue.mu.Unlock() - return frame, true, nil - } - if queue.closed { - queue.mu.Unlock() - return Frame{}, false, nil - } - queue.mu.Unlock() - - select { - case <-queue.notify: - case <-ctx.Done(): - return Frame{}, false, ctx.Err() - } - } -} - -func (queue *frameQueue) MarkRecoveryWritten(keyFrame bool) { - if !keyFrame { - return - } - queue.mu.Lock() - defer queue.mu.Unlock() - if queue.recoveryPending { - queue.recoveryPending = false - queue.metrics.CompletedRecoveries++ - } -} - -func (queue *frameQueue) Close() { - queue.mu.Lock() - queue.metrics.ShutdownDroppedFrames += uint64(len(queue.items)) - queue.metrics.DroppedFrames += uint64(len(queue.items)) - clear(queue.items) - queue.items = nil - queue.closed = true - queue.mu.Unlock() - queue.signal() -} - -func (queue *frameQueue) Snapshot() QueueMetrics { - queue.mu.Lock() - defer queue.mu.Unlock() - metrics := queue.metrics - metrics.Depth = len(queue.items) - return metrics -} - -func (queue *frameQueue) signal() { - select { - case queue.notify <- struct{}{}: - default: - } -} diff --git a/native/sender/internal/media/queue_test.go b/native/sender/internal/media/queue_test.go deleted file mode 100644 index 340aefa..0000000 --- a/native/sender/internal/media/queue_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package media - -import ( - "context" - "strings" - "testing" -) - -func TestFrameQueueRecoversAtAKeyFrameAfterOneOverload(t *testing.T) { - queue := newFrameQueue(2) - defer queue.Close() - - pushFrame(t, queue, false, 1) - pushFrame(t, queue, false, 2) - result, err := queue.Push(testFrame(false, 3)) - if err != nil { - t.Fatalf("first overload returned an error: %v", err) - } - if result.accepted || !result.requestKeyFrame { - t.Fatalf("first overload result = %+v, want dropped frame and key-frame request", result) - } - result, err = queue.Push(testFrame(false, 4)) - if err != nil || result.accepted || result.requestKeyFrame { - t.Fatalf("recovery delta result = %+v, %v", result, err) - } - result, err = queue.Push(testFrame(true, 5)) - if err != nil || !result.accepted { - t.Fatalf("recovery key frame result = %+v, %v", result, err) - } - - frame, ok, err := queue.Pop(context.Background()) - if err != nil || !ok || !frame.KeyFrame || frame.TimestampMicros != 5 { - t.Fatalf("recovery pop = %+v, %t, %v", frame, ok, err) - } - queue.MarkRecoveryWritten(true) - - metrics := queue.Snapshot() - if metrics.OverloadEvents != 1 || metrics.RecoveryKeyFrames != 1 || - metrics.CompletedRecoveries != 1 || metrics.DroppedFrames != 4 { - t.Fatalf("unexpected recovery metrics: %+v", metrics) - } -} - -func TestFrameQueueFailsOnASecondOverloadBeforeRecoveryIsWritten(t *testing.T) { - queue := newFrameQueue(1) - defer queue.Close() - - pushFrame(t, queue, false, 1) - result, err := queue.Push(testFrame(true, 2)) - if err != nil || !result.accepted || !result.requestKeyFrame { - t.Fatalf("first overload result = %+v, %v", result, err) - } - _, err = queue.Push(testFrame(false, 3)) - if err == nil || !strings.Contains(err.Error(), "before key-frame recovery completed") { - t.Fatalf("second overload error = %v", err) - } - if queue.Snapshot().UnrecoverableOverload != 1 { - t.Fatalf("unrecoverable overload was not counted: %+v", queue.Snapshot()) - } -} - -func TestFrameQueueCloseDiscardsBacklog(t *testing.T) { - queue := newFrameQueue(2) - pushFrame(t, queue, true, 1) - pushFrame(t, queue, false, 2) - queue.Close() - - _, ok, err := queue.Pop(context.Background()) - if err != nil || ok { - t.Fatalf("closed queue pop = %t, %v; want no frame", ok, err) - } - metrics := queue.Snapshot() - if metrics.ShutdownDroppedFrames != 2 || metrics.DroppedFrames != 2 { - t.Fatalf("shutdown drops = %+v, want 2", metrics) - } -} - -func pushFrame(t *testing.T, queue *frameQueue, keyFrame bool, timestamp uint64) { - t.Helper() - result, err := queue.Push(testFrame(keyFrame, timestamp)) - if err != nil || !result.accepted { - t.Fatalf("push frame %d = %+v, %v", timestamp, result, err) - } -} - -func testFrame(keyFrame bool, timestamp uint64) Frame { - return Frame{ - KeyFrame: keyFrame, - TimestampMicros: timestamp, - DurationMicros: 1, - Data: []byte{byte(timestamp)}, - } -} diff --git a/native/sender/internal/remote/admission.go b/native/sender/internal/remote/admission.go deleted file mode 100644 index 90684ee..0000000 --- a/native/sender/internal/remote/admission.go +++ /dev/null @@ -1,103 +0,0 @@ -package remote - -type admissionAction uint8 - -const ( - admissionDuplicate admissionAction = iota - admissionActivate - admissionWait - admissionFull -) - -type viewerAdmission struct { - edgeLimit int - roomLimit int - active map[string]struct{} - waiting []string - waitSet map[string]struct{} -} - -func newViewerAdmission(maxViewers, endpointMediaCopyCapacity int) *viewerAdmission { - edgeLimit := endpointMediaCopyCapacity - if maxViewers < edgeLimit { - edgeLimit = maxViewers - } - return &viewerAdmission{ - edgeLimit: edgeLimit, - roomLimit: maxViewers, - active: make(map[string]struct{}), - waitSet: make(map[string]struct{}), - } -} - -func (admission *viewerAdmission) Activate(peerID string) admissionAction { - if _, exists := admission.active[peerID]; exists { - return admissionDuplicate - } - _, wasWaiting := admission.waitSet[peerID] - if !wasWaiting && len(admission.active)+len(admission.waiting) >= admission.roomLimit { - return admissionFull - } - if len(admission.active) >= admission.edgeLimit { - return admissionFull - } - if wasWaiting { - admission.removeWaiting(peerID) - } - admission.active[peerID] = struct{}{} - return admissionActivate -} - -func (admission *viewerAdmission) Wait(peerID string) admissionAction { - if _, exists := admission.waitSet[peerID]; exists { - return admissionDuplicate - } - _, wasActive := admission.active[peerID] - if !wasActive && len(admission.active)+len(admission.waiting) >= admission.roomLimit { - return admissionFull - } - if wasActive { - delete(admission.active, peerID) - } - admission.waiting = append(admission.waiting, peerID) - admission.waitSet[peerID] = struct{}{} - return admissionWait -} - -func (admission *viewerAdmission) Leave(peerID string) (wasActive bool) { - if _, exists := admission.active[peerID]; exists { - delete(admission.active, peerID) - return true - } - if _, exists := admission.waitSet[peerID]; !exists { - return false - } - admission.removeWaiting(peerID) - return false -} - -func (admission *viewerAdmission) removeWaiting(peerID string) { - delete(admission.waitSet, peerID) - for index, waitingID := range admission.waiting { - if waitingID != peerID { - continue - } - copy(admission.waiting[index:], admission.waiting[index+1:]) - admission.waiting[len(admission.waiting)-1] = "" - admission.waiting = admission.waiting[:len(admission.waiting)-1] - break - } -} - -func (admission *viewerAdmission) AbortActivation(peerID string) { - delete(admission.active, peerID) -} - -func (admission *viewerAdmission) Counts() (active, waiting int) { - return len(admission.active), len(admission.waiting) -} - -func (admission *viewerAdmission) IsActive(peerID string) bool { - _, exists := admission.active[peerID] - return exists -} diff --git a/native/sender/internal/remote/admission_test.go b/native/sender/internal/remote/admission_test.go deleted file mode 100644 index e21d604..0000000 --- a/native/sender/internal/remote/admission_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package remote - -import "testing" - -func TestViewerAdmissionMirrorsAuthoritativeActiveAndWaitingSets(t *testing.T) { - admission := newViewerAdmission(4, 2) - assertActiveAdmission(t, admission, "viewer-a", admissionActivate) - assertActiveAdmission(t, admission, "viewer-b", admissionActivate) - assertWaitingAdmission(t, admission, "viewer-c", admissionWait) - assertWaitingAdmission(t, admission, "viewer-d", admissionWait) - assertWaitingAdmission(t, admission, "viewer-c", admissionDuplicate) - assertWaitingAdmission(t, admission, "viewer-e", admissionFull) - - active, waiting := admission.Counts() - if active != 2 || waiting != 2 { - t.Fatalf("counts = %d active, %d waiting", active, waiting) - } - if !admission.Leave("viewer-b") { - t.Fatal("active leave was not recorded") - } - if admission.IsActive("viewer-b") || admission.IsActive("viewer-c") { - t.Fatal("leave promoted a Viewer without server authority") - } - active, waiting = admission.Counts() - if active != 1 || waiting != 2 { - t.Fatalf("post-leave counts = %d active, %d waiting", active, waiting) - } - assertActiveAdmission(t, admission, "viewer-c", admissionActivate) - active, waiting = admission.Counts() - if active != 2 || waiting != 1 { - t.Fatalf("server promotion counts = %d active, %d waiting", active, waiting) - } -} - -func TestViewerAdmissionRemovesAWaitingViewerWithoutPromotion(t *testing.T) { - admission := newViewerAdmission(3, 2) - assertActiveAdmission(t, admission, "viewer-a", admissionActivate) - assertActiveAdmission(t, admission, "viewer-b", admissionActivate) - assertWaitingAdmission(t, admission, "viewer-c", admissionWait) - - if admission.Leave("viewer-c") { - t.Fatal("waiting leave was reported as active") - } - active, waiting := admission.Counts() - if active != 2 || waiting != 0 { - t.Fatalf("counts = %d active, %d waiting", active, waiting) - } -} - -func TestViewerAdmissionUsesTheSmallerRoomLimit(t *testing.T) { - admission := newViewerAdmission(1, 3) - assertActiveAdmission(t, admission, "viewer-a", admissionActivate) - assertWaitingAdmission(t, admission, "viewer-b", admissionFull) - active, waiting := admission.Counts() - if active != 1 || waiting != 0 { - t.Fatalf("counts = %d active, %d waiting", active, waiting) - } -} - -func TestViewerAdmissionClearsFailedActivation(t *testing.T) { - admission := newViewerAdmission(4, 2) - assertActiveAdmission(t, admission, "viewer-a", admissionActivate) - assertActiveAdmission(t, admission, "viewer-b", admissionActivate) - assertWaitingAdmission(t, admission, "viewer-c", admissionWait) - assertWaitingAdmission(t, admission, "viewer-d", admissionWait) - - admission.Leave("viewer-a") - assertActiveAdmission(t, admission, "viewer-c", admissionActivate) - admission.AbortActivation("viewer-c") - if admission.IsActive("viewer-c") || admission.IsActive("viewer-d") { - t.Fatal("failed activation left a viewer in the active set") - } - active, waiting := admission.Counts() - if active != 1 || waiting != 1 { - t.Fatalf("counts = %d active, %d waiting", active, waiting) - } -} - -func TestViewerAdmissionNeverActivatesAWaitingViewerIntoAFreeSlot(t *testing.T) { - admission := newViewerAdmission(4, 2) - assertWaitingAdmission(t, admission, "viewer-a", admissionWait) - active, waiting := admission.Counts() - if active != 0 || waiting != 1 || admission.IsActive("viewer-a") { - t.Fatalf("waiting authority = %d active, %d waiting", active, waiting) - } -} - -func assertActiveAdmission(t *testing.T, admission *viewerAdmission, peerID string, want admissionAction) { - t.Helper() - if got := admission.Activate(peerID); got != want { - t.Fatalf("activate %q = %d, want %d", peerID, got, want) - } -} - -func assertWaitingAdmission(t *testing.T, admission *viewerAdmission, peerID string, want admissionAction) { - t.Helper() - if got := admission.Wait(peerID); got != want { - t.Fatalf("wait %q = %d, want %d", peerID, got, want) - } -} diff --git a/native/sender/internal/remote/codec.go b/native/sender/internal/remote/codec.go deleted file mode 100644 index 22ad94d..0000000 --- a/native/sender/internal/remote/codec.go +++ /dev/null @@ -1,56 +0,0 @@ -package remote - -import ( - "fmt" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/pion/webrtc/v4" -) - -const ( - h264PayloadType = 114 - h264RTXPayloadType = 115 -) - -// newPeerAPI keeps the default VP8 API untouched. H.264 gets a private -// MediaEngine so its fixture-derived profile is advertised only on opt-in -// sessions and cannot alter Web or ordinary Native negotiation. -func newPeerAPI(codec media.Codec) (*webrtc.API, []webrtc.RTPCodecParameters, error) { - if codec == media.CodecVP8 { - return nil, nil, nil - } - if codec != media.CodecH264 { - return nil, nil, fmt.Errorf("unsupported native codec %q", codec) - } - - engine := &webrtc.MediaEngine{} - if err := engine.RegisterDefaultCodecs(); err != nil { - return nil, nil, fmt.Errorf("register default codecs: %w", err) - } - feedback := []webrtc.RTCPFeedback{ - {Type: "ccm", Parameter: "fir"}, - {Type: "nack"}, - {Type: "nack", Parameter: "pli"}, - } - primary := webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, ClockRate: 90_000, - SDPFmtpLine: media.H264SDPFmtpLine, RTCPFeedback: feedback, - }, - PayloadType: h264PayloadType, - } - rtx := webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, ClockRate: 90_000, - SDPFmtpLine: fmt.Sprintf("apt=%d", h264PayloadType), - }, - PayloadType: h264RTXPayloadType, - } - if err := engine.RegisterCodec(primary, webrtc.RTPCodecTypeVideo); err != nil { - return nil, nil, fmt.Errorf("register H.264 codec: %w", err) - } - if err := engine.RegisterCodec(rtx, webrtc.RTPCodecTypeVideo); err != nil { - return nil, nil, fmt.Errorf("register H.264 RTX codec: %w", err) - } - return webrtc.NewAPI(webrtc.WithMediaEngine(engine)), []webrtc.RTPCodecParameters{primary, rtx}, nil -} diff --git a/native/sender/internal/remote/codec_test.go b/native/sender/internal/remote/codec_test.go deleted file mode 100644 index 1df62d7..0000000 --- a/native/sender/internal/remote/codec_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package remote - -import ( - "strings" - "testing" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/pion/webrtc/v4" -) - -func TestH264PeerAPIRegistersFixtureProfileAndRTX(t *testing.T) { - api, preferences, err := newPeerAPI(media.CodecH264) - if err != nil { - t.Fatal(err) - } - if api == nil || len(preferences) != 2 { - t.Fatalf("H.264 API/preferences = %v/%d", api != nil, len(preferences)) - } - if preferences[0].MimeType != webrtc.MimeTypeH264 || - preferences[0].SDPFmtpLine != media.H264SDPFmtpLine || - preferences[1].MimeType != webrtc.MimeTypeRTX || preferences[1].SDPFmtpLine != "apt=114" { - t.Fatalf("H.264 preferences = %+v", preferences) - } - if defaultAPI, defaultPreferences, err := newPeerAPI(media.CodecVP8); err != nil || defaultAPI != nil || defaultPreferences != nil { - t.Fatalf("VP8 unexpectedly changed API boundary: %v/%v/%v", defaultAPI, defaultPreferences, err) - } -} - -func TestH264PeerAPIOffersFixtureProfileWhenTrackIsBound(t *testing.T) { - api, preferences, err := newPeerAPI(media.CodecH264) - if err != nil { - t.Fatal(err) - } - fanout, err := media.NewFanoutWithCodec(media.CodecH264, nil, nil) - if err != nil { - t.Fatal(err) - } - defer fanout.Close() - connection, err := api.NewPeerConnection(webrtc.Configuration{}) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - if _, err = connection.AddTrack(fanout.Track()); err != nil { - t.Fatal(err) - } - if err = connection.GetTransceivers()[0].SetCodecPreferences(preferences); err != nil { - t.Fatal(err) - } - offer, err := connection.CreateOffer(nil) - if err != nil { - t.Fatal(err) - } - if !containsAll(offer.SDP, []string{"H264/90000", "profile-level-id=42c01f", "packetization-mode=1"}) { - t.Fatalf("H.264 offer omitted fixture profile: %s", offer.SDP) - } -} - -func containsAll(value string, needles []string) bool { - for _, needle := range needles { - if !strings.Contains(value, needle) { - return false - } - } - return true -} diff --git a/native/sender/internal/remote/peer.go b/native/sender/internal/remote/peer.go deleted file mode 100644 index 57c85a1..0000000 --- a/native/sender/internal/remote/peer.go +++ /dev/null @@ -1,320 +0,0 @@ -package remote - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/pion/rtcp" - "github.com/pion/webrtc/v4" -) - -type peer struct { - session *Session - peerID string - slot int - connectionID string - connection *webrtc.PeerConnection - - mu sync.Mutex - pendingCandidates []webrtc.ICECandidateInit - remoteDescription bool - closeOnce sync.Once - firstConnected sync.Once - closed atomic.Bool - pliRequests atomic.Uint64 - firRequests atomic.Uint64 - diagnosticsMu sync.Mutex - lastSampleAt time.Time - lastPacketsSent uint64 - lastBytesSent uint64 -} - -type PeerDiagnostics struct { - Slot int `json:"slot"` - ConnectionState string `json:"connectionState"` - ICEConnectionState string `json:"iceConnectionState"` - Route string `json:"route"` - PacketsSent uint64 `json:"packetsSent"` - BytesSent uint64 `json:"bytesSent"` - BitrateBPS *uint64 `json:"bitrateBps,omitempty"` - CurrentRoundTripTimeMS *uint64 `json:"currentRoundTripTimeMs,omitempty"` - AvailableOutgoingBitrate *uint64 `json:"availableOutgoingBitrate,omitempty"` - PLIRequests uint64 `json:"pliRequests"` - FIRRequests uint64 `json:"firRequests"` -} - -func newPeer(session *Session, peerID string, slot int, configuration webrtc.Configuration) (*peer, error) { - connectionID, err := opaqueID() - if err != nil { - return nil, fmt.Errorf("create connection identity: %w", err) - } - var connection *webrtc.PeerConnection - if session.peerAPI != nil { - connection, err = session.peerAPI.NewPeerConnection(configuration) - } else { - connection, err = webrtc.NewPeerConnection(configuration) - } - if err != nil { - return nil, errors.New("create viewer peer connection failed") - } - peer := &peer{ - session: session, - peerID: peerID, - slot: slot, - connectionID: connectionID, - connection: connection, - } - connection.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if peer.closed.Load() { - return - } - payload := outboundCandidatePayload{Kind: "candidate", ConnectionID: connectionID} - if candidate != nil { - init := candidate.ToJSON() - payload.Candidate = &iceCandidate{ - Candidate: init.Candidate, - SDPMid: init.SDPMid, - SDPMLineIndex: init.SDPMLineIndex, - UsernameFragment: init.UsernameFragment, - } - } - if sendErr := session.sendSignal(peerID, payload); sendErr != nil && !session.closing.Load() { - go session.fail(errors.New("send viewer ICE candidate failed")) - } - }) - connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { - if !peer.closed.Load() { - session.emit(Event{Kind: "peer-state", Message: fmt.Sprintf("Media edge %d is %s", slot, state.String())}) - if state == webrtc.PeerConnectionStateConnected { - peer.firstConnected.Do(func() { session.emit(Event{Kind: "request-keyframe"}) }) - } - } - }) - videoSender, err := connection.AddTrack(session.fanout.Track()) - if err != nil { - _ = connection.Close() - return nil, errors.New("bind shared encoded track failed") - } - var audioSender *webrtc.RTPSender - if session.audioFanout != nil { - audioSender, err = connection.AddTrack(session.audioFanout.Track()) - if err != nil { - _ = connection.Close() - return nil, errors.New("bind shared Opus track failed") - } - } - if session.codec == media.CodecH264 { - var videoTransceiver *webrtc.RTPTransceiver - for _, transceiver := range connection.GetTransceivers() { - if transceiver.Kind() == webrtc.RTPCodecTypeVideo { - videoTransceiver = transceiver - break - } - } - if videoTransceiver == nil || len(session.codecPreferences) == 0 || - videoTransceiver.SetCodecPreferences(session.codecPreferences) != nil { - _ = connection.Close() - return nil, errors.New("configure H.264 codec preferences failed") - } - } - go peer.readRTCP(videoSender) - if audioSender != nil { - go peer.readRTCP(audioSender) - } - return peer, nil -} - -func (peer *peer) offer(restart bool) error { - offer, err := peer.connection.CreateOffer(&webrtc.OfferOptions{ICERestart: restart}) - if err != nil { - return errors.New("create viewer offer failed") - } - if err = peer.connection.SetLocalDescription(offer); err != nil { - return errors.New("set viewer offer failed") - } - local := peer.connection.LocalDescription() - if local == nil { - return errors.New("viewer offer is unavailable") - } - if err = peer.session.sendSignal(peer.peerID, outboundDescriptionPayload{ - Kind: "description", - ConnectionID: peer.connectionID, - Description: sessionDescription{ - Type: "offer", - SDP: local.SDP, - }, - }); err != nil { - return errors.New("send viewer offer failed") - } - return nil -} - -func (peer *peer) restartICE() error { - return peer.offer(true) -} - -func (peer *peer) acceptSignal(payload inboundSignalPayload) error { - if payload.ConnectionID != peer.connectionID { - return nil - } - switch payload.Kind { - case "description": - if payload.Description == nil || payload.Description.Type != "answer" || payload.Description.SDP == "" { - return errors.New("viewer returned an invalid answer") - } - if err := peer.connection.SetRemoteDescription(webrtc.SessionDescription{ - Type: webrtc.SDPTypeAnswer, - SDP: payload.Description.SDP, - }); err != nil { - return errors.New("apply viewer answer failed") - } - peer.mu.Lock() - peer.remoteDescription = true - pending := append([]webrtc.ICECandidateInit(nil), peer.pendingCandidates...) - peer.pendingCandidates = nil - peer.mu.Unlock() - for _, candidate := range pending { - if err := peer.connection.AddICECandidate(candidate); err != nil { - return errors.New("apply queued viewer ICE candidate failed") - } - } - return nil - case "candidate": - candidate := webrtc.ICECandidateInit{} - if payload.Candidate != nil { - candidate = webrtc.ICECandidateInit{ - Candidate: payload.Candidate.Candidate, - SDPMid: payload.Candidate.SDPMid, - SDPMLineIndex: payload.Candidate.SDPMLineIndex, - UsernameFragment: payload.Candidate.UsernameFragment, - } - } - peer.mu.Lock() - if !peer.remoteDescription { - if len(peer.pendingCandidates) >= 64 { - peer.mu.Unlock() - return errors.New("viewer ICE candidate queue exceeded its bound") - } - peer.pendingCandidates = append(peer.pendingCandidates, candidate) - peer.mu.Unlock() - return nil - } - peer.mu.Unlock() - if err := peer.connection.AddICECandidate(candidate); err != nil { - return errors.New("apply viewer ICE candidate failed") - } - return nil - default: - return errors.New("viewer signal kind is invalid") - } -} - -func (peer *peer) readRTCP(sender *webrtc.RTPSender) { - for { - packets, _, err := sender.ReadRTCP() - if err != nil { - return - } - for _, packet := range packets { - switch packet.(type) { - case *rtcp.PictureLossIndication: - peer.pliRequests.Add(1) - peer.session.emit(Event{Kind: "request-keyframe"}) - case *rtcp.FullIntraRequest: - peer.firRequests.Add(1) - peer.session.emit(Event{Kind: "request-keyframe"}) - } - } - } -} - -func (peer *peer) close() { - peer.closeOnce.Do(func() { - peer.closed.Store(true) - _ = peer.connection.Close() - }) -} - -func (peer *peer) snapshot(now time.Time) PeerDiagnostics { - diagnostics := PeerDiagnostics{ - Slot: peer.slot, - ConnectionState: peer.connection.ConnectionState().String(), - ICEConnectionState: peer.connection.ICEConnectionState().String(), - Route: "unknown", - PLIRequests: peer.pliRequests.Load(), - FIRRequests: peer.firRequests.Load(), - } - report := peer.connection.GetStats() - transportID := "" - for _, value := range report { - outbound, ok := value.(webrtc.OutboundRTPStreamStats) - if !ok || outbound.Kind != "video" { - continue - } - diagnostics.PacketsSent += uint64(outbound.PacketsSent) - diagnostics.BytesSent += outbound.BytesSent - if transportID == "" { - transportID = outbound.TransportID - } - } - if transport, ok := report[transportID].(webrtc.TransportStats); ok { - if pair, pairOK := report[transport.SelectedCandidatePairID].(webrtc.ICECandidatePairStats); pairOK { - diagnostics.Route = selectedRoute(report, pair) - if pair.CurrentRoundTripTime > 0 { - value := uint64(pair.CurrentRoundTripTime*1000 + 0.5) - diagnostics.CurrentRoundTripTimeMS = &value - } - if pair.AvailableOutgoingBitrate > 0 { - value := uint64(pair.AvailableOutgoingBitrate + 0.5) - diagnostics.AvailableOutgoingBitrate = &value - } - } - } - peer.diagnosticsMu.Lock() - if !peer.lastSampleAt.IsZero() && now.After(peer.lastSampleAt) && - diagnostics.BytesSent >= peer.lastBytesSent && diagnostics.PacketsSent >= peer.lastPacketsSent { - seconds := now.Sub(peer.lastSampleAt).Seconds() - value := uint64(float64(diagnostics.BytesSent-peer.lastBytesSent)*8/seconds + 0.5) - diagnostics.BitrateBPS = &value - } - peer.lastSampleAt = now - peer.lastPacketsSent = diagnostics.PacketsSent - peer.lastBytesSent = diagnostics.BytesSent - peer.diagnosticsMu.Unlock() - return diagnostics -} - -func selectedRoute(report webrtc.StatsReport, pair webrtc.ICECandidatePairStats) string { - local, localOK := report[pair.LocalCandidateID].(webrtc.ICECandidateStats) - remote, remoteOK := report[pair.RemoteCandidateID].(webrtc.ICECandidateStats) - if localOK && local.CandidateType == webrtc.ICECandidateTypeRelay || - remoteOK && remote.CandidateType == webrtc.ICECandidateTypeRelay { - return "unknown" - } - if !localOK || !remoteOK { - return "unknown" - } - return "direct-" + candidateProtocol(local) -} - -func candidateProtocol(candidate webrtc.ICECandidateStats) string { - if protocol := strings.ToLower(candidate.Protocol); protocol == "udp" || protocol == "tcp" { - return protocol - } - return "unknown" -} - -func opaqueID() (string, error) { - value := make([]byte, 16) - if _, err := rand.Read(value); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(value), nil -} diff --git a/native/sender/internal/remote/peer_test.go b/native/sender/internal/remote/peer_test.go deleted file mode 100644 index b0f5fef..0000000 --- a/native/sender/internal/remote/peer_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package remote - -import ( - "testing" - - "github.com/pion/webrtc/v4" -) - -func TestSelectedRouteLeavesARemoteRelayCandidateUnknown(t *testing.T) { - report := webrtc.StatsReport{ - "local": webrtc.ICECandidateStats{ - ID: "local", - Protocol: "udp", - CandidateType: webrtc.ICECandidateTypeHost, - }, - "remote": webrtc.ICECandidateStats{ - ID: "remote", - Protocol: "udp", - CandidateType: webrtc.ICECandidateTypeRelay, - }, - } - pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"} - if route := selectedRoute(report, pair); route != "unknown" { - t.Fatalf("selected route = %q, want unknown", route) - } -} - -func TestSelectedRouteLeavesALocalRelayCandidateUnknown(t *testing.T) { - report := webrtc.StatsReport{ - "local": webrtc.ICECandidateStats{ - ID: "local", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeRelay, - }, - "remote": webrtc.ICECandidateStats{ - ID: "remote", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeHost, - }, - } - pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"} - if route := selectedRoute(report, pair); route != "unknown" { - t.Fatalf("selected route = %q, want unknown", route) - } -} - -func TestSelectedRouteRequiresBothCandidatesBeforeClaimingDirect(t *testing.T) { - report := webrtc.StatsReport{ - "local": webrtc.ICECandidateStats{ - ID: "local", - Protocol: "udp", - CandidateType: webrtc.ICECandidateTypeHost, - }, - } - pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "missing"} - if route := selectedRoute(report, pair); route != "unknown" { - t.Fatalf("selected route = %q, want unknown", route) - } -} - -func TestPeerAnswerFailureIsSanitized(t *testing.T) { - connection, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - viewer := &peer{connection: connection, connectionID: "connection-1"} - err = viewer.acceptSignal(inboundSignalPayload{ - Kind: "description", ConnectionID: "connection-1", - Description: &sessionDescription{Type: "answer", SDP: "private-sdp-marker"}, - }) - if err == nil || err.Error() != "apply viewer answer failed" { - t.Fatalf("answer error = %q", err) - } -} diff --git a/native/sender/internal/remote/session.go b/native/sender/internal/remote/session.go deleted file mode 100644 index 7b86676..0000000 --- a/native/sender/internal/remote/session.go +++ /dev/null @@ -1,1074 +0,0 @@ -package remote - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/TNTcraftHIM/Screener/native/sender/internal/media" - "github.com/coder/websocket" - "github.com/pion/webrtc/v4" -) - -const ( - maxRemoteResponse = 64 << 10 - remoteRequestTimeout = 10 * time.Second - signalWriteTimeout = 5 * time.Second - siteAccessTTL = 12 * time.Hour - nativeHostClaimTTL = 5 * time.Minute - privateViewerGrantTTL = 7 * 24 * time.Hour - privateViewerPolicy = "private-link" -) - -var ( - roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{0,11}$`) - hostTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) - viewerGrantPattern = regexp.MustCompile(`^g1\.([1-9][0-9]{0,11})\.([1-9][0-9]{0,12})\.[A-Za-z0-9_-]{43}$`) - admissionCookiePattern = regexp.MustCompile(`^v1\.[1-9][0-9]{0,12}\.[A-Za-z0-9_-]{43}$`) -) - -type Event struct { - Kind string `json:"kind"` - Message string `json:"message,omitempty"` - ActiveViewers int `json:"activeViewers,omitempty"` - WaitingViewers int `json:"waitingViewers,omitempty"` - Media *media.Metrics `json:"media,omitempty"` - Audio *media.AudioMetrics `json:"audio,omitempty"` - Peers []PeerDiagnostics `json:"peers,omitempty"` -} - -type StartOptions struct { - BaseURL *url.URL - SiteAccessPassword string - Codec media.Codec - EnableAudio bool - OnEvent func(Event) -} - -type Room struct { - ID string `json:"roomId"` - InviteURL string `json:"inviteUrl"` -} - -type Session struct { - ctx context.Context - cancel context.CancelFunc - signalCtx context.Context - cancelSignal context.CancelFunc - baseURL *url.URL - httpClient *http.Client - room Room - hostToken string - clientID string - shareGeneration string - onEvent func(Event) - - mu sync.Mutex - writeMu sync.Mutex - conn *websocket.Conn - ice iceConfig - peers map[string]*peer - admission *viewerAdmission - endpointMediaCopyCapacity int - peerAssisted bool - routeRevision int64 - routePhase string - routeFailures [2]string - fanout *media.Fanout - audioFanout *media.AudioFanout - codec media.Codec - peerAPI *webrtc.API - codecPreferences []webrtc.RTPCodecParameters - closeOnce sync.Once - fatalOnce sync.Once - terminalOnce sync.Once - readDone chan struct{} - terminalResult chan error - readerStarted atomic.Bool - closing atomic.Bool -} - -type createRoomResponse struct { - RoomID string `json:"roomId"` - HostToken string `json:"hostToken"` - InviteURL string `json:"inviteUrl"` - ViewerPolicy string `json:"viewerPolicy"` - ViewerGrantExpiresAt json.RawMessage `json:"viewerGrantExpiresAt"` - ExpiresAt json.RawMessage `json:"expiresAt"` -} - -type siteAccessStatus struct { - Required *bool `json:"required"` - Authenticated *bool `json:"authenticated"` -} - -type createRoomRequest struct { - ViewerPolicy string `json:"viewerPolicy"` - HostClaimTTLSeconds int `json:"hostClaimTtlSeconds"` -} - -type iceServer struct { - URLs json.RawMessage `json:"urls"` -} - -type iceConfig struct { - IceServers []iceServer `json:"iceServers"` -} - -func Start(parent context.Context, options StartOptions) (*Session, Room, error) { - if options.BaseURL == nil { - return nil, Room{}, errors.New("remote server URL is required") - } - codec := options.Codec - if codec == "" { - codec = media.CodecVP8 - } - if _, ok := media.ParseCodec(string(codec)); !ok { - return nil, Room{}, fmt.Errorf("unsupported native codec %q", codec) - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, Room{}, fmt.Errorf("create in-memory cookie jar: %w", err) - } - httpClient := &http.Client{ - Jar: jar, - Timeout: remoteRequestTimeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - if err = authenticateHTTP(parent, httpClient, options.BaseURL, options.SiteAccessPassword); err != nil { - return nil, Room{}, err - } - created, err := createRoom(parent, httpClient, options.BaseURL) - if err != nil { - return nil, Room{}, err - } - clientID, err := opaqueID() - if err != nil { - return nil, Room{}, fmt.Errorf("create sender identity: %w", err) - } - shareGeneration, err := opaqueID() - if err != nil { - return nil, Room{}, fmt.Errorf("create sharing generation: %w", err) - } - ctx, cancel := context.WithCancel(parent) - signalCtx, cancelSignal := context.WithCancel(context.WithoutCancel(parent)) - session := &Session{ - ctx: ctx, - cancel: cancel, - signalCtx: signalCtx, - cancelSignal: cancelSignal, - baseURL: options.BaseURL, - httpClient: httpClient, - room: Room{ID: created.RoomID, InviteURL: created.InviteURL}, - hostToken: created.HostToken, - clientID: clientID, - shareGeneration: shareGeneration, - codec: codec, - onEvent: options.OnEvent, - peers: make(map[string]*peer), - readDone: make(chan struct{}), - terminalResult: make(chan error, 1), - } - session.fanout, err = media.NewFanoutWithCodec(codec, - func() { session.emit(Event{Kind: "request-keyframe"}) }, - func(fatalErr error) { session.fail(fatalErr) }, - ) - if err != nil { - cancel() - return nil, Room{}, err - } - if options.EnableAudio { - session.audioFanout, err = media.NewAudioFanout(func(fatalErr error) { session.fail(fatalErr) }) - if err != nil { - session.close() - return nil, Room{}, err - } - } - session.peerAPI, session.codecPreferences, err = newPeerAPI(codec) - if err != nil { - session.close() - return nil, Room{}, err - } - if err = session.connectWithRetry(parent); err != nil { - session.close() - return nil, Room{}, err - } - session.readerStarted.Store(true) - go session.readLoop() - go session.diagnosticsLoop() - go session.closeWhenCanceled() - return session, session.room, nil -} - -func (session *Session) connectWithRetry(parent context.Context) error { - var err error - for attempt := 0; attempt < 2; attempt++ { - ctx, cancel := context.WithTimeout(parent, remoteRequestTimeout) - err = session.connect(ctx) - cancel() - if err == nil { - return nil - } - session.resetSignalingConnection() - if parent.Err() != nil { - break - } - } - return err -} - -func (session *Session) resetSignalingConnection() { - session.mu.Lock() - conn := session.conn - session.conn = nil - session.mu.Unlock() - if conn != nil { - conn.CloseNow() - } -} - -func (session *Session) WriteMedia(packet media.Packet) error { - switch packet.Kind { - case media.KindVideo: - frame, err := packet.VideoFrame() - if err == nil { - err = session.fanout.Push(frame) - } - if err != nil { - session.fail(fmt.Errorf("write encoded video: %w", err)) - return err - } - return nil - case media.KindOpus: - if session.audioFanout == nil { - return errors.New("encoded audio was not enabled for this session") - } - if err := session.audioFanout.Push(packet); err != nil { - session.fail(fmt.Errorf("write encoded audio: %w", err)) - return err - } - return nil - default: - return errors.New("local sender supplied an unsupported encoded media kind") - } -} - -func (session *Session) Codec() media.Codec { - return session.codec -} - -func (session *Session) AudioEnabled() bool { return session.audioFanout != nil } - -func (session *Session) Stop() { - session.close() -} - -func (session *Session) Close() { - session.close() -} - -func (session *Session) Fail(err error) { - session.fail(err) -} - -func (session *Session) connect(ctx context.Context) error { - signalURL := *session.baseURL - if signalURL.Scheme == "https" { - signalURL.Scheme = "wss" - } else { - signalURL.Scheme = "ws" - } - signalURL.Path = "/signal" - header := http.Header{} - header.Set("Origin", remoteOrigin(session.baseURL)) - conn, response, err := websocket.Dial(ctx, signalURL.String(), &websocket.DialOptions{ - HTTPClient: session.httpClient, - HTTPHeader: header, - }) - if err != nil { - if response != nil { - return fmt.Errorf("connect signaling: HTTP %d", response.StatusCode) - } - return errors.New("connect signaling failed") - } - conn.SetReadLimit(64 << 10) - authenticate := authenticateMessage{ - Type: "authenticate", - Protocol: signalingProtocol, - RoomID: session.room.ID, - Role: "host", - Token: session.hostToken, - ClientID: session.clientID, - ShareGeneration: session.shareGeneration, - } - writeContext, cancel := context.WithTimeout(ctx, signalWriteTimeout) - err = writeWebSocketJSON(writeContext, conn, authenticate) - cancel() - if err != nil { - conn.CloseNow() - return errors.New("authenticate signaling failed") - } - session.mu.Lock() - session.conn = conn - session.mu.Unlock() - message, err := readServerMessage(ctx, conn) - if err != nil { - return errors.New("read signaling authentication failed") - } - if message.Type != "authenticated" || message.Role != "host" { - return errors.New("signaling returned an invalid host authentication response") - } - if message.ViewerPolicy != privateViewerPolicy { - return errors.New("signaling returned an unexpected room access policy") - } - session.mu.Lock() - session.ice = message.IceConfig - session.endpointMediaCopyCapacity = message.EndpointMediaCopyCapacity - session.peerAssisted = message.PeerAssisted - if message.PeerAssisted { - session.routeRevision = message.RouteRevision - session.routePhase = "active" - } else { - session.admission = newViewerAdmission(message.MaxViewers, message.EndpointMediaCopyCapacity) - } - session.mu.Unlock() - if message.PeerAssisted { - if err = session.reconcileAuthoritativeChildren(message.MediaAssignment.ChildPeerIDs); err != nil { - return err - } - publicationGeneration, _ := decodeNullableOpaqueID(message.RouteAssignment.SFUPublicationGeneration) - if publicationGeneration != "" { - if err = session.failUnsupportedRoute(message.RouteRevision, "active", nil); err != nil { - return err - } - } - } - session.emitViewerCounts() - session.emit(Event{Kind: "remote-ready"}) - return nil -} - -func (session *Session) readLoop() { - defer close(session.readDone) - conn := session.connection() - for { - message, err := readServerMessage(session.signalCtx, conn) - if err != nil { - if !session.closing.Load() { - go session.fail(errors.New("remote signaling disconnected")) - } - return - } - if session.closing.Load() { - switch message.Type { - case "room-closed": - if message.Reason == "host-ended" { - session.completeTerminal(nil) - } else { - session.completeTerminal(errors.New("remote signaling returned an unexpected room closure")) - } - return - default: - continue - } - } - if err = session.handle(message); err != nil { - // The sole reader must remain available for the room-closed ACK while - // the asynchronous fatal path performs the bounded terminal write. - go session.fail(err) - } - } -} - -func (session *Session) closeWhenCanceled() { - select { - case <-session.ctx.Done(): - session.close() - case <-session.readDone: - } -} - -func (session *Session) completeTerminal(err error) { - session.terminalOnce.Do(func() { - session.terminalResult <- err - }) -} - -func (session *Session) handle(message serverMessage) error { - switch message.Type { - case "peer-joined": - if session.peerAssisted { - return nil - } - return session.addViewer(message.PeerID) - case "peer-waiting": - if session.peerAssisted { - return nil - } - return session.waitViewer(message.PeerID) - case "peer-left": - if session.peerAssisted { - return nil - } - return session.removeViewer(message.PeerID) - case "signal": - peer := session.peer(message.FromPeerID) - if peer == nil { - return nil - } - if err := peer.acceptSignal(message.Payload); err != nil { - return fmt.Errorf("apply viewer signal: %w", err) - } - case "restart-request": - peer := session.peer(message.FromPeerID) - if peer == nil || peer.connectionID != message.ConnectionID { - return nil - } - if message.Rebuild { - return session.rebuildPeer(message.FromPeerID) - } else if err := peer.restartICE(); err != nil { - return fmt.Errorf("restart viewer ICE: %w", err) - } - case "media-assignment": - if !session.peerAssisted { - return errors.New("peer-assisted assignment arrived in ordinary media mode") - } - return session.reconcileAuthoritativeChildren(message.MediaAssignment.ChildPeerIDs) - case "route-update": - return session.handleRouteUpdate(message) - case "sfu-config": - return session.handleUnsupportedRouteMessage(message, nil) - case "viewer-quality-evidence": - // Fixed-HIGH observes no viewer feedback; accepting this current-wire - // message must not turn it into a room-wide quality controller. - return nil - case "error": - if session.peerAssisted && message.Code == "PEER_NOT_FOUND" { - return nil - } - return fmt.Errorf("remote signaling error %s", message.Code) - case "room-closed": - return fmt.Errorf("remote room closed: %s", message.Reason) - } - return nil -} - -func (session *Session) addViewer(peerID string) error { - session.mu.Lock() - if session.admission == nil { - session.mu.Unlock() - return errors.New("viewer joined before host authentication completed") - } - action := session.admission.Activate(peerID) - session.mu.Unlock() - switch action { - case admissionDuplicate: - return nil - case admissionWait: - return errors.New("active Viewer was assigned to the waiting set") - case admissionFull: - return errors.New("active Viewer exceeds authenticated endpoint or room capacity") - case admissionActivate: - if err := session.activatePeer(peerID); err != nil { - session.mu.Lock() - session.admission.AbortActivation(peerID) - session.mu.Unlock() - return err - } - session.emitViewerCounts() - return nil - default: - return errors.New("viewer admission returned an invalid action") - } -} - -func (session *Session) waitViewer(peerID string) error { - session.mu.Lock() - if session.admission == nil { - session.mu.Unlock() - return errors.New("waiting Viewer arrived before host authentication completed") - } - wasActive := session.admission.IsActive(peerID) - action := session.admission.Wait(peerID) - peer := session.peers[peerID] - if wasActive { - delete(session.peers, peerID) - } - session.mu.Unlock() - if peer != nil && wasActive { - peer.close() - } - if action == admissionFull { - return errors.New("waiting Viewer exceeds authenticated room capacity") - } - session.emitViewerCounts() - return nil -} - -func (session *Session) activatePeer(peerID string) error { - session.mu.Lock() - configuration, err := pionConfiguration(session.ice) - slot := session.availableSlotLocked() - session.mu.Unlock() - if err != nil { - return err - } - if slot == 0 { - return errors.New("native host edge budget is exhausted") - } - created, err := newPeer(session, peerID, slot, configuration) - if err != nil { - return err - } - session.mu.Lock() - session.peers[peerID] = created - session.mu.Unlock() - if err = created.offer(false); err != nil { - session.mu.Lock() - delete(session.peers, peerID) - session.mu.Unlock() - created.close() - return err - } - return nil -} - -func (session *Session) removeViewer(peerID string) error { - session.mu.Lock() - peer := session.peers[peerID] - delete(session.peers, peerID) - if session.admission == nil { - session.mu.Unlock() - if peer != nil { - peer.close() - } - return nil - } - session.admission.Leave(peerID) - session.mu.Unlock() - if peer != nil { - peer.close() - } - session.emitViewerCounts() - return nil -} - -func (session *Session) rebuildPeer(peerID string) error { - session.mu.Lock() - if session.peerAssisted { - if session.peers[peerID] == nil { - session.mu.Unlock() - return nil - } - } else if session.admission == nil || !session.admission.IsActive(peerID) { - session.mu.Unlock() - return nil - } - old := session.peers[peerID] - delete(session.peers, peerID) - session.mu.Unlock() - if old != nil { - old.close() - } - if err := session.activatePeer(peerID); err != nil { - if !session.peerAssisted { - session.mu.Lock() - session.admission.AbortActivation(peerID) - session.mu.Unlock() - } - return err - } - return nil -} - -func (session *Session) reconcileAuthoritativeChildren(peerIDs []string) error { - if len(peerIDs) > session.endpointMediaCopyCapacity { - return errors.New("authoritative children exceed the authenticated endpoint capacity") - } - desired := make(map[string]struct{}, len(peerIDs)) - for _, peerID := range peerIDs { - desired[peerID] = struct{}{} - } - - session.mu.Lock() - retired := make([]*peer, 0, len(session.peers)) - for peerID, activePeer := range session.peers { - if _, keep := desired[peerID]; !keep { - delete(session.peers, peerID) - retired = append(retired, activePeer) - } - } - missing := make([]string, 0, len(peerIDs)) - for _, peerID := range peerIDs { - if session.peers[peerID] == nil { - missing = append(missing, peerID) - } - } - session.mu.Unlock() - for _, activePeer := range retired { - activePeer.close() - } - for _, peerID := range missing { - if err := session.activatePeer(peerID); err != nil { - return fmt.Errorf("activate authoritative viewer: %w", err) - } - } - session.emitViewerCounts() - return nil -} - -func (session *Session) handleRouteUpdate(message serverMessage) error { - if !session.peerAssisted { - return errors.New("route update arrived in ordinary media mode") - } - if message.RouteRevision < session.routeRevision { - return nil - } - if !hostRouteAssignmentFitsCapacity( - message.RouteAssignment, - session.endpointMediaCopyCapacity, - message.RoutePhase == "prepare", - ) { - return errors.New("route update exceeds the authenticated endpoint capacity") - } - session.routeRevision = message.RouteRevision - session.routePhase = message.RoutePhase - publicationGeneration, _ := decodeNullableOpaqueID(message.RouteAssignment.SFUPublicationGeneration) - if publicationGeneration != "" { - return session.failUnsupportedRoute(message.RouteRevision, message.RoutePhase, nil) - } - if message.RoutePhase == "prepare" && !session.hasExactChildren(message.RouteAssignment.ChildPeerIDs) { - return session.failUnsupportedRoute(message.RouteRevision, message.RoutePhase, nil) - } - if message.RoutePhase == "active" { - if err := session.reconcileAuthoritativeChildren(message.RouteAssignment.ChildPeerIDs); err != nil { - return err - } - } - return session.send(map[string]any{"type": "route-ready", "revision": message.RouteRevision, "phase": message.RoutePhase}) -} - -func (session *Session) hasExactChildren(peerIDs []string) bool { - session.mu.Lock() - defer session.mu.Unlock() - if len(session.peers) != len(peerIDs) { - return false - } - for _, peerID := range peerIDs { - if session.peers[peerID] == nil { - return false - } - } - return true -} - -func (session *Session) handleUnsupportedRouteMessage(message serverMessage, connectionID *string) error { - if !session.peerAssisted || message.RouteRevision != session.routeRevision { - return nil - } - return session.failUnsupportedRoute(message.RouteRevision, session.routePhase, connectionID) -} - -func (session *Session) failUnsupportedRoute(revision int64, phase string, connectionID *string) error { - key := fmt.Sprintf("%d/%s", revision, phase) - reported := &session.routeFailures[0] - if connectionID != nil { - reported = &session.routeFailures[1] - } - if *reported == key { - return nil - } - *reported = key - return session.send(map[string]any{"type": "route-failed", "revision": revision, "phase": phase, "connectionId": connectionID}) -} - -func (session *Session) peer(peerID string) *peer { - session.mu.Lock() - defer session.mu.Unlock() - return session.peers[peerID] -} - -func (session *Session) connection() *websocket.Conn { - session.mu.Lock() - defer session.mu.Unlock() - return session.conn -} - -func (session *Session) send(value any) error { - ctx, cancel := context.WithTimeout(session.signalCtx, signalWriteTimeout) - defer cancel() - return session.write(ctx, value, false) -} - -func (session *Session) write(ctx context.Context, value any, terminal bool) error { - session.writeMu.Lock() - defer session.writeMu.Unlock() - if session.closing.Load() && !terminal { - return io.ErrClosedPipe - } - conn := session.connection() - if conn == nil { - return io.ErrClosedPipe - } - return writeWebSocketJSON(ctx, conn, value) -} - -func writeWebSocketJSON(ctx context.Context, conn *websocket.Conn, value any) error { - payload, err := json.Marshal(value) - if err != nil { - return err - } - return conn.Write(ctx, websocket.MessageText, payload) -} - -func (session *Session) sendSignal(targetPeerID string, payload any) error { - return session.send(outboundSignalMessage{ - Type: "signal", - TargetPeerID: targetPeerID, - Payload: payload, - }) -} - -func (session *Session) emit(event Event) { - if session.onEvent != nil { - session.onEvent(event) - } -} - -func (session *Session) emitViewerCounts() { - session.mu.Lock() - active, waiting := 0, 0 - if session.peerAssisted { - active = len(session.peers) - } else if session.admission != nil { - active, waiting = session.admission.Counts() - } - session.mu.Unlock() - session.emit(Event{Kind: "viewer-count", ActiveViewers: active, WaitingViewers: waiting}) -} - -func (session *Session) diagnosticsLoop() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-session.ctx.Done(): - return - case now := <-ticker.C: - session.mu.Lock() - peers := make([]*peer, 0, len(session.peers)) - for _, activePeer := range session.peers { - peers = append(peers, activePeer) - } - session.mu.Unlock() - diagnostics := make([]PeerDiagnostics, 0, len(peers)) - for _, activePeer := range peers { - diagnostics = append(diagnostics, activePeer.snapshot(now)) - } - sort.Slice(diagnostics, func(left, right int) bool { - return diagnostics[left].Slot < diagnostics[right].Slot - }) - metrics := session.fanout.Snapshot() - var audio *media.AudioMetrics - if session.audioFanout != nil { - value := session.audioFanout.Snapshot() - audio = &value - } - session.emit(Event{Kind: "diagnostics", Media: &metrics, Audio: audio, Peers: diagnostics}) - } - } -} - -func (session *Session) availableSlotLocked() int { - used := [maxEndpointMediaCopyCapacity + 1]bool{} - for _, activePeer := range session.peers { - if activePeer.slot > 0 && activePeer.slot <= session.endpointMediaCopyCapacity { - used[activePeer.slot] = true - } - } - for slot := 1; slot <= session.endpointMediaCopyCapacity; slot++ { - if !used[slot] { - return slot - } - } - return 0 -} - -func (session *Session) fail(err error) { - if err == nil || session.closing.Load() { - return - } - session.fatalOnce.Do(func() { - session.emit(Event{Kind: "fatal", Message: err.Error()}) - session.close() - }) -} - -func (session *Session) close() { - session.closeOnce.Do(func() { - session.closing.Store(true) - if session.connection() != nil { - terminalContext, cancelTerminal := context.WithTimeout(context.Background(), signalWriteTimeout) - err := session.write(terminalContext, simpleMessage{Type: "abandon-room"}, true) - confirmed := false - if err == nil && session.readerStarted.Load() { - select { - case terminalErr := <-session.terminalResult: - err = terminalErr - confirmed = terminalErr == nil - case <-session.readDone: - select { - case terminalErr := <-session.terminalResult: - err = terminalErr - confirmed = terminalErr == nil - default: - err = errors.New("remote signaling closed before room abandonment was acknowledged") - } - case <-terminalContext.Done(): - select { - case terminalErr := <-session.terminalResult: - err = terminalErr - confirmed = terminalErr == nil - default: - err = terminalContext.Err() - } - } - } - cancelTerminal() - if err != nil || !confirmed { - session.emit(Event{Kind: "warning", Message: "The fresh room could not be removed before shutdown"}) - } - } - session.cancel() - session.cancelSignal() - session.mu.Lock() - conn := session.conn - session.conn = nil - peers := make([]*peer, 0, len(session.peers)) - for _, peer := range session.peers { - peers = append(peers, peer) - } - session.peers = make(map[string]*peer) - session.mu.Unlock() - session.fanout.Close() - if session.audioFanout != nil { - session.audioFanout.Close() - } - for _, peer := range peers { - peer.close() - } - if conn != nil { - _ = conn.Close(websocket.StatusNormalClosure, "sender stopped") - } - }) -} - -func authenticateHTTP(ctx context.Context, client *http.Client, base *url.URL, password string) error { - endpoint := base.ResolveReference(&url.URL{Path: "/api/site-access"}) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) - if err != nil { - return errors.New("prepare site access request failed") - } - request.Header.Set("Origin", remoteOrigin(base)) - request.Header.Set("Accept", "application/json") - if password != "" { - request.Header.Set("Authorization", "Bearer "+password) - } - var status siteAccessStatus - response, err := doJSON(client, request, &status) - if err != nil { - return fmt.Errorf("authenticate with the Screener site: %w", err) - } - if response.StatusCode != http.StatusOK { - return errors.New("authenticate with the Screener site: unexpected success status") - } - if status.Required == nil || status.Authenticated == nil { - return errors.New("authenticate with the Screener site: response is invalid") - } - if !*status.Authenticated { - return errors.New("the Screener site did not authenticate this sender") - } - if err = validateSiteAccessCookie(response, client.Jar, base, *status.Required); err != nil { - return fmt.Errorf("authenticate with the Screener site: %w", err) - } - return nil -} - -func createRoom(ctx context.Context, client *http.Client, base *url.URL) (createRoomResponse, error) { - endpoint := base.ResolveReference(&url.URL{Path: "/api/rooms"}) - payload, err := json.Marshal(createRoomRequest{ - ViewerPolicy: privateViewerPolicy, - HostClaimTTLSeconds: int(nativeHostClaimTTL / time.Second), - }) - if err != nil { - return createRoomResponse{}, errors.New("prepare room request failed") - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) - if err != nil { - return createRoomResponse{}, errors.New("prepare room request failed") - } - request.Header.Set("Origin", remoteOrigin(base)) - request.Header.Set("Accept", "application/json") - request.Header.Set("Content-Type", "application/json") - var room createRoomResponse - response, err := doJSON(client, request, &room) - if err != nil { - return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err) - } - if response.StatusCode != http.StatusCreated { - return createRoomResponse{}, errors.New("create Screener room: unexpected success status") - } - if err = validateCreatedRoom(room, base, time.Now()); err != nil { - return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err) - } - return room, nil -} - -func doJSON(client *http.Client, request *http.Request, target any) (*http.Response, error) { - response, err := client.Do(request) - if err != nil { - return nil, errors.New("request failed") - } - defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, maxRemoteResponse+1)) - if err != nil { - return nil, errors.New("response read failed") - } - if len(payload) > maxRemoteResponse { - return nil, errors.New("response is too large") - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, fmt.Errorf("HTTP %d", response.StatusCode) - } - if err = decodeStrict(payload, target); err != nil { - return nil, errors.New("response is not valid current JSON") - } - return response, nil -} - -func remoteOrigin(base *url.URL) string { - return (&url.URL{Scheme: base.Scheme, Host: base.Host}).String() -} - -func validateSiteAccessCookie(response *http.Response, jar http.CookieJar, base *url.URL, required bool) error { - setCookies := response.Header.Values("Set-Cookie") - if !required { - if len(setCookies) != 0 { - return errors.New("unexpected site access cookie") - } - return nil - } - if len(setCookies) != 1 { - return errors.New("site access cookie is missing or ambiguous") - } - cookies := response.Cookies() - if len(cookies) != 1 { - return errors.New("site access cookie is invalid") - } - cookie := cookies[0] - expectedName := "screener-site-access" - if base.Scheme == "https" { - expectedName = "__Host-screener-site-access" - } - if cookie.Name != expectedName || !admissionCookiePattern.MatchString(cookie.Value) || - cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != int(siteAccessTTL/time.Second) || - !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Secure != (base.Scheme == "https") || - cookie.Quoted || cookie.Partitioned || cookie.RawExpires != "" || !cookie.Expires.IsZero() || len(cookie.Unparsed) != 0 { - return errors.New("site access cookie is invalid") - } - stored := jar.Cookies(base) - if len(stored) != 1 || stored[0].Name != cookie.Name || stored[0].Value != cookie.Value { - return errors.New("site access cookie was not retained") - } - return nil -} - -func validateCreatedRoom(room createRoomResponse, base *url.URL, now time.Time) error { - if !roomCodePattern.MatchString(room.RoomID) || !hostTokenPattern.MatchString(room.HostToken) || - room.ViewerPolicy != privateViewerPolicy || - len(room.InviteURL) < 1 || len(room.InviteURL) > 2048 { - return errors.New("response is invalid") - } - viewerGrantExpiresAt, isNull, err := parseResponseTimestamp(room.ViewerGrantExpiresAt) - if err != nil || isNull || !viewerGrantExpiresAt.After(now) || - viewerGrantExpiresAt.After(now.Add(privateViewerGrantTTL)) { - return errors.New("response is invalid") - } - expiresAt, expiresAtIsNull, err := parseResponseTimestamp(room.ExpiresAt) - if err != nil || expiresAtIsNull || !expiresAt.After(now) || viewerGrantExpiresAt.After(expiresAt) { - return errors.New("response is invalid") - } - invite, err := url.Parse(room.InviteURL) - if err != nil || invite.User != nil || invite.RawQuery != "" || - !strings.EqualFold(invite.Scheme, base.Scheme) || !strings.EqualFold(invite.Host, base.Host) || - invite.Path != "/r/"+room.RoomID || !strings.HasPrefix(invite.Fragment, "v=") { - return errors.New("response is invalid") - } - grant := strings.TrimPrefix(invite.Fragment, "v=") - match := viewerGrantPattern.FindStringSubmatch(grant) - if len(match) != 3 || match[1] != room.RoomID { - return errors.New("response is invalid") - } - grantExpiresAt, err := strconv.ParseInt(match[2], 10, 64) - if err != nil || grantExpiresAt != viewerGrantExpiresAt.Unix() { - return errors.New("response is invalid") - } - return nil -} - -func parseResponseTimestamp(raw json.RawMessage) (time.Time, bool, error) { - if len(raw) == 0 { - return time.Time{}, false, errors.New("response field is missing") - } - if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return time.Time{}, true, nil - } - var value string - if err := json.Unmarshal(raw, &value); err != nil { - return time.Time{}, false, errors.New("response timestamp is invalid") - } - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, false, errors.New("response timestamp is invalid") - } - return parsed, false, nil -} - -func pionConfiguration(config iceConfig) (webrtc.Configuration, error) { - servers := make([]webrtc.ICEServer, 0, len(config.IceServers)) - for _, server := range config.IceServers { - var urls []string - if err := json.Unmarshal(server.URLs, &urls); err != nil { - var single string - if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) { - return webrtc.Configuration{}, errors.New("ICE server URLs are invalid") - } - urls = []string{single} - } - for index, value := range urls { - if !validStunURL(value) { - return webrtc.Configuration{}, errors.New("ICE server URLs are invalid") - } - urls[index] = "stun:" + value[len("stun:"):] - } - servers = append(servers, webrtc.ICEServer{ - URLs: urls, - }) - } - return webrtc.Configuration{ICEServers: servers}, nil -} diff --git a/native/sender/internal/remote/session_test.go b/native/sender/internal/remote/session_test.go deleted file mode 100644 index b904ba4..0000000 --- a/native/sender/internal/remote/session_test.go +++ /dev/null @@ -1,891 +0,0 @@ -package remote - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/coder/websocket" -) - -func TestStartUsesSiteAccessCookieAndPrivateV2Room(t *testing.T) { - const password = "site-access-password" - harness := newRemoteHarnessWithPassword(t, []string{}, password) - defer harness.Close() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - session, room, err := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - SiteAccessPassword: password, - }) - if err != nil { - t.Fatal(err) - } - defer session.Close() - - admission := <-harness.admission - if admission.Method != http.MethodPost || admission.Authorization != "Bearer "+password || - admission.Origin != harness.URL() || admission.Body != "" { - t.Fatalf("site access request = %+v", admission) - } - creation := <-harness.roomCreation - if creation.Method != http.MethodPost || creation.Authorization != "" || creation.Origin != harness.URL() || - creation.ContentType != "application/json" || - creation.Body != `{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}` || - !strings.HasPrefix(creation.Cookie, "screener-site-access=v1.") { - t.Fatalf("room creation request = %+v", creation) - } - if signalCookie := <-harness.signalCookie; signalCookie != creation.Cookie { - t.Fatalf("signaling cookie did not match room cookie") - } - if !strings.Contains(room.InviteURL, "/r/1#v=g1.1.") { - t.Fatalf("private invite was not returned to the local Host UI") - } - assertAuthenticateShape(t, harness.Authentication()) -} - -func TestStartRetriesTheSameProvisionalRoomOnce(t *testing.T) { - var roomCreations atomic.Int32 - var signalAttempts atomic.Int32 - var authMu sync.Mutex - var authentications [][]byte - mux := http.NewServeMux() - var server *httptest.Server - mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { - writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) - }) - mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { - roomCreations.Add(1) - response.WriteHeader(http.StatusCreated) - _, _ = response.Write([]byte(validRoomResponseJSON(server.URL, "1", "1", nil))) - }) - mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - defer connection.CloseNow() - messageType, payload, err := connection.Read(request.Context()) - if err != nil || messageType != websocket.MessageText { - return - } - authMu.Lock() - authentications = append(authentications, append([]byte(nil), payload...)) - authMu.Unlock() - if signalAttempts.Add(1) == 1 { - return - } - expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) - authenticated, _ := json.Marshal(map[string]any{ - "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer", - "roomExpiresAt": expiresAt.Format(time.RFC3339), "maxViewers": 3, "endpointMediaCopyCapacity": 2, "hostOnline": true, - "connectionId": nil, "viewerPeerIds": []string{}, - "iceConfig": map[string]any{"iceServers": []any{}}, "viewerPolicy": privateViewerPolicy, - "viewerAuthorizationGeneration": "viewer_generation_12345678", - }) - writeErr := connection.Write(request.Context(), websocket.MessageText, authenticated) - if writeErr != nil { - return - } - _, payload, err = connection.Read(request.Context()) - if err != nil || string(payload) != `{"type":"abandon-room"}` { - return - } - _ = connection.Write(context.Background(), websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`)) - }) - server = httptest.NewServer(mux) - defer server.Close() - baseURL, err := url.Parse(server.URL) - if err != nil { - t.Fatal(err) - } - session, _, err := Start(context.Background(), StartOptions{BaseURL: baseURL}) - if err != nil { - t.Fatal(err) - } - session.Close() - if roomCreations.Load() != 1 || signalAttempts.Load() != 2 { - t.Fatalf("room creations = %d, signaling attempts = %d", roomCreations.Load(), signalAttempts.Load()) - } - authMu.Lock() - defer authMu.Unlock() - if len(authentications) != 2 || !bytes.Equal(authentications[0], authentications[1]) { - t.Fatal("signaling retry did not reuse the exact room and Host generation") - } -} - -func TestSiteAccessFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) { - const password = "never-report-this-password" - const grant = "g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - tests := []struct { - name string - status int - body string - setCookie string - }{ - {name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"` + password + ` ` + grant + `"}`}, - {name: "rate-limited", status: http.StatusTooManyRequests, body: `{"error":"` + password + ` ` + grant + `"}`}, - {name: "wrong-success-status", status: http.StatusCreated, body: `{"required":false,"authenticated":true}`}, - {name: "missing-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`}, - {name: "missing-required", status: http.StatusOK, body: `{"authenticated":true}`}, - {name: "missing-authenticated", status: http.StatusOK, body: `{"required":false}`}, - {name: "unknown-field", status: http.StatusOK, body: `{"required":false,"authenticated":true,"url":"https://private.test/path"}`}, - { - name: "quoted-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, - setCookie: `screener-site-access="v1.1893456000.` + strings.Repeat("a", 43) + `"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict`, - }, - { - name: "partitioned-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`, - setCookie: `screener-site-access=v1.1893456000.` + strings.Repeat("a", 43) + `; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict; Partitioned`, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.URL.Path != "/api/site-access" { - http.NotFound(response, request) - return - } - if test.setCookie != "" { - response.Header().Set("Set-Cookie", test.setCookie) - } - response.WriteHeader(test.status) - _, _ = response.Write([]byte(test.body)) - })) - defer server.Close() - baseURL, parseErr := url.Parse(server.URL) - if parseErr != nil { - t.Fatal(parseErr) - } - _, _, startErr := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - SiteAccessPassword: password, - }) - if startErr == nil { - t.Fatal("invalid site access response was accepted") - } - reported := startErr.Error() - if strings.Contains(reported, password) || strings.Contains(reported, grant) || strings.Contains(reported, server.URL) { - t.Fatalf("site access error leaked untrusted data: %q", reported) - } - }) - } -} - -func TestCreateRoomFailuresAreStrictAndDoNotLeakGrant(t *testing.T) { - const grant = "g1.1.1893456000.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" - tests := []struct { - name string - status int - body func(string) string - }{ - {name: "unauthorized", status: http.StatusUnauthorized, body: func(string) string { return `{"error":"` + grant + `"}` }}, - {name: "wrong-success-status", status: http.StatusOK, body: func(origin string) string { - return validRoomResponseJSON(origin, "1", "1", nil) - }}, - {name: "unknown-field", status: http.StatusCreated, body: func(origin string) string { - return validRoomResponseJSON(origin, "1", "1", map[string]any{"rawGrant": grant}) - }}, - {name: "mismatched-grant-room", status: http.StatusCreated, body: func(origin string) string { - return validRoomResponseJSON(origin, "2", "1", nil) - }}, - {name: "missing-room-expiry", status: http.StatusCreated, body: func(origin string) string { - return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": omittedJSONField{}}) - }}, - {name: "persistent-room", status: http.StatusCreated, body: func(origin string) string { - return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": nil}) - }}, - {name: "grant-expiry-mismatch", status: http.StatusCreated, body: func(origin string) string { - return validRoomResponseJSON(origin, "1", "1", map[string]any{ - "viewerGrantExpiresAt": time.Now().UTC().Add(25 * time.Hour).Truncate(time.Second).Format(time.RFC3339), - }) - }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - var server *httptest.Server - mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { - writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) - }) - mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { - response.WriteHeader(test.status) - _, _ = response.Write([]byte(test.body(server.URL))) - }) - server = httptest.NewServer(mux) - defer server.Close() - baseURL, parseErr := url.Parse(server.URL) - if parseErr != nil { - t.Fatal(parseErr) - } - _, _, startErr := Start(context.Background(), StartOptions{BaseURL: baseURL}) - if startErr == nil { - t.Fatal("invalid room response was accepted") - } - if strings.Contains(startErr.Error(), grant) || strings.Contains(startErr.Error(), server.URL) { - t.Fatalf("room creation error leaked untrusted data: %q", startErr) - } - }) - } -} - -func TestSessionTerminalPathsAbandonFreshRoomExactlyOnce(t *testing.T) { - tests := []struct { - name string - terminate func(*Session) - wantFatal bool - }{ - {name: "stop", terminate: func(session *Session) { session.Stop() }}, - {name: "process-close", terminate: func(session *Session) { session.Close() }}, - {name: "local-fatal", terminate: func(session *Session) { session.Fail(errors.New("local bridge failed")) }, wantFatal: true}, - {name: "concurrent", terminate: func(session *Session) { - var wait sync.WaitGroup - for index := 0; index < 8; index++ { - wait.Add(1) - go func(closeSession bool) { - defer wait.Done() - if closeSession { - session.Close() - } else { - session.Stop() - } - }(index%2 == 0) - } - wait.Wait() - }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - harness := newRemoteHarness(t, []string{"viewer-old"}) - defer harness.Close() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - var eventsMu sync.Mutex - var events []Event - session, _, err := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - OnEvent: func(event Event) { - eventsMu.Lock() - events = append(events, event) - eventsMu.Unlock() - }, - }) - if err != nil { - t.Fatal(err) - } - assertAuthenticateShape(t, harness.Authentication()) - - session.mu.Lock() - active, waiting := session.admission.Counts() - peerCount := len(session.peers) - session.mu.Unlock() - if active != 0 || waiting != 0 || peerCount != 0 { - t.Fatalf("authentication roster allocated media: %d active, %d waiting, %d peers", active, waiting, peerCount) - } - - test.terminate(session) - select { - case <-harness.Done(): - case <-time.After(2 * time.Second): - t.Fatal("fake signaling server did not observe terminal shutdown") - } - if got := harness.AbandonCount(); got != 1 { - t.Fatalf("abandon-room count = %d, want 1", got) - } - if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` { - t.Fatalf("terminal wire = %q (length %d)", got, len(got)) - } - - eventsMu.Lock() - defer eventsMu.Unlock() - fatalCount := 0 - for _, event := range events { - if event.Kind == "fatal" { - fatalCount++ - } - } - if test.wantFatal && fatalCount != 1 { - t.Fatalf("fatal event count = %d, want 1", fatalCount) - } - if !test.wantFatal && fatalCount != 0 { - t.Fatalf("normal room closure emitted %d fatal events", fatalCount) - } - }) - } -} - -func TestSessionSendPropagatesAClosedSignalingConnection(t *testing.T) { - accepted := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - defer connection.CloseNow() - close(accepted) - _, _, _ = connection.Read(request.Context()) - })) - defer server.Close() - - websocketURL := "ws" + server.URL[len("http"):] - connection, _, err := websocket.Dial(context.Background(), websocketURL, nil) - if err != nil { - t.Fatal(err) - } - <-accepted - connection.CloseNow() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - session := &Session{ctx: ctx, signalCtx: ctx, conn: connection} - if err = session.send(simpleMessage{Type: "abandon-room"}); err == nil { - t.Fatal("signaling write to a closed connection returned success") - } -} - -func TestPeerAssistedHostUsesOnlyAuthoritativeRoutes(t *testing.T) { - received := make(chan []byte, 8) - accepted := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - return - } - defer connection.CloseNow() - close(accepted) - for { - _, payload, readErr := connection.Read(request.Context()) - if readErr != nil { - return - } - received <- payload - } - })) - defer server.Close() - connection, _, err := websocket.Dial(context.Background(), "ws"+server.URL[len("http"):], nil) - if err != nil { - t.Fatal(err) - } - defer connection.CloseNow() - <-accepted - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - session := &Session{ - signalCtx: ctx, conn: connection, peerAssisted: true, - peers: make(map[string]*peer), endpointMediaCopyCapacity: 2, - routeRevision: 7, routePhase: "active", - } - - if err = session.handle(serverMessage{Type: "peer-joined", PeerID: "viewer-new"}); err != nil || len(session.peers) != 0 { - t.Fatal("peer-joined created a non-authoritative edge") - } - direct := participantRouteAssignment{ChildPeerIDs: []string{}} - direct.Upstream.Kind = "none" - direct.SFUPublicationGeneration = json.RawMessage("null") - stale := direct - stale.ChildPeerIDs = []string{"viewer-stale"} - if err = session.handle(serverMessage{Type: "route-update", RouteRevision: 6, RoutePhase: "active", RouteAssignment: stale}); err != nil { - t.Fatal(err) - } - select { - case payload := <-received: - t.Fatalf("stale route emitted %s", payload) - case <-time.After(50 * time.Millisecond): - } - - unsupportedPrepare := direct - unsupportedPrepare.ChildPeerIDs = []string{"viewer-new"} - if err = session.handle(serverMessage{Type: "route-update", RouteRevision: 8, RoutePhase: "prepare", RouteAssignment: unsupportedPrepare}); err != nil { - t.Fatal(err) - } - assertRouteStatus(t, receiveRouteStatus(t, received), "route-failed", 8, "prepare", nil) - - if err = session.handle(serverMessage{Type: "route-update", RouteRevision: 8, RoutePhase: "active", RouteAssignment: direct}); err != nil { - t.Fatal(err) - } - assertRouteStatus(t, receiveRouteStatus(t, received), "route-ready", 8, "active", nil) - - sfu := direct - sfu.SFUPublicationGeneration = json.RawMessage(`"publication-1"`) - sfuUpdate := serverMessage{Type: "route-update", RouteRevision: 9, RoutePhase: "active", RouteAssignment: sfu} - if err = session.handle(sfuUpdate); err != nil { - t.Fatal(err) - } - assertRouteStatus(t, receiveRouteStatus(t, received), "route-failed", 9, "active", nil) - if err = session.handle(sfuUpdate); err != nil { - t.Fatal(err) - } - if err = session.handle(serverMessage{Type: "error", Code: "PEER_NOT_FOUND"}); err != nil { - t.Fatal("bounded route failure became fatal") - } - select { - case payload := <-received: - t.Fatalf("duplicate route failure emitted %s", payload) - case <-time.After(50 * time.Millisecond): - } -} - -func receiveRouteStatus(t *testing.T, received <-chan []byte) []byte { - t.Helper() - select { - case payload := <-received: - return payload - case <-time.After(time.Second): - t.Fatal("timed out waiting for route status") - return nil - } -} - -func assertRouteStatus(t *testing.T, payload []byte, messageType string, revision int64, phase string, connectionID *string) { - t.Helper() - var message struct { - Type string `json:"type"` - Revision int64 `json:"revision"` - Phase string `json:"phase"` - ConnectionID *string `json:"connectionId"` - } - if err := json.Unmarshal(payload, &message); err != nil || message.Type != messageType || message.Revision != revision || message.Phase != phase || - (connectionID == nil) != (message.ConnectionID == nil) || connectionID != nil && *connectionID != *message.ConnectionID { - t.Fatalf("route status = %s", payload) - } -} - -func TestCanceledSessionContextStillAbandonsTheFreshRoomExactlyOnce(t *testing.T) { - harness := newRemoteHarness(t, []string{}) - defer harness.Close() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - parent, cancelParent := context.WithCancel(context.Background()) - session, _, err := Start(parent, StartOptions{BaseURL: baseURL}) - if err != nil { - cancelParent() - t.Fatal(err) - } - assertAuthenticateShape(t, harness.Authentication()) - cancelParent() - session.Close() - select { - case <-harness.Done(): - case <-time.After(2 * time.Second): - t.Fatal("canceled session context prevented fresh-room abandonment") - } - if got := harness.AbandonCount(); got != 1 { - t.Fatalf("abandon-room count = %d, want 1", got) - } - if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` { - t.Fatalf("terminal wire = %q", got) - } -} - -func TestSessionCloseWaitsForRoomClosedAcknowledgement(t *testing.T) { - ackGate := make(chan struct{}) - var releaseOnce sync.Once - releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) } - - harness := newRemoteHarness(t, []string{}) - harness.ackGate = ackGate - defer harness.Close() - defer releaseACK() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - var warningCount atomic.Uint64 - session, _, err := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - OnEvent: func(event Event) { - if event.Kind == "warning" { - warningCount.Add(1) - } - }, - }) - if err != nil { - t.Fatal(err) - } - assertAuthenticateShape(t, harness.Authentication()) - - closed := make(chan struct{}) - go func() { - session.Close() - close(closed) - }() - select { - case <-harness.AbandonObserved(): - case <-time.After(2 * time.Second): - releaseACK() - t.Fatal("fake signaling server did not observe room abandonment") - } - select { - case <-closed: - releaseACK() - t.Fatal("session closed the signaling transport before terminal acknowledgement") - default: - } - - releaseACK() - select { - case <-closed: - case <-time.After(2 * time.Second): - t.Fatal("session did not close after terminal acknowledgement") - } - if got := warningCount.Load(); got != 0 { - t.Fatalf("acknowledged room abandonment emitted %d warnings", got) - } -} - -func TestSessionCloseBoundsMissingRoomClosedAcknowledgement(t *testing.T) { - ackGate := make(chan struct{}) - var releaseOnce sync.Once - releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) } - - harness := newRemoteHarness(t, []string{}) - harness.ackGate = ackGate - defer harness.Close() - defer releaseACK() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - var warningCount atomic.Uint64 - session, _, err := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - OnEvent: func(event Event) { - if event.Kind == "warning" { - warningCount.Add(1) - } - }, - }) - if err != nil { - t.Fatal(err) - } - assertAuthenticateShape(t, harness.Authentication()) - - started := time.Now() - session.Close() - elapsed := time.Since(started) - if elapsed < signalWriteTimeout-250*time.Millisecond { - t.Fatalf("session closed before its terminal deadline: %s", elapsed) - } - if elapsed > signalWriteTimeout+2*time.Second { - t.Fatalf("session exceeded its terminal deadline: %s", elapsed) - } - if got := harness.AbandonCount(); got != 1 { - t.Fatalf("abandon-room count = %d, want 1", got) - } - if got := warningCount.Load(); got != 1 { - t.Fatalf("missing acknowledgement emitted %d warnings, want 1", got) - } - releaseACK() -} - -func TestReaderOriginatedFatalAbandonsOnceWithoutDeadlock(t *testing.T) { - harness := newRemoteHarness(t, []string{}) - harness.afterAuth = [][]byte{[]byte(`{"type":"error","code":"SERVER_ERROR","message":"bounded failure"}`)} - defer harness.Close() - baseURL, err := url.Parse(harness.URL()) - if err != nil { - t.Fatal(err) - } - fatal := make(chan string, 1) - session, _, err := Start(context.Background(), StartOptions{ - BaseURL: baseURL, - OnEvent: func(event Event) { - if event.Kind == "fatal" { - select { - case fatal <- event.Message: - default: - } - } - }, - }) - if err != nil { - t.Fatal(err) - } - defer session.Close() - assertAuthenticateShape(t, harness.Authentication()) - - select { - case <-harness.Done(): - case <-time.After(2 * time.Second): - t.Fatal("reader-originated fatal did not complete terminal cleanup") - } - if got := harness.AbandonCount(); got != 1 { - t.Fatalf("abandon-room count = %d, want 1", got) - } - select { - case message := <-fatal: - if message == "" { - t.Fatal("reader-originated fatal message is empty") - } - default: - t.Fatal("reader-originated error did not emit a fatal event") - } -} - -type remoteHarness struct { - testing *testing.T - server *httptest.Server - authentication chan []byte - done chan struct{} - doneOnce sync.Once - abandonObserved chan struct{} - abandonOnce sync.Once - ackGate <-chan struct{} - afterAuth [][]byte - terminalMu sync.Mutex - terminal []byte - abandonCount atomic.Uint64 - password string - admission chan capturedRequest - roomCreation chan capturedRequest - signalCookie chan string -} - -type capturedRequest struct { - Method string - Authorization string - Cookie string - Origin string - ContentType string - Body string -} - -func newRemoteHarness(t *testing.T, roster []string) *remoteHarness { - return newRemoteHarnessWithPassword(t, roster, "") -} - -func newRemoteHarnessWithPassword(t *testing.T, roster []string, password string) *remoteHarness { - t.Helper() - harness := &remoteHarness{ - testing: t, - authentication: make(chan []byte, 1), - done: make(chan struct{}), - abandonObserved: make(chan struct{}), - password: password, - admission: make(chan capturedRequest, 1), - roomCreation: make(chan capturedRequest, 1), - signalCookie: make(chan string, 1), - } - mux := http.NewServeMux() - mux.HandleFunc("/api/site-access", func(response http.ResponseWriter, request *http.Request) { - body, _ := io.ReadAll(request.Body) - harness.admission <- capturedRequest{ - Method: request.Method, - Authorization: request.Header.Get("Authorization"), - Origin: request.Header.Get("Origin"), - Body: string(body), - } - if password == "" { - writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true}) - return - } - if request.Header.Get("Authorization") != "Bearer "+password { - response.WriteHeader(http.StatusUnauthorized) - writeHarnessJSON(response, map[string]string{"error": "secret must never be reflected"}) - return - } - response.Header().Set("Set-Cookie", "screener-site-access=v1.1893456000."+strings.Repeat("a", 43)+"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict") - writeHarnessJSON(response, map[string]any{"required": true, "authenticated": true}) - }) - mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) { - body, _ := io.ReadAll(request.Body) - harness.roomCreation <- capturedRequest{ - Method: request.Method, - Authorization: request.Header.Get("Authorization"), - Cookie: request.Header.Get("Cookie"), - Origin: request.Header.Get("Origin"), - ContentType: request.Header.Get("Content-Type"), - Body: string(body), - } - if password != "" && request.Header.Get("Cookie") == "" { - response.WriteHeader(http.StatusUnauthorized) - return - } - response.WriteHeader(http.StatusCreated) - _, _ = response.Write([]byte(validRoomResponseJSON(harness.server.URL, "1", "1", nil))) - }) - mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) { - harness.signalCookie <- request.Header.Get("Cookie") - if password != "" && request.Header.Get("Cookie") == "" { - http.Error(response, "unauthorized", http.StatusUnauthorized) - return - } - connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - harness.finish() - return - } - defer connection.CloseNow() - messageType, payload, err := connection.Read(request.Context()) - if err != nil || messageType != websocket.MessageText { - harness.finish() - return - } - harness.authentication <- append([]byte(nil), payload...) - authenticated, _ := json.Marshal(map[string]any{ - "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer", - "roomExpiresAt": nil, "maxViewers": 3, "endpointMediaCopyCapacity": 2, "hostOnline": true, - "connectionId": nil, "viewerPeerIds": roster, - "iceConfig": map[string]any{"iceServers": []any{}}, - "viewerPolicy": privateViewerPolicy, "viewerAuthorizationGeneration": "viewer_generation_12345678", - }) - if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil { - harness.finish() - return - } - for _, message := range harness.afterAuth { - if err = connection.Write(request.Context(), websocket.MessageText, message); err != nil { - harness.finish() - return - } - } - for { - messageType, payload, err = connection.Read(request.Context()) - if err != nil { - harness.finish() - return - } - if messageType != websocket.MessageText { - continue - } - var discriminator struct { - Type string `json:"type"` - } - _ = json.Unmarshal(payload, &discriminator) - if discriminator.Type != "abandon-room" { - continue - } - harness.abandonCount.Add(1) - harness.terminalMu.Lock() - harness.terminal = append([]byte(nil), payload...) - harness.terminalMu.Unlock() - harness.abandonOnce.Do(func() { close(harness.abandonObserved) }) - if harness.ackGate != nil { - select { - case <-harness.ackGate: - case <-request.Context().Done(): - harness.finish() - return - } - } - closed := []byte(`{"type":"room-closed","reason":"host-ended"}`) - _ = connection.Write(context.Background(), websocket.MessageText, closed) - harness.finish() - return - } - }) - harness.server = httptest.NewServer(mux) - return harness -} - -func (harness *remoteHarness) URL() string { - return harness.server.URL -} - -func (harness *remoteHarness) Authentication() []byte { - select { - case payload := <-harness.authentication: - return payload - case <-time.After(2 * time.Second): - harness.testing.Fatal("fake signaling server did not receive authentication") - return nil - } -} - -func (harness *remoteHarness) TerminalMessage() []byte { - harness.terminalMu.Lock() - defer harness.terminalMu.Unlock() - return append([]byte(nil), harness.terminal...) -} - -func (harness *remoteHarness) AbandonCount() uint64 { - return harness.abandonCount.Load() -} - -func (harness *remoteHarness) Done() <-chan struct{} { - return harness.done -} - -func (harness *remoteHarness) AbandonObserved() <-chan struct{} { - return harness.abandonObserved -} - -func (harness *remoteHarness) Close() { - harness.server.Close() -} - -func (harness *remoteHarness) finish() { - harness.doneOnce.Do(func() { close(harness.done) }) -} - -func writeHarnessJSON(response http.ResponseWriter, value any) { - response.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(response).Encode(value) -} - -type omittedJSONField struct{} - -func validRoomResponseJSON(origin, roomID, grantRoomID string, overrides map[string]any) string { - expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) - grant := "g1." + grantRoomID + "." + strconv.FormatInt(expiresAt.Unix(), 10) + "." + strings.Repeat("b", 43) - response := map[string]any{ - "roomId": roomID, "hostToken": "0123456789abcdef0123456789abcdef", - "inviteUrl": origin + "/r/" + roomID + "#v=" + grant, - "viewerPolicy": privateViewerPolicy, "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339), - } - for key, value := range overrides { - if _, omitted := value.(omittedJSONField); omitted { - delete(response, key) - continue - } - response[key] = value - } - payload, err := json.Marshal(response) - if err != nil { - panic(err) - } - return string(payload) -} - -func assertAuthenticateShape(t *testing.T, payload []byte) { - t.Helper() - var message map[string]any - if err := json.Unmarshal(payload, &message); err != nil { - t.Fatal(err) - } - keys := make([]string, 0, len(message)) - for key := range message { - keys = append(keys, key) - } - sort.Strings(keys) - want := []string{"clientId", "protocol", "role", "roomId", "shareGeneration", "token", "type"} - if len(keys) != len(want) { - t.Fatalf("authentication keys = %v", keys) - } - for index := range want { - if keys[index] != want[index] { - t.Fatalf("authentication keys = %v, want %v", keys, want) - } - } - if message["type"] != "authenticate" || message["protocol"] != signalingProtocol || - message["role"] != "host" || message["roomId"] != "1" { - t.Fatalf("authentication = %v", message) - } -} diff --git a/native/sender/internal/remote/wire.go b/native/sender/internal/remote/wire.go deleted file mode 100644 index 29e5e24..0000000 --- a/native/sender/internal/remote/wire.go +++ /dev/null @@ -1,593 +0,0 @@ -package remote - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "github.com/coder/websocket" -) - -const ( - signalingProtocol = "screener-v6" - maxProtocolViewers = 20 - maxEndpointMediaCopyCapacity = 3 - maxRouteRevision = int64(1<<53 - 1) - maxSignalSDPBytes = 48 << 10 - maxQualityEvidence = 2 << 10 -) - -var opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) - -type sessionDescription struct { - Type string `json:"type"` - SDP string `json:"sdp"` -} - -type iceCandidate struct { - Candidate string `json:"candidate"` - SDPMid *string `json:"sdpMid,omitempty"` - SDPMLineIndex *uint16 `json:"sdpMLineIndex,omitempty"` - UsernameFragment *string `json:"usernameFragment,omitempty"` -} - -type inboundSignalPayload struct { - Kind string - ConnectionID string - Description *sessionDescription - Candidate *iceCandidate -} - -type outboundDescriptionPayload struct { - Kind string `json:"kind"` - ConnectionID string `json:"connectionId"` - Description sessionDescription `json:"description"` -} - -type outboundCandidatePayload struct { - Kind string `json:"kind"` - ConnectionID string `json:"connectionId"` - Candidate *iceCandidate `json:"candidate"` -} - -type outboundSignalMessage struct { - Type string `json:"type"` - TargetPeerID string `json:"targetPeerId"` - Payload any `json:"payload"` -} - -type authenticateMessage struct { - Type string `json:"type"` - Protocol string `json:"protocol"` - RoomID string `json:"roomId"` - Role string `json:"role"` - Token string `json:"token"` - ClientID string `json:"clientId"` - ShareGeneration string `json:"shareGeneration"` -} - -type simpleMessage struct { - Type string `json:"type"` -} - -type mediaAssignment struct { - ParentPeerID json.RawMessage `json:"parentPeerId"` - ChildPeerIDs []string `json:"childPeerIds"` -} - -type participantRouteAssignment struct { - Upstream struct { - Kind string `json:"kind"` - PeerID *string `json:"peerId,omitempty"` - } `json:"upstream"` - ChildPeerIDs []string `json:"childPeerIds"` - SFUPublicationGeneration json.RawMessage `json:"sfuPublicationGeneration"` -} - -type serverMessage struct { - Type string - Role string - PeerID string - FromPeerID string - ViewerPeerIDs []string - ConnectionID string - Rebuild bool - MaxViewers int - EndpointMediaCopyCapacity int - IceConfig iceConfig - ViewerPolicy string - Payload inboundSignalPayload - Code string - Message string - Reason string - PeerAssisted bool - MediaAssignment mediaAssignment - RouteAssignment participantRouteAssignment - RouteRevision int64 - RoutePhase string -} - -func readServerMessage(ctx context.Context, conn *websocket.Conn) (serverMessage, error) { - messageType, payload, err := conn.Read(ctx) - if err != nil { - return serverMessage{}, err - } - if messageType != websocket.MessageText { - return serverMessage{}, errors.New("signaling message is not text") - } - return decodeServerMessage(payload) -} - -func decodeServerMessage(payload []byte) (serverMessage, error) { - var discriminator struct { - Type string `json:"type"` - } - if err := json.Unmarshal(payload, &discriminator); err != nil || discriminator.Type == "" { - return serverMessage{}, errors.New("signaling message is not valid JSON") - } - switch discriminator.Type { - case "authenticated": - return decodeAuthenticatedMessage(payload) - case "peer-joined", "peer-waiting", "peer-left": - var wire struct { - Type string `json:"type"` - PeerID string `json:"peerId"` - } - if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.PeerID) { - return serverMessage{}, errors.New("peer presence message is invalid") - } - return serverMessage{Type: wire.Type, PeerID: wire.PeerID}, nil - case "signal": - var wire struct { - Type string `json:"type"` - FromPeerID string `json:"fromPeerId"` - Payload json.RawMessage `json:"payload"` - } - if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.FromPeerID) { - return serverMessage{}, errors.New("viewer signal envelope is invalid") - } - signal, err := decodeInboundSignalPayload(wire.Payload) - if err != nil { - return serverMessage{}, err - } - return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, Payload: signal}, nil - case "restart-request": - var wire struct { - Type string `json:"type"` - FromPeerID string `json:"fromPeerId"` - ConnectionID string `json:"connectionId"` - Rebuild *bool `json:"rebuild"` - } - if err := decodeStrict(payload, &wire); err != nil || wire.Rebuild == nil || - !validOpaqueID(wire.FromPeerID) || !validOpaqueID(wire.ConnectionID) { - return serverMessage{}, errors.New("viewer restart request is invalid") - } - return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, ConnectionID: wire.ConnectionID, Rebuild: *wire.Rebuild}, nil - case "media-assignment": - var wire struct { - Type string `json:"type"` - MediaAssignment json.RawMessage `json:"mediaAssignment"` - } - if err := decodeStrict(payload, &wire); err != nil { - return serverMessage{}, errors.New("media assignment message is invalid") - } - assignment, err := decodeHostMediaAssignment(wire.MediaAssignment) - if err != nil { - return serverMessage{}, err - } - return serverMessage{Type: wire.Type, MediaAssignment: assignment}, nil - case "route-update": - var wire struct { - Type string `json:"type"` - Revision int64 `json:"revision"` - Phase string `json:"phase"` - Assignment json.RawMessage `json:"assignment"` - } - if err := decodeStrict(payload, &wire); err != nil { - return serverMessage{}, errors.New("route update message is invalid") - } - assignment, assignmentErr := decodeHostRouteAssignment(wire.Assignment) - if wire.Revision < 0 || wire.Revision > maxRouteRevision || assignmentErr != nil || wire.Phase != "prepare" && wire.Phase != "active" { - return serverMessage{}, errors.New("route update message is invalid") - } - return serverMessage{Type: wire.Type, RouteRevision: wire.Revision, RoutePhase: wire.Phase, RouteAssignment: assignment}, nil - case "sfu-config": - var wire struct { - Type string `json:"type"` - Revision int64 `json:"revision"` - } - if err := json.Unmarshal(payload, &wire); err != nil || wire.Revision < 0 || wire.Revision > maxRouteRevision { - return serverMessage{}, errors.New("SFU configuration message is invalid") - } - return serverMessage{Type: wire.Type, RouteRevision: wire.Revision}, nil - case "viewer-quality-evidence": - if err := validateViewerQualityEvidence(payload); err != nil { - return serverMessage{}, err - } - return serverMessage{Type: discriminator.Type}, nil - case "error": - var wire struct { - Type string `json:"type"` - Code string `json:"code"` - Message string `json:"message"` - } - if err := decodeStrict(payload, &wire); err != nil || !validErrorCode(wire.Code) || len(wire.Message) < 1 || len(wire.Message) > 256 { - return serverMessage{}, errors.New("signaling error message is invalid") - } - return serverMessage{Type: wire.Type, Code: wire.Code, Message: wire.Message}, nil - case "room-closed": - var wire struct { - Type string `json:"type"` - Reason string `json:"reason"` - } - if err := decodeStrict(payload, &wire); err != nil || wire.Reason != "host-ended" && wire.Reason != "expired" { - return serverMessage{}, errors.New("room closure message is invalid") - } - return serverMessage{Type: wire.Type, Reason: wire.Reason}, nil - default: - return serverMessage{}, errors.New("signaling message type is unsupported") - } -} - -type viewerQualityEvidenceMetrics struct { - Width json.RawMessage `json:"width"` - Height json.RawMessage `json:"height"` - FramesPerSecond json.RawMessage `json:"framesPerSecond"` - BitrateKbps json.RawMessage `json:"bitrateKbps"` - PacketsReceivedDelta json.RawMessage `json:"packetsReceivedDelta"` - PacketsLostDelta json.RawMessage `json:"packetsLostDelta"` - JitterMs json.RawMessage `json:"jitterMs"` - FramesDecodedDelta json.RawMessage `json:"framesDecodedDelta"` - FramesDroppedDelta json.RawMessage `json:"framesDroppedDelta"` - DecodeMsPerFrame json.RawMessage `json:"decodeMsPerFrame"` - FreezeCountDelta json.RawMessage `json:"freezeCountDelta"` - FreezeDurationMsDelta json.RawMessage `json:"freezeDurationMsDelta"` - Codec json.RawMessage `json:"codec"` - CodecProfile json.RawMessage `json:"codecProfile"` - CodecParameters json.RawMessage `json:"codecParameters"` -} - -func validateViewerQualityEvidence(payload []byte) error { - if len(payload) > maxQualityEvidence { - return errors.New("viewer quality evidence is too large") - } - var wire struct { - Type string `json:"type"` - ViewerPeerID string `json:"viewerPeerId"` - ParentPeerID string `json:"parentPeerId"` - Guard struct { - ConnectionID string `json:"connectionId"` - RouteRevision json.RawMessage `json:"routeRevision"` - } `json:"guard"` - Sequence json.RawMessage `json:"sequence"` - WindowMS json.RawMessage `json:"windowMs"` - Metrics viewerQualityEvidenceMetrics `json:"metrics"` - } - if err := decodeStrict(payload, &wire); err != nil || wire.Type != "viewer-quality-evidence" || - !validOpaqueID(wire.ViewerPeerID) || !validOpaqueID(wire.ParentPeerID) || - !validOpaqueID(wire.Guard.ConnectionID) || - !allJSONFieldsPresent( - wire.Guard.RouteRevision, - wire.Sequence, - wire.WindowMS, - wire.Metrics.Width, - wire.Metrics.Height, - wire.Metrics.FramesPerSecond, - wire.Metrics.BitrateKbps, - wire.Metrics.PacketsReceivedDelta, - wire.Metrics.PacketsLostDelta, - wire.Metrics.JitterMs, - wire.Metrics.FramesDecodedDelta, - wire.Metrics.FramesDroppedDelta, - wire.Metrics.DecodeMsPerFrame, - wire.Metrics.FreezeCountDelta, - wire.Metrics.FreezeDurationMsDelta, - wire.Metrics.Codec, - wire.Metrics.CodecProfile, - wire.Metrics.CodecParameters, - ) { - return errors.New("viewer quality evidence envelope is invalid") - } - return nil -} - -func allJSONFieldsPresent(fields ...json.RawMessage) bool { - for _, field := range fields { - if len(field) == 0 { - return false - } - } - return true -} - -func decodeAuthenticatedMessage(payload []byte) (serverMessage, error) { - var wire struct { - Type string `json:"type"` - Protocol string `json:"protocol"` - Role string `json:"role"` - PeerID string `json:"peerId"` - RoomExpiresAt json.RawMessage `json:"roomExpiresAt"` - MaxViewers *int `json:"maxViewers"` - EndpointMediaCopyCapacity *int `json:"endpointMediaCopyCapacity"` - HostOnline *bool `json:"hostOnline"` - ConnectionID json.RawMessage `json:"connectionId"` - ViewerPeerIDs []string `json:"viewerPeerIds"` - IceConfig json.RawMessage `json:"iceConfig"` - ViewerPolicy string `json:"viewerPolicy"` - ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` - MediaMode *string `json:"mediaMode,omitempty"` - MediaAssignment json.RawMessage `json:"mediaAssignment,omitempty"` - RouteRevision *int64 `json:"routeRevision,omitempty"` - RouteAssignment json.RawMessage `json:"routeAssignment,omitempty"` - QualitySettings json.RawMessage `json:"qualitySettings,omitempty"` - SFUStandbyURL json.RawMessage `json:"sfuStandbyUrl,omitempty"` - } - if err := decodeStrict(payload, &wire); err != nil || wire.Type != "authenticated" || - wire.Protocol != signalingProtocol || - wire.Role != "host" || !validOpaqueID(wire.PeerID) || wire.MaxViewers == nil || - *wire.MaxViewers < 1 || *wire.MaxViewers > maxProtocolViewers || - wire.EndpointMediaCopyCapacity == nil || *wire.EndpointMediaCopyCapacity < 1 || - *wire.EndpointMediaCopyCapacity > maxEndpointMediaCopyCapacity || wire.HostOnline == nil || - !*wire.HostOnline || !bytes.Equal(bytes.TrimSpace(wire.ConnectionID), []byte("null")) || - wire.ViewerPeerIDs == nil || len(wire.ViewerPeerIDs) > *wire.MaxViewers || - (wire.ViewerPolicy != "private-link" && wire.ViewerPolicy != "public-watch") || - !validOpaqueID(wire.ViewerAuthorizationGeneration) { - return serverMessage{}, errors.New("host authentication response is invalid") - } - if err := validateNullableTime(wire.RoomExpiresAt); err != nil { - return serverMessage{}, errors.New("host room expiry is invalid") - } - for _, peerID := range wire.ViewerPeerIDs { - if !validOpaqueID(peerID) { - return serverMessage{}, errors.New("host viewer roster is invalid") - } - } - config, err := decodeICEConfig(wire.IceConfig) - if err != nil { - return serverMessage{}, err - } - peerAssisted := wire.MediaMode != nil - var assignment mediaAssignment - var routeAssignment participantRouteAssignment - var routeRevision int64 - if !peerAssisted { - if len(wire.MediaAssignment) != 0 || wire.RouteRevision != nil || len(wire.RouteAssignment) != 0 || len(wire.QualitySettings) != 0 || len(wire.SFUStandbyURL) != 0 { - return serverMessage{}, errors.New("host authentication response is invalid") - } - } else { - if *wire.MediaMode != "peer-assisted" { - return serverMessage{}, errors.New("host authentication response is invalid") - } - assignment, err = decodeHostMediaAssignment(wire.MediaAssignment) - if err != nil { - return serverMessage{}, errors.New("host authentication response is invalid") - } - routeAssignment, err = decodeHostRouteAssignment(wire.RouteAssignment) - if err != nil || strings.Join(assignment.ChildPeerIDs, "\x00") != strings.Join(routeAssignment.ChildPeerIDs, "\x00") { - return serverMessage{}, errors.New("host authentication response is invalid") - } - if !hostRouteAssignmentFitsCapacity(routeAssignment, *wire.EndpointMediaCopyCapacity, false) { - return serverMessage{}, errors.New("host authentication response is invalid") - } - if wire.RouteRevision == nil || *wire.RouteRevision < 0 || *wire.RouteRevision > maxRouteRevision || len(wire.QualitySettings) == 0 { - return serverMessage{}, errors.New("host authentication response is invalid") - } - routeRevision = *wire.RouteRevision - } - return serverMessage{ - Type: wire.Type, - Role: wire.Role, - PeerID: wire.PeerID, - MaxViewers: *wire.MaxViewers, - EndpointMediaCopyCapacity: *wire.EndpointMediaCopyCapacity, - ViewerPeerIDs: append([]string(nil), wire.ViewerPeerIDs...), - IceConfig: config, - ViewerPolicy: wire.ViewerPolicy, - PeerAssisted: peerAssisted, - MediaAssignment: assignment, - RouteAssignment: routeAssignment, - RouteRevision: routeRevision, - }, nil -} - -func decodeHostMediaAssignment(payload []byte) (mediaAssignment, error) { - var assignment mediaAssignment - if err := decodeStrict(payload, &assignment); err != nil || !bytes.Equal(bytes.TrimSpace(assignment.ParentPeerID), []byte("null")) || !validChildPeerIDs(assignment.ChildPeerIDs) { - return mediaAssignment{}, errors.New("host media assignment is invalid") - } - return assignment, nil -} - -func decodeHostRouteAssignment(payload []byte) (participantRouteAssignment, error) { - var assignment participantRouteAssignment - if err := decodeStrict(payload, &assignment); err != nil || assignment.Upstream.Kind != "none" || assignment.Upstream.PeerID != nil || !validChildPeerIDs(assignment.ChildPeerIDs) { - return participantRouteAssignment{}, errors.New("host route assignment is invalid") - } - if _, err := decodeNullableOpaqueID(assignment.SFUPublicationGeneration); err != nil { - return participantRouteAssignment{}, errors.New("host route assignment is invalid") - } - return assignment, nil -} - -func decodeNullableOpaqueID(payload []byte) (string, error) { - if bytes.Equal(bytes.TrimSpace(payload), []byte("null")) { - return "", nil - } - var value string - if len(payload) == 0 || json.Unmarshal(payload, &value) != nil || !validOpaqueID(value) { - return "", errors.New("nullable opaque ID is invalid") - } - return value, nil -} - -func validChildPeerIDs(peerIDs []string) bool { - if peerIDs == nil || len(peerIDs) > maxEndpointMediaCopyCapacity { - return false - } - seen := make(map[string]struct{}, len(peerIDs)) - for _, peerID := range peerIDs { - if !validOpaqueID(peerID) { - return false - } - if _, duplicate := seen[peerID]; duplicate { - return false - } - seen[peerID] = struct{}{} - } - return true -} - -func hostRouteAssignmentFitsCapacity(assignment participantRouteAssignment, capacity int, transition bool) bool { - limit := capacity - if transition && limit < maxEndpointMediaCopyCapacity { - limit++ - } - publicationGeneration, err := decodeNullableOpaqueID(assignment.SFUPublicationGeneration) - if err != nil { - return false - } - count := len(assignment.ChildPeerIDs) - if publicationGeneration != "" { - count++ - } - return count <= limit -} - -func decodeInboundSignalPayload(payload []byte) (inboundSignalPayload, error) { - var discriminator struct { - Kind string `json:"kind"` - } - if err := json.Unmarshal(payload, &discriminator); err != nil { - return inboundSignalPayload{}, errors.New("viewer signal payload is invalid") - } - switch discriminator.Kind { - case "description": - var wire struct { - Kind string `json:"kind"` - ConnectionID string `json:"connectionId"` - Description sessionDescription `json:"description"` - } - if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) || - wire.Description.Type != "answer" || len(wire.Description.SDP) < 1 || len(wire.Description.SDP) > maxSignalSDPBytes { - return inboundSignalPayload{}, errors.New("viewer answer payload is invalid") - } - return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Description: &wire.Description}, nil - case "candidate": - var wire struct { - Kind string `json:"kind"` - ConnectionID string `json:"connectionId"` - Candidate json.RawMessage `json:"candidate"` - } - if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) || len(wire.Candidate) == 0 { - return inboundSignalPayload{}, errors.New("viewer candidate payload is invalid") - } - if bytes.Equal(bytes.TrimSpace(wire.Candidate), []byte("null")) { - return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID}, nil - } - var candidate iceCandidate - if err := decodeStrict(wire.Candidate, &candidate); err != nil || len(candidate.Candidate) > 4096 || - candidate.SDPMid != nil && len(*candidate.SDPMid) > 128 || - candidate.SDPMLineIndex != nil && *candidate.SDPMLineIndex > 255 || - candidate.UsernameFragment != nil && len(*candidate.UsernameFragment) > 256 { - return inboundSignalPayload{}, errors.New("viewer ICE candidate is invalid") - } - return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Candidate: &candidate}, nil - default: - return inboundSignalPayload{}, errors.New("viewer signal kind is invalid") - } -} - -func decodeICEConfig(payload []byte) (iceConfig, error) { - var wire struct { - IceServers []iceServer `json:"iceServers"` - } - if err := decodeStrict(payload, &wire); err != nil || wire.IceServers == nil || len(wire.IceServers) > 8 { - return iceConfig{}, errors.New("ICE configuration is invalid") - } - for _, server := range wire.IceServers { - var urls []string - if err := json.Unmarshal(server.URLs, &urls); err != nil { - var single string - if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) { - return iceConfig{}, errors.New("ICE server URLs are invalid") - } - continue - } - if len(urls) < 1 || len(urls) > 8 { - return iceConfig{}, errors.New("ICE server URLs are invalid") - } - for _, value := range urls { - if !validStunURL(value) { - return iceConfig{}, errors.New("ICE server URLs are invalid") - } - } - } - return iceConfig{IceServers: wire.IceServers}, nil -} - -func validStunURL(value string) bool { - if len(value) < len("stun:a") || len(value) > 512 || !strings.EqualFold(value[:len("stun:")], "stun:") { - return false - } - authority := value[len("stun:"):] - if authority == "" || strings.ContainsAny(authority, "/\\\t\r\n ?#") || strings.HasSuffix(authority, ":") { - return false - } - parsed, err := url.Parse("//" + authority) - if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - return false - } - if port := parsed.Port(); port != "" { - value, portErr := strconv.Atoi(port) - return portErr == nil && value > 0 && value <= 65535 - } - return true -} - -func decodeStrict(payload []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return errors.New("multiple JSON values") - } - return nil -} - -func validateNullableTime(payload json.RawMessage) error { - trimmed := bytes.TrimSpace(payload) - if len(trimmed) == 0 { - return errors.New("missing time") - } - if bytes.Equal(trimmed, []byte("null")) { - return nil - } - var value string - if err := json.Unmarshal(trimmed, &value); err != nil { - return err - } - _, err := time.Parse(time.RFC3339, value) - return err -} - -func validOpaqueID(value string) bool { - return opaqueIDPattern.MatchString(value) -} - -func validErrorCode(value string) bool { - switch value { - case "AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR": - return true - default: - return false - } -} diff --git a/native/sender/internal/remote/wire_test.go b/native/sender/internal/remote/wire_test.go deleted file mode 100644 index d2b108c..0000000 --- a/native/sender/internal/remote/wire_test.go +++ /dev/null @@ -1,317 +0,0 @@ -package remote - -import ( - "encoding/json" - "strings" - "testing" -) - -const validHostAuthenticated = `{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"endpointMediaCopyCapacity":2,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}` - -const validPeerAssistedHostAuthenticated = `{"type":"authenticated","protocol":"screener-v6","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"endpointMediaCopyCapacity":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":["viewer-1","viewer-2","viewer-3"],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678","mediaMode":"peer-assisted","mediaAssignment":{"parentPeerId":null,"childPeerIds":["viewer-1","viewer-2","viewer-3"]},"routeRevision":7,"routeAssignment":{"upstream":{"kind":"none"},"childPeerIds":["viewer-1","viewer-2","viewer-3"],"sfuPublicationGeneration":null},"qualitySettings":{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","videoCodec":"automatic"}}` - -func TestMarshalHostSignalShapes(t *testing.T) { - mid := "0" - line := uint16(0) - tests := []struct { - name string - payload any - want string - }{ - { - name: "offer", - payload: outboundDescriptionPayload{ - Kind: "description", ConnectionID: "connection-1", - Description: sessionDescription{Type: "offer", SDP: "v=0"}, - }, - want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}}`, - }, - { - name: "candidate", - payload: outboundCandidatePayload{ - Kind: "candidate", ConnectionID: "connection-1", - Candidate: &iceCandidate{Candidate: "candidate:1", SDPMid: &mid, SDPMLineIndex: &line}, - }, - want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}}}`, - }, - { - name: "end-of-candidates", - payload: outboundCandidatePayload{Kind: "candidate", ConnectionID: "connection-1", Candidate: nil}, - want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":null}}`, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - encoded, err := json.Marshal(outboundSignalMessage{Type: "signal", TargetPeerID: "viewer-1", Payload: test.payload}) - if err != nil { - t.Fatal(err) - } - if string(encoded) != test.want { - t.Fatalf("signal JSON = %s\nwant = %s", encoded, test.want) - } - }) - } -} - -func TestDecodeOrdinaryHostAuthentication(t *testing.T) { - message, err := decodeServerMessage([]byte(validHostAuthenticated)) - if err != nil { - t.Fatal(err) - } - if message.MaxViewers != 3 || message.EndpointMediaCopyCapacity != 2 || message.Type != "authenticated" || message.Role != "host" || - message.ViewerPolicy != "private-link" { - t.Fatalf("authentication = %+v", message) - } -} - -func TestDecodeOrdinaryHostAuthenticationAcceptsViewerCeiling(t *testing.T) { - var candidate map[string]any - if err := json.Unmarshal([]byte(validHostAuthenticated), &candidate); err != nil { - t.Fatal(err) - } - candidate["maxViewers"] = maxProtocolViewers - payload, err := json.Marshal(candidate) - if err != nil { - t.Fatal(err) - } - message, err := decodeServerMessage(payload) - if err != nil { - t.Fatal(err) - } - if message.MaxViewers != maxProtocolViewers { - t.Fatalf("max viewers = %d, want %d", message.MaxViewers, maxProtocolViewers) - } -} - -func TestDecodePeerAssistedHostAuthentication(t *testing.T) { - message, err := decodeServerMessage([]byte(validPeerAssistedHostAuthenticated)) - if err != nil { - t.Fatal(err) - } - if !message.PeerAssisted || message.RouteRevision != 7 || len(message.MediaAssignment.ChildPeerIDs) != 3 { - t.Fatalf("peer-assisted authentication = %+v", message) - } -} - -func TestDecodePeerAssistedHostAuthenticationRejectsInvalidAuthority(t *testing.T) { - tests := map[string]func(map[string]any){ - "fourth-child": func(message map[string]any) { - children := []any{"viewer-1", "viewer-2", "viewer-3", "viewer-4"} - message["mediaAssignment"].(map[string]any)["childPeerIds"] = children - message["routeAssignment"].(map[string]any)["childPeerIds"] = children - }, - "duplicate-child": func(message map[string]any) { - children := []any{"viewer-1", "viewer-1"} - message["mediaAssignment"].(map[string]any)["childPeerIds"] = children - message["routeAssignment"].(map[string]any)["childPeerIds"] = children - }, - "host-parent": func(message map[string]any) { - message["mediaAssignment"].(map[string]any)["parentPeerId"] = "viewer-1" - }, - "host-upstream": func(message map[string]any) { - message["routeAssignment"].(map[string]any)["upstream"] = map[string]any{"kind": "peer", "peerId": "viewer-1"} - }, - "extra-upstream-field": func(message map[string]any) { - message["routeAssignment"].(map[string]any)["upstream"] = map[string]any{"kind": "none", "peerId": ""} - }, - "mismatched-children": func(message map[string]any) { - message["routeAssignment"].(map[string]any)["childPeerIds"] = []any{"viewer-2", "viewer-1"} - }, - "missing-quality": func(message map[string]any) { delete(message, "qualitySettings") }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - var candidate map[string]any - if err := json.Unmarshal([]byte(validPeerAssistedHostAuthenticated), &candidate); err != nil { - t.Fatal(err) - } - mutate(candidate) - payload, err := json.Marshal(candidate) - if err != nil { - t.Fatal(err) - } - if _, err = decodeServerMessage(payload); err == nil { - t.Fatal("invalid peer-assisted authority was accepted") - } - }) - } -} - -func TestDecodePeerAssistedRouteMessages(t *testing.T) { - tests := []struct { - payload string - typeName string - }{ - {`{"type":"media-assignment","mediaAssignment":{"parentPeerId":null,"childPeerIds":["viewer-1"]}}`, "media-assignment"}, - {`{"type":"route-update","revision":8,"phase":"active","assignment":{"upstream":{"kind":"none"},"childPeerIds":["viewer-1"],"sfuPublicationGeneration":null}}`, "route-update"}, - {`{"type":"sfu-config","revision":8,"url":"wss://example.test","token":"opaque"}`, "sfu-config"}, - } - for _, test := range tests { - message, err := decodeServerMessage([]byte(test.payload)) - if err != nil || message.Type != test.typeName { - t.Fatalf("decode %s = %+v, %v", test.typeName, message, err) - } - } -} - -func TestDecodeOrdinaryHostAuthenticationRejectsInvalidVariants(t *testing.T) { - tests := map[string]func(map[string]any){ - "missing-protocol": func(message map[string]any) { delete(message, "protocol") }, - "wrong-protocol": func(message map[string]any) { message["protocol"] = "screener-v5" }, - "missing-max": func(message map[string]any) { delete(message, "maxViewers") }, - "zero-max": func(message map[string]any) { message["maxViewers"] = 0 }, - "too-large-max": func(message map[string]any) { message["maxViewers"] = maxProtocolViewers + 1 }, - "missing-endpoint-cap": func(message map[string]any) { delete(message, "endpointMediaCopyCapacity") }, - "zero-endpoint-cap": func(message map[string]any) { message["endpointMediaCopyCapacity"] = 0 }, - "too-large-endpoint-cap": func(message map[string]any) { message["endpointMediaCopyCapacity"] = 4 }, - "peer-mode-extra": func(message map[string]any) { message["mediaMode"] = "peer-assisted" }, - "wrong-role": func(message map[string]any) { message["role"] = "viewer" }, - "legacy-ice-shape": func(message map[string]any) { - message["iceConfig"].(map[string]any)["expiresAt"] = nil - }, - "turn-url": func(message map[string]any) { - message["iceConfig"] = map[string]any{"iceServers": []any{map[string]any{"urls": "turn:example.test"}}} - }, - "missing-viewer-policy": func(message map[string]any) { delete(message, "viewerPolicy") }, - "wrong-viewer-policy": func(message map[string]any) { message["viewerPolicy"] = "friends" }, - "missing-viewer-generation": func(message map[string]any) { delete(message, "viewerAuthorizationGeneration") }, - "invalid-viewer-generation": func(message map[string]any) { message["viewerAuthorizationGeneration"] = "short" }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - var candidate map[string]any - if err := json.Unmarshal([]byte(validHostAuthenticated), &candidate); err != nil { - t.Fatal(err) - } - mutate(candidate) - payload, err := json.Marshal(candidate) - if err != nil { - t.Fatal(err) - } - if _, err = decodeServerMessage(payload); err == nil { - t.Fatal("invalid authentication was accepted") - } - }) - } -} - -func TestDecodeServerMessageRejectsRemovedICERefresh(t *testing.T) { - payload := []byte(`{"type":"ice-config","iceConfig":{"iceServers":[]}}`) - if _, err := decodeServerMessage(payload); err == nil { - t.Fatal("removed ICE refresh message was accepted") - } -} - -func TestDecodeUnknownServerMessageDoesNotReflectUntrustedType(t *testing.T) { - const secret = "password=g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@https://private.test/path" - _, err := decodeServerMessage([]byte(`{"type":"` + secret + `"}`)) - if err == nil || err.Error() != "signaling message type is unsupported" { - t.Fatalf("unknown message error = %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatal("unknown message error reflected untrusted input") - } -} - -func TestDecodeAndIgnoreBoundedViewerQualityEvidence(t *testing.T) { - const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}` - message, err := decodeServerMessage([]byte(valid)) - if err != nil { - t.Fatal(err) - } - if message.Type != "viewer-quality-evidence" { - t.Fatalf("message type = %q", message.Type) - } - if err = (&Session{}).handle(message); err != nil { - t.Fatalf("fixed-HIGH rejected read-only viewer evidence: %v", err) - } -} - -func TestDecodeViewerQualityEvidenceRejectsInvalidVariants(t *testing.T) { - const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}` - tests := map[string]func(map[string]any){ - "extra-envelope-field": func(message map[string]any) { message["roomId"] = "1" }, - "missing-metric": func(message map[string]any) { - delete(message["metrics"].(map[string]any), "bitrateKbps") - }, - "extra-metric": func(message map[string]any) { - message["metrics"].(map[string]any)["decoderImplementation"] = "private" - }, - "extra-guard-field": func(message map[string]any) { - message["guard"].(map[string]any)["peerId"] = "viewer-1" - }, - "missing-guard-field": func(message map[string]any) { - delete(message["guard"].(map[string]any), "routeRevision") - }, - "invalid-viewer-id": func(message map[string]any) { - message["viewerPeerId"] = "short" - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - var candidate map[string]any - if err := json.Unmarshal([]byte(valid), &candidate); err != nil { - t.Fatal(err) - } - mutate(candidate) - payload, err := json.Marshal(candidate) - if err != nil { - t.Fatal(err) - } - if _, err = decodeServerMessage(payload); err == nil { - t.Fatal("invalid viewer quality evidence was accepted") - } - }) - } -} - -func TestDecodeViewerQualityEvidenceRejectsOversizedPayload(t *testing.T) { - payload := make([]byte, maxQualityEvidence+1) - if err := validateViewerQualityEvidence(payload); err == nil || err.Error() != "viewer quality evidence is too large" { - t.Fatalf("oversized viewer quality evidence error = %v", err) - } -} - -func TestPionConfigurationNormalizesUppercaseStunScheme(t *testing.T) { - config, err := decodeICEConfig([]byte(`{"iceServers":[{"urls":["STUN:[2001:db8::1]:3478"]}]}`)) - if err != nil { - t.Fatal(err) - } - pionConfig, err := pionConfiguration(config) - if err != nil { - t.Fatal(err) - } - if got := pionConfig.ICEServers[0].URLs[0]; got != "stun:[2001:db8::1]:3478" { - t.Fatalf("Pion STUN URL = %q", got) - } -} - -func TestDecodeInboundSignalRejectsCrossVariantFields(t *testing.T) { - tests := []string{ - `{"kind":"description","connectionId":"connection-1","description":{"type":"answer","sdp":"v=0"},"candidate":null}`, - `{"kind":"candidate","connectionId":"connection-1"}`, - `{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}`, - } - for _, payload := range tests { - if _, err := decodeInboundSignalPayload([]byte(payload)); err == nil { - t.Fatalf("invalid payload was accepted: %s", payload) - } - } -} - -func TestDecodeInboundSignalRejectsCandidateLineAboveProtocolBound(t *testing.T) { - payload := []byte(`{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMLineIndex":256}}`) - if _, err := decodeInboundSignalPayload(payload); err == nil { - t.Fatal("candidate line 256 was accepted") - } -} - -func TestMarshalAbandonRoomShape(t *testing.T) { - encoded, err := json.Marshal(simpleMessage{Type: "abandon-room"}) - if err != nil { - t.Fatal(err) - } - if string(encoded) != `{"type":"abandon-room"}` { - t.Fatalf("abandon JSON = %s", encoded) - } -} diff --git a/native/sender/internal/windowcapture/provider.go b/native/sender/internal/windowcapture/provider.go deleted file mode 100644 index 5c8a0cb..0000000 --- a/native/sender/internal/windowcapture/provider.go +++ /dev/null @@ -1,56 +0,0 @@ -package windowcapture - -import ( - "context" - "errors" -) - -var ErrUnavailable = errors.New("Windows 11 window capture is unavailable") - -type Target struct { - WindowHandle uint64 - PID uint32 - CreationTime uint64 - Title string -} - -type PCMChunk struct { - Timestamp100ns uint64 - Duration100ns uint64 - Data []byte -} - -type H264AccessUnit struct { - KeyFrame bool - Timestamp100ns uint64 - Duration100ns uint64 - Data []byte -} - -type Status struct { - State string `json:"state"` - HardwareOnly bool `json:"hardwareOnly"` - AdapterIndex *uint `json:"adapterIndex,omitempty"` - AdapterName string `json:"adapterName,omitempty"` - AdapterLUID string `json:"adapterLuid,omitempty"` - MFTIndex *uint `json:"mftIndex,omitempty"` - MFTName string `json:"mftName,omitempty"` - MFTCLSID string `json:"mftClsid,omitempty"` - ProfileLevelID string `json:"profileLevelId,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - FPS int `json:"fps,omitempty"` -} - -type Provider interface { - List(context.Context) ([]Target, error) - CaptureAudio(context.Context, Target, func(PCMChunk) error) error - CaptureWindow( - context.Context, - Target, - <-chan struct{}, - func(PCMChunk) error, - func(H264AccessUnit) error, - func(Status) error, - ) error -} diff --git a/native/sender/internal/windowcapture/provider_other.go b/native/sender/internal/windowcapture/provider_other.go deleted file mode 100644 index 7fad201..0000000 --- a/native/sender/internal/windowcapture/provider_other.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package windowcapture - -import "context" - -type unsupportedProvider struct{} - -func NewProvider() Provider { return unsupportedProvider{} } - -func (unsupportedProvider) List(context.Context) ([]Target, error) { return nil, ErrUnavailable } - -func (unsupportedProvider) CaptureAudio(context.Context, Target, func(PCMChunk) error) error { - return ErrUnavailable -} - -func (unsupportedProvider) CaptureWindow( - context.Context, - Target, - <-chan struct{}, - func(PCMChunk) error, - func(H264AccessUnit) error, - func(Status) error, -) error { - return ErrUnavailable -} diff --git a/native/sender/internal/windowcapture/provider_windows.go b/native/sender/internal/windowcapture/provider_windows.go deleted file mode 100644 index dc0b352..0000000 --- a/native/sender/internal/windowcapture/provider_windows.go +++ /dev/null @@ -1,307 +0,0 @@ -//go:build windows - -package windowcapture - -import ( - "bytes" - "context" - "encoding/binary" - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - - "golang.org/x/sys/windows" -) - -const ( - maxListBytes = 64 << 10 - maxTargets = 100 - helperHeaderSize = 28 - maxStatusBytes = 4 << 10 - maxMediaBytes = 1 << 20 - maxPCMBytes = 48_000 * 2 * 2 / 10 - helperKindPCM = 1 - helperKindH264 = 2 - helperKindStatus = 3 - helperProtocolMagic = "SMED" -) - -type windowsProvider struct { - helper string - adapterIndex uint - mftIndex uint -} - -type helperTarget struct { - WindowHandle uint64 `json:"windowHandle"` - PID uint32 `json:"pid"` - CreationTime uint64 `json:"creationTime"` - Title string `json:"title"` -} - -func NewProvider() Provider { - helper := strings.TrimSpace(os.Getenv("SCREENER_WINDOW_CAPTURE_HELPER")) - if helper == "" { - if executable, err := os.Executable(); err == nil { - helper = filepath.Join(filepath.Dir(executable), "screener-window-capture.exe") - } - } - return &windowsProvider{ - helper: helper, - adapterIndex: parseOptionalIndex("SCREENER_WINDOW_CAPTURE_ADAPTER_INDEX"), - mftIndex: parseOptionalIndex("SCREENER_WINDOW_CAPTURE_MFT_INDEX"), - } -} - -func parseOptionalIndex(name string) uint { - value, err := strconv.ParseUint(strings.TrimSpace(os.Getenv(name)), 10, 32) - if err != nil { - return 0 - } - return uint(value) -} - -func (provider *windowsProvider) available() bool { - version := windows.RtlGetVersion() - if version.MajorVersion < 10 || version.BuildNumber < 22_000 || provider.helper == "" { - return false - } - info, err := os.Stat(provider.helper) - return err == nil && !info.IsDir() -} - -func (provider *windowsProvider) List(ctx context.Context) ([]Target, error) { - if !provider.available() { - return nil, ErrUnavailable - } - command := exec.CommandContext(ctx, provider.helper, "--list") - stdout, err := command.StdoutPipe() - if err != nil { - return nil, ErrUnavailable - } - command.Stderr = io.Discard - if err = command.Start(); err != nil { - return nil, ErrUnavailable - } - payload, readErr := io.ReadAll(io.LimitReader(stdout, maxListBytes+1)) - if (readErr != nil || len(payload) > maxListBytes) && ctx.Err() == nil { - _ = command.Process.Kill() - } - waitErr := command.Wait() - if readErr != nil || waitErr != nil || len(payload) > maxListBytes { - return nil, ErrUnavailable - } - var listed []helperTarget - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - if decoder.Decode(&listed) != nil || decoder.Decode(&struct{}{}) != io.EOF || len(listed) > maxTargets { - return nil, ErrUnavailable - } - targets := make([]Target, 0, len(listed)) - for _, candidate := range listed { - title := strings.TrimSpace(candidate.Title) - if candidate.WindowHandle == 0 || candidate.PID == 0 || candidate.CreationTime == 0 || - title == "" || len(title) > 512 { - continue - } - targets = append(targets, Target{ - WindowHandle: candidate.WindowHandle, - PID: candidate.PID, - CreationTime: candidate.CreationTime, - Title: title, - }) - } - return targets, nil -} - -func (provider *windowsProvider) CaptureAudio( - ctx context.Context, - target Target, - onPCM func(PCMChunk) error, -) error { - return provider.capture(ctx, target, - []string{"--capture-audio", targetPID(target), targetCreationTime(target)}, - nil, onPCM, nil, nil) -} - -func (provider *windowsProvider) CaptureWindow( - ctx context.Context, - target Target, - keyFrames <-chan struct{}, - onPCM func(PCMChunk) error, - onH264 func(H264AccessUnit) error, - onStatus func(Status) error, -) error { - if keyFrames == nil || onPCM == nil || onH264 == nil || onStatus == nil { - return ErrUnavailable - } - arguments := []string{ - "--capture-window", targetPID(target), targetCreationTime(target), - strconv.FormatUint(target.WindowHandle, 10), - "--adapter-index", strconv.FormatUint(uint64(provider.adapterIndex), 10), - "--mft-index", strconv.FormatUint(uint64(provider.mftIndex), 10), - "--protocol-v1", - } - return provider.capture(ctx, target, arguments, keyFrames, onPCM, onH264, onStatus) -} - -func targetPID(target Target) string { return strconv.FormatUint(uint64(target.PID), 10) } - -func targetCreationTime(target Target) string { return strconv.FormatUint(target.CreationTime, 10) } - -func (provider *windowsProvider) capture( - ctx context.Context, - target Target, - arguments []string, - keyFrames <-chan struct{}, - onPCM func(PCMChunk) error, - onH264 func(H264AccessUnit) error, - onStatus func(Status) error, -) error { - windowMode := onH264 != nil && onStatus != nil - if !provider.available() || target.PID == 0 || target.CreationTime == 0 || onPCM == nil || - (windowMode && target.WindowHandle == 0) { - return ErrUnavailable - } - command := exec.CommandContext(ctx, provider.helper, arguments...) - stdout, err := command.StdoutPipe() - if err != nil { - return ErrUnavailable - } - var stdin io.WriteCloser - if windowMode { - stdin, err = command.StdinPipe() - if err != nil { - return ErrUnavailable - } - } - command.Stderr = io.Discard - if err = command.Start(); err != nil { - return ErrUnavailable - } - keyFrameDone := make(chan struct{}) - keyFrameStop := make(chan struct{}) - if windowMode { - go func() { - defer close(keyFrameDone) - defer stdin.Close() - for { - select { - case <-ctx.Done(): - return - case <-keyFrameStop: - return - case _, ok := <-keyFrames: - if !ok { - return - } - if _, writeErr := stdin.Write([]byte{'K'}); writeErr != nil { - return - } - } - } - }() - } else { - close(keyFrameDone) - } - - startedPCM := false - startedH264 := false - for { - header := make([]byte, helperHeaderSize) - if _, err = io.ReadFull(stdout, header); err != nil { - break - } - kind := header[5] - flags := header[6] - timestamp := binary.BigEndian.Uint64(header[8:16]) - duration := binary.BigEndian.Uint64(header[16:24]) - length := binary.BigEndian.Uint32(header[24:28]) - if string(header[:4]) != helperProtocolMagic || header[4] != 1 || header[7] != 0 || - length == 0 || !validHelperHeader(kind, flags, timestamp, duration, length, windowMode) { - err = errors.New("window capture helper returned an invalid media header") - break - } - data := make([]byte, length) - if _, err = io.ReadFull(stdout, data); err != nil { - break - } - switch kind { - case helperKindPCM: - startedPCM = true - err = onPCM(PCMChunk{Timestamp100ns: timestamp, Duration100ns: duration, Data: data}) - case helperKindH264: - startedH264 = true - err = onH264(H264AccessUnit{ - KeyFrame: flags == 1, Timestamp100ns: timestamp, Duration100ns: duration, Data: data, - }) - case helperKindStatus: - var status Status - err = decodeStatus(data, &status) - if err == nil { - err = onStatus(status) - } - } - if err != nil { - break - } - } - if err != nil && ctx.Err() == nil { - _ = command.Process.Kill() - } - _ = command.Wait() - if windowMode { - close(keyFrameStop) - _ = stdin.Close() - } - <-keyFrameDone - if ctx.Err() != nil { - return nil - } - if err != nil || (windowMode && !startedH264) || (!windowMode && !startedPCM) { - return errors.New("selected window capture stopped") - } - return errors.New("selected target process exited") -} - -func validHelperHeader(kind, flags byte, timestamp, duration uint64, length uint32, windowMode bool) bool { - switch kind { - case helperKindPCM: - return flags == 0 && duration > 0 && duration <= 10_000_000 && - length <= maxPCMBytes && length%4 == 0 - case helperKindH264: - return windowMode && flags <= 1 && timestamp > 0 && duration >= 10 && - duration <= 10_000_000 && duration%10 == 0 && length <= maxMediaBytes - case helperKindStatus: - return windowMode && flags == 0 && timestamp == 0 && duration == 0 && length <= maxStatusBytes - default: - return false - } -} - -func decodeStatus(payload []byte, status *Status) error { - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - if decoder.Decode(status) != nil || decoder.Decode(&struct{}{}) != io.EOF || !status.HardwareOnly { - return errors.New("window capture helper returned invalid status") - } - switch status.State { - case "starting": - if status.AdapterIndex == nil || status.MFTIndex == nil || status.AdapterName == "" || - status.AdapterLUID == "" || status.MFTName == "" || status.MFTCLSID == "" { - return errors.New("window capture helper returned incomplete hardware status") - } - case "active": - if status.ProfileLevelID != "42c01f" || status.Width != 1280 || status.Height != 720 || status.FPS != 30 { - return errors.New("window capture helper returned unexpected video status") - } - default: - return errors.New("window capture helper returned unknown status") - } - return nil -} diff --git a/native/sender/internal/windowcapture/provider_windows_test.go b/native/sender/internal/windowcapture/provider_windows_test.go deleted file mode 100644 index e54c21c..0000000 --- a/native/sender/internal/windowcapture/provider_windows_test.go +++ /dev/null @@ -1,149 +0,0 @@ -//go:build windows - -package windowcapture - -import ( - "context" - "encoding/binary" - "io" - "os" - "reflect" - "testing" - "time" -) - -const testCreationTime = uint64(123456) -const testWindowHandle = uint64(987654) - -func TestMain(main *testing.M) { - if len(os.Args) > 1 { - switch os.Args[1] { - case "--list": - _, _ = os.Stdout.Write(make([]byte, maxListBytes+1)) - select {} - case "--capture-audio": - if !reflect.DeepEqual(os.Args[2:], []string{"1", "123456"}) { - os.Exit(3) - } - writeTestEnvelope(helperKindPCM, 0, 1, 200_000, make([]byte, 4)) - _, _ = os.Stdout.Write(make([]byte, helperHeaderSize)) - select {} - case "--capture-window": - expected := []string{ - "1", "123456", "987654", "--adapter-index", "2", - "--mft-index", "3", "--protocol-v1", - } - if !reflect.DeepEqual(os.Args[2:], expected) { - os.Exit(4) - } - status := []byte(`{"state":"starting","hardwareOnly":true,"adapterIndex":2,"adapterName":"GPU","adapterLuid":"0:1","mftIndex":3,"mftName":"MFT","mftClsid":"clsid"}`) - writeTestEnvelope(helperKindStatus, 0, 0, 0, status) - writeTestEnvelope(helperKindH264, 1, 100, 333_330, []byte{0, 0, 0, 1, 0x65}) - request := make([]byte, 1) - if _, err := io.ReadFull(os.Stdin, request); err != nil || request[0] != 'K' { - os.Exit(5) - } - _, _ = os.Stdout.Write(make([]byte, helperHeaderSize)) - select {} - } - } - os.Exit(main.Run()) -} - -func writeTestEnvelope(kind, flags byte, timestamp, duration uint64, payload []byte) { - header := make([]byte, helperHeaderSize) - copy(header, helperProtocolMagic) - header[4] = 1 - header[5] = kind - header[6] = flags - binary.BigEndian.PutUint64(header[8:16], timestamp) - binary.BigEndian.PutUint64(header[16:24], duration) - binary.BigEndian.PutUint32(header[24:28], uint32(len(payload))) - _, _ = os.Stdout.Write(header) - _, _ = os.Stdout.Write(payload) -} - -func TestCaptureAudioPassesIdentityAndKillsHelperBeforeWaitOnInvalidHeader(t *testing.T) { - provider := &windowsProvider{helper: os.Args[0]} - if !provider.available() { - t.Skip("requires Windows 11") - } - - result := make(chan error, 1) - chunks := 0 - go func() { - result <- provider.CaptureAudio(context.Background(), Target{ - WindowHandle: testWindowHandle, PID: 1, CreationTime: testCreationTime, - }, func(PCMChunk) error { - chunks++ - return nil - }) - }() - - select { - case err := <-result: - if err == nil || chunks != 1 { - t.Fatalf("CaptureAudio error = %v, chunks = %d", err, chunks) - } - case <-time.After(2 * time.Second): - t.Fatal("CaptureAudio waited on a running helper after an invalid header") - } -} - -func TestCaptureWindowPassesBoundIdentityAndForwardsKeyFrameRequest(t *testing.T) { - provider := &windowsProvider{helper: os.Args[0], adapterIndex: 2, mftIndex: 3} - if !provider.available() { - t.Skip("requires Windows 11") - } - - keyFrames := make(chan struct{}, 1) - result := make(chan error, 1) - statuses, accessUnits := 0, 0 - go func() { - result <- provider.CaptureWindow(context.Background(), Target{ - WindowHandle: testWindowHandle, PID: 1, CreationTime: testCreationTime, - }, keyFrames, func(PCMChunk) error { - return nil - }, func(unit H264AccessUnit) error { - accessUnits++ - if !unit.KeyFrame { - t.Error("first access unit was not a key frame") - } - keyFrames <- struct{}{} - return nil - }, func(Status) error { - statuses++ - return nil - }) - }() - - select { - case err := <-result: - if err == nil || statuses != 1 || accessUnits != 1 { - t.Fatalf("CaptureWindow error = %v, statuses = %d, access units = %d", err, statuses, accessUnits) - } - case <-time.After(2 * time.Second): - t.Fatal("CaptureWindow did not forward the key-frame request or kill the invalid helper") - } -} - -func TestListKillsHelperBeforeWaitingOnOversizeOutput(t *testing.T) { - provider := &windowsProvider{helper: os.Args[0]} - if !provider.available() { - t.Skip("requires Windows 11") - } - - result := make(chan error, 1) - go func() { - _, err := provider.List(context.Background()) - result <- err - }() - select { - case err := <-result: - if err == nil { - t.Fatal("List accepted oversized helper output") - } - case <-time.After(2 * time.Second): - t.Fatal("List waited on a running helper after oversized output") - } -} diff --git a/native/window-capture-helper/README.md b/native/window-capture-helper/README.md deleted file mode 100644 index 6b1c5ea..0000000 --- a/native/window-capture-helper/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Windows Window-Capture Helper - -This Windows 11 helper is the local input for Native sender window capture. It -enumerates visible top-level windows locally, binds the selected HWND to its PID -and process creation time, and has two explicit modes: - -- process-tree PCM for the existing browser video sources; or -- WGC/D3D11 window video plus process-tree PCM, with adapter-bound, - hardware-only Media Foundation H.264 output. - -It has no whole-system audio, software encoder, alternate codec, or network -fallback. Build it outside the repository, then place it beside the sender (or -set `SCREENER_WINDOW_CAPTURE_HELPER` for a development run): - -```powershell -$out = Join-Path ([IO.Path]::GetTempPath()) 'screener-native-capture' -./native/window-capture-helper/build.ps1 -OutputDirectory $out -``` - -The implementation follows Microsoft's MIT-licensed reference samples and -official API contracts without copying their WIL framework. The retained MF -fixture compiles the same encoder source with `SCREENER_H264_FIXTURE`; there is -not a second product MFT implementation. - -- -- -- -- -- -- diff --git a/package-lock.json b/package-lock.json index 75327c2..f3b4b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,7 @@ "name": "screener", "version": "0.1.0", "dependencies": { - "livekit-client": "2.22.1", "livekit-server-sdk": "2.18.0", - "react": "19.2.8", - "react-dom": "19.2.8", - "sdp-transform": "3.0.0", "sirv": "3.0.2", "ws": "8.21.3", "zod": "4.4.3" @@ -24,6 +20,10 @@ "@types/sdp-transform": "3.0.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "6.0.5", + "livekit-client": "2.22.1", + "react": "19.2.8", + "react-dom": "19.2.8", + "sdp-transform": "3.0.0", "tsx": "4.23.12", "typescript": "~6.0.3", "vite": "8.2.1", @@ -492,12 +492,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@livekit/protocol": { "version": "1.50.4", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^1.10.0" @@ -811,6 +813,7 @@ "version": "1.0.22", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "dev": true, "license": "MIT", "peer": true }, @@ -1114,6 +1117,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -1166,6 +1170,7 @@ "version": "6.2.9", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1448,6 +1453,7 @@ "version": "2.22.1", "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.1.tgz", "integrity": "sha512-kCxTlGOV2XWYXZ7zK01UUKjgpVydvJ0CBgdHuv2I+s4q08tn0UTzpHfFqXPb5Aba4h6KdNU0TjQCHzv4Lww7fQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@livekit/mutex": "1.1.1", @@ -1469,6 +1475,7 @@ "version": "2.15.0", "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "dev": true, "license": "MIT", "bin": { "sdp-verify": "checker.js" @@ -1510,6 +1517,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -1523,6 +1531,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "dev": true, "license": "MIT", "engines": { "node": ">=22.22" @@ -1640,6 +1649,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1649,6 +1659,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -1694,6 +1705,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1704,18 +1716,21 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/sdp": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "dev": true, "license": "MIT" }, "node_modules/sdp-transform": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==", + "dev": true, "license": "MIT", "bin": { "sdp-verify": "checker.js" @@ -1823,6 +1838,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -1848,6 +1864,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "dev": true, "license": "MIT", "optionalDependencies": { "rxjs": "*" @@ -2046,6 +2063,7 @@ "version": "9.0.6", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "sdp": "^3.2.0" diff --git a/package.json b/package.json index da3a464..84a7977 100644 --- a/package.json +++ b/package.json @@ -12,22 +12,30 @@ "build": "npm run build:client && npm run build:server", "build:client": "vite build", "build:server": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist/server', { recursive: true, force: true })\" && tsc -p tsconfig.server.json", + "package:client": "node scripts/assemble-client.mjs", + "package:client-candidate": "node scripts/package-client-candidate.mjs", + "check:client": "node scripts/check-client.mjs", + "check:client-core": "node scripts/check-client.mjs --core", + "check:client-capture": "node scripts/check-client.mjs --capture-only", + "gate:client-media": "tsx scripts/client-media-gate.ts", + "gate:client-native-host": "tsx scripts/client-native-host-gate.ts", + "gate:client-cross-nat": "tsx scripts/client-native-host-gate.ts", + "gate:client-link-media": "tsx scripts/client-native-host-gate.ts", + "gate:client-native-sfu": "tsx scripts/client-native-sfu-gate.ts", + "gate:client-link": "tsx scripts/client-link-gate.ts", "start": "node dist/server/server/index.js", "gate:peer-topology-loopback": "tsx scripts/peer-assisted-benchmark.ts", "gate:sfu-admission": "vitest run tests/room-route-controller.test.ts tests/hybrid-media-router.test.ts tests/sfu-resource-admission.test.ts tests/sfu-room-control.test.ts tests/livekit-token.test.ts tests/sfu-client.test.ts tests/route-transition.test.ts tests/server-config.test.ts tests/server-http.test.ts tests/server-signal.test.ts", "gate:access-privacy": "npm run build:client && vitest run tests/server-signal.test.ts && tsx scripts/access-privacy-gate.ts", - "probe:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts", + "probe:client-loopback": "tsx scripts/client-loopback-gate.ts", + "gate:client-local": "tsx scripts/client-local-gate.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", "check": "npm run typecheck && npm test && npm run build" }, "dependencies": { - "livekit-client": "2.22.1", "livekit-server-sdk": "2.18.0", - "react": "19.2.8", - "react-dom": "19.2.8", - "sdp-transform": "3.0.0", "sirv": "3.0.2", "ws": "8.21.3", "zod": "4.4.3" @@ -39,6 +47,10 @@ "@types/sdp-transform": "3.0.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "6.0.5", + "livekit-client": "2.22.1", + "react": "19.2.8", + "react-dom": "19.2.8", + "sdp-transform": "3.0.0", "tsx": "4.23.12", "typescript": "~6.0.3", "vite": "8.2.1", diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts index bbbe43d..c6a85f9 100644 --- a/scripts/access-privacy-gate.ts +++ b/scripts/access-privacy-gate.ts @@ -101,7 +101,10 @@ async function main(): Promise { maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2, peerAssistedMedia: false, stunUrls: [], natPredictionEnabled: false, }; - server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") }); + server = await createScreenerServer({ + config, + frontend: { mode: "static", directory: resolve(root, "dist/client") }, + }); server.httpServer.prependListener("request", (request, response) => { const target = request.url ?? ""; const record = `${request.method ?? ""} ${target} ${request.headers.referer ?? ""}`; diff --git a/scripts/assemble-client.mjs b/scripts/assemble-client.mjs new file mode 100644 index 0000000..87183c4 --- /dev/null +++ b/scripts/assemble-client.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { clientPackageTarget } from "./client-package-targets.mjs"; + +function fail(message) { + throw new Error(message); +} + +function run(command, args, cwd, environment = process.env) { + const result = spawnSync(command, args, { + cwd, + env: environment, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail(`${command} failed: ${(result.stderr || result.stdout).trim()}`); + } + return result.stdout.trim(); +} + +function runNpm(args, cwd) { + if (process.platform === "win32") { + run(process.env.ComSpec || "cmd.exe", [ + "/d", "/s", "/c", `npm ${args.join(" ")}`, + ], cwd); + return; + } + run("npm", args, cwd); +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function bytesAt(path, length, position = 0) { + const descriptor = openSync(path, "r"); + try { + const bytes = Buffer.alloc(length); + return bytes.subarray(0, readSync(descriptor, bytes, 0, length, position)); + } finally { + closeSync(descriptor); + } +} + +function executableMatchesTarget(path, target) { + const header = bytesAt(path, 64); + if (target.goos === "windows") { + if (header.length < 64 || header[0] !== 0x4d || header[1] !== 0x5a) { + return false; + } + const pe = bytesAt(path, 6, header.readUInt32LE(0x3c)); + return pe.length === 6 && pe.subarray(0, 4).equals(Buffer.from("PE\0\0")) && + pe.readUInt16LE(4) === 0x8664; + } + if (target.goos === "linux") { + return header.length >= 20 && header.subarray(0, 4).equals( + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + ) && header[4] === 2 && header[5] === 1 && header.readUInt16LE(18) === 0x3e; + } + return header.length >= 8 && header.subarray(0, 4).equals( + Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + ) && header.readUInt32LE(4) === 0x0100000c; +} + +function assertTargetExecutable(path, target, label) { + if (!lstatSync(path).isFile() || !executableMatchesTarget(path, target)) { + fail(`${label} does not match Client package target ${target.id}`); + } +} + +function plainName(value, label) { + if ( + typeof value !== "string" || + !/^[A-Za-z0-9._-]+$/.test(value) || + value === "." || + value === ".." + ) { + fail(`Release ${label} is invalid`); + } + return value; +} + +function readDescriptor(path) { + let value; + try { + value = JSON.parse(readFileSync(path, "utf8")); + } catch { + fail("Application release descriptor is invalid"); + } + if ( + value?.schema !== 1 || + typeof value.revision !== "string" || + !/^[0-9a-f]{40}$/.test(value.revision) || + typeof value.artifactSha256 !== "string" || + !/^[0-9a-f]{64}$/.test(value.artifactSha256) + ) { + fail("Application release descriptor is invalid"); + } + return { + revision: value.revision, + artifact: plainName(value.artifact, "artifact"), + artifactSha256: value.artifactSha256, + }; +} + +function assertOutsideRepository(repositoryRoot, outputRoot) { + const path = relative(repositoryRoot, outputRoot); + if (path === "" || (!path.startsWith("..") && !isAbsolute(path))) { + fail("Client output directory must be outside the repository"); + } + if (existsSync(outputRoot)) { + fail("Client output directory must not already exist"); + } +} + +const positional = process.argv.slice(2, 5); +const options = process.argv.slice(5); +if (positional.length !== 3 || options.length % 2 !== 0) { + fail( + "Usage: node scripts/assemble-client.mjs --target [--capture ] [--tunnel ]", + ); +} + +let targetArgument = null; +let captureArgument = null; +let tunnelArgument = null; +for (let index = 0; index < options.length; index += 2) { + const name = options[index]; + const value = options[index + 1]; + if ( + !value || + (name !== "--target" && name !== "--capture" && name !== "--tunnel") + ) { + fail("Client package option is invalid"); + } + if (name === "--target" && targetArgument === null) { + targetArgument = value; + } else if (name === "--capture" && captureArgument === null) { + captureArgument = value; + } else if (name === "--tunnel" && tunnelArgument === null) { + tunnelArgument = value; + } else { + fail("Client package option is duplicated"); + } +} +const target = clientPackageTarget(targetArgument); +if (!target) fail("Client package target is invalid"); + +const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); +const descriptorPath = realpathSync(resolve(positional[0])); +const nodePath = realpathSync(resolve(positional[1])); +const outputRoot = resolve(process.cwd(), positional[2]); +const capturePath = captureArgument ? realpathSync(resolve(captureArgument)) : null; +const tunnelPath = tunnelArgument ? realpathSync(resolve(tunnelArgument)) : null; +assertOutsideRepository(repositoryRoot, outputRoot); +assertTargetExecutable(nodePath, target, "Node runtime"); +const expectedNodeVersion = `v${readFileSync( + join(repositoryRoot, ".node-version"), + "ascii", +).trim()}`; +if (run(nodePath, ["--version"], repositoryRoot) !== expectedNodeVersion) { + fail(`Node runtime must be ${expectedNodeVersion}`); +} +if (capturePath && !target.captureName) { + fail("Capture runtime is invalid for the Client package target"); +} +if (capturePath) assertTargetExecutable(capturePath, target, "Capture runtime"); +if (tunnelPath) assertTargetExecutable(tunnelPath, target, "Public tunnel runtime"); + +const descriptor = readDescriptor(descriptorPath); +const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase(); +if (revision !== descriptor.revision) { + fail("Client source and application release revisions do not match"); +} +if (run("git", ["status", "--porcelain"], repositoryRoot) !== "") { + fail("Client assembly requires a clean Git checkout"); +} +const artifactPath = join(dirname(descriptorPath), descriptor.artifact); +if (!existsSync(artifactPath) || sha256(artifactPath) !== descriptor.artifactSha256) { + fail("Application release artifact does not match its descriptor"); +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-client-${revision.slice(0, 7)}-`)); +const packageRoot = join(temporaryRoot, "package"); +const appRoot = join(packageRoot, "app"); +try { + mkdirSync(appRoot, { recursive: true }); + run("tar", ["-xzf", artifactPath, "-C", appRoot], repositoryRoot); + if (readFileSync(join(appRoot, "REVISION"), "ascii") !== `${revision}\n`) { + fail("Extracted application revision does not match the Client"); + } + runNpm(["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], appRoot); + + const runtimeRoot = join(packageRoot, "runtime", "node"); + mkdirSync(runtimeRoot, { recursive: true }); + const nodeName = target.nodeName; + const packagedNode = join(runtimeRoot, nodeName); + copyFileSync(nodePath, packagedNode); + chmodSync(packagedNode, 0o755); + + let packagedCapture = null; + if (capturePath) { + const nativeRoot = join(packageRoot, "runtime", "native"); + mkdirSync(nativeRoot, { recursive: true }); + packagedCapture = join(nativeRoot, target.captureName); + copyFileSync(capturePath, packagedCapture); + chmodSync(packagedCapture, 0o755); + } + + let packagedTunnel = null; + if (tunnelPath) { + const tunnelRoot = join(packageRoot, "runtime", "tunnel"); + mkdirSync(tunnelRoot, { recursive: true }); + const tunnelName = target.tunnelName; + packagedTunnel = join(tunnelRoot, tunnelName); + copyFileSync(tunnelPath, packagedTunnel); + chmodSync(packagedTunnel, 0o755); + } + + const clientName = target.clientName; + const clientPath = join(packageRoot, clientName); + const goCommand = process.env.SCREENER_GO?.trim() || "go"; + run(goCommand, [ + "build", + "-trimpath", + "-ldflags", + `-s -w -X github.com/TNTcraftHIM/Screener/native/client/internal/clientapp.BuildRevision=${revision}`, + "-o", + clientPath, + "./cmd/screener-client", + ], join(repositoryRoot, "native", "client"), { + ...process.env, + GOOS: target.goos, + GOARCH: target.goarch, + CGO_ENABLED: "0", + }); + chmodSync(clientPath, 0o755); + writeFileSync(join(packageRoot, "REVISION"), `${revision}\n`, "ascii"); + + cpSync(packageRoot, outputRoot, { + recursive: true, + errorOnExist: true, + force: false, + }); + process.stdout.write(`${JSON.stringify({ + root: outputRoot, + revision, + target: target.id, + platform: target.goos, + arch: target.goarch, + client: clientName, + node: `runtime/node/${nodeName}`, + nativeCapture: packagedCapture ? `runtime/native/${target.captureName}` : null, + publicTunnel: packagedTunnel + ? `runtime/tunnel/${target.tunnelName}` + : null, + app: "app", + })}\n`); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/browser-gate-harness.ts b/scripts/browser-gate-harness.ts index 5491e6f..414a55f 100644 --- a/scripts/browser-gate-harness.ts +++ b/scripts/browser-gate-harness.ts @@ -464,7 +464,7 @@ $root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE) $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) $parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar) $name = [IO.Path]::GetFileName($root) -if ($parent -ine $temp -or $name -notmatch '^screener-(native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$') { exit 31 } +if ($parent -ine $temp -or $name -notmatch '^screener-(access-privacy|client-loopback|client-media)-[A-Za-z0-9_-]{6}$') { exit 31 } $rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly)) if ($rootEntries.Count -eq 0) { exit 0 } if ($rootEntries.Count -ne 1) { exit 34 } @@ -499,7 +499,7 @@ export function isExactGateProfile( ? dirname(candidate).toLowerCase() === temp.toLowerCase() : dirname(candidate) === temp; return same && - /^screener-(?:native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$/.test( + /^screener-(?:access-privacy|client-loopback|client-media)-[A-Za-z0-9_-]{6}$/.test( basename(candidate), ); } diff --git a/scripts/check-client.mjs b/scripts/check-client.mjs new file mode 100644 index 0000000..27379e9 --- /dev/null +++ b/scripts/check-client.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + realpathSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CLIENT_PACKAGE_TARGETS } from "./client-package-targets.mjs"; + +const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); +const clientRoot = join(root, "native", "client"); +const mode = process.argv[2] ?? "--all"; + +if (!["--all", "--core", "--capture-only"].includes(mode)) { + throw new Error("Usage: node scripts/check-client.mjs [--all|--core|--capture-only]"); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? root, + env: options.env ?? process.env, + encoding: "utf8", + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = options.capture + ? (result.stderr || result.stdout).trim() + : ""; + throw new Error(`${basename(command)} failed${detail ? `: ${detail}` : ""}`); + } + return options.capture ? result.stdout.trim() : ""; +} + +function checkCore() { + const go = process.env.SCREENER_GO?.trim() || "go"; + const goRoot = run(go, ["env", "GOROOT"], { cwd: clientRoot, capture: true }); + const gofmt = process.env.SCREENER_GOFMT?.trim() || + join(goRoot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt"); + const unformatted = run(gofmt, ["-l", "."], { cwd: clientRoot, capture: true }); + if (unformatted) { + throw new Error(`Go source is not formatted:\n${unformatted}`); + } + runClientTests(go); + run(go, ["vet", "./..."], { cwd: clientRoot }); + + const buildRoot = join(root, "build", "client-check"); + mkdirSync(buildRoot, { recursive: true }); + for (const target of CLIENT_PACKAGE_TARGETS) { + for (const command of ["screener-client", "screener-peer-gate"]) { + const outputName = target.goos === "windows" + ? `${command}.exe` + : `${command}-${target.id}`; + run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], { + cwd: clientRoot, + env: { + ...process.env, + GOOS: target.goos, + GOARCH: target.goarch, + CGO_ENABLED: "0", + }, + }); + } + } +} + +function runClientTests(go) { + if (process.platform !== "win32") { + run(go, ["test", "./..."], { cwd: clientRoot }); + return; + } + + // Windows associates its listen prompt with the test executable path. Keep + // the one UDP integration package at a stable path so repeated checks do not + // create a new firewall rule for every Go build directory. + const packages = run(go, ["list", "./..."], { cwd: clientRoot, capture: true }) + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + const stableNetworkPackages = [ + { + package: "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge", + binary: "mediaedge.test.exe", + }, + ]; + const stablePackageNames = new Set( + stableNetworkPackages.map((entry) => entry.package), + ); + const otherPackages = packages.filter((value) => !stablePackageNames.has(value)); + if (otherPackages.length > 0) { + run(go, ["test", ...otherPackages], { cwd: clientRoot }); + } + const stableRoot = join(root, "build", "client-check"); + mkdirSync(stableRoot, { recursive: true }); + for (const entry of stableNetworkPackages) { + const binary = join(stableRoot, entry.binary); + run(go, ["test", "-c", "-o", binary, entry.package], { + cwd: clientRoot, + }); + run(binary, ["-test.v"], { cwd: clientRoot }); + } +} + +function checkPlatformCapture() { + if (process.platform !== "win32" && process.platform !== "darwin") { + if (mode === "--capture-only") { + throw new Error("No native capture check exists for this platform"); + } + return; + } + const buildRoot = join(root, "build", "client-check"); + mkdirSync(buildRoot, { recursive: true }); + let executable; + if (process.platform === "win32") { + const systemPowerShell = join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const powershell = process.env.SCREENER_POWERSHELL?.trim() || + (existsSync(systemPowerShell) ? systemPowerShell : "pwsh"); + run(powershell, [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + join(clientRoot, "platform", "windows", "capture", "build.ps1"), + "-OutputDirectory", + buildRoot, + ]); + executable = join(buildRoot, "screener-client-capture.exe"); + } else { + run("sh", [ + join(clientRoot, "platform", "darwin", "capture", "build.sh"), + buildRoot, + ]); + executable = join(buildRoot, "screener-client-capture"); + } + if (!existsSync(executable)) { + throw new Error("Native capture build did not produce its executable"); + } + if (process.platform === "darwin") { + run(executable, ["--self-test"]); + } + const raw = run(executable, ["--probe"], { capture: true }); + const probe = JSON.parse(raw); + const expectedPlatform = process.platform === "win32" ? "windows" : "darwin"; + if ( + probe?.protocol !== 3 || + probe.platform !== expectedPlatform || + typeof probe.platformBuild !== "string" || + typeof probe.videoCapture !== "boolean" || + typeof probe.processAudio !== "boolean" || + typeof probe.systemAudio !== "boolean" || + !Array.isArray(probe.adapters) || + probe.adapters.some((adapter) => + !Number.isInteger(adapter?.index) || + typeof adapter?.name !== "string" || + typeof adapter?.identity !== "string" || + !Array.isArray(adapter?.hardwareH264) || + adapter.hardwareH264.some((encoder) => + !Number.isInteger(encoder?.index) || + typeof encoder?.name !== "string" || + typeof encoder?.identity !== "string" + ) + ) + ) { + throw new Error("Native capture probe returned an invalid contract"); + } + if (process.platform !== "win32") return; + const sources = JSON.parse(run(executable, ["--list"], { capture: true })); + if (!Array.isArray(sources) || sources.some((target) => + !["window", "display"].includes(target?.kind) || + !/^[1-9][0-9]{0,19}$/.test(target?.sourceId) || + typeof target?.title !== "string" || + (target.kind === "window" && + (!Number.isInteger(target?.pid) || + !/^[1-9][0-9]{0,19}$/.test(target?.creationTime))) || + (target.kind === "display" && + (target?.pid !== undefined || target?.creationTime !== undefined)) + )) { + throw new Error("Windows capture process returned an invalid source list"); + } +} + +if (mode !== "--capture-only") checkCore(); +if (mode !== "--core") checkPlatformCapture(); +process.stdout.write("Screener Client checks passed.\n"); diff --git a/scripts/client-gate-endpoint.ts b/scripts/client-gate-endpoint.ts new file mode 100644 index 0000000..a1fb2c7 --- /dev/null +++ b/scripts/client-gate-endpoint.ts @@ -0,0 +1,36 @@ +const INSTANCE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const CLIENT_PORT_START = 39_721; +const CLIENT_PORT_END = 39_730; + +export interface ClientEndpoint { + url: string; + host: string; + port: number; + instanceToken: string; +} + +export function decodeClientEndpoint(line: string): ClientEndpoint { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("Client endpoint is invalid"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Client endpoint is invalid"); + } + const endpoint = value as Partial; + if ( + !Number.isInteger(endpoint.port) || + endpoint.port! < CLIENT_PORT_START || + endpoint.port! > CLIENT_PORT_END || + endpoint.host !== `127.0.0.1:${endpoint.port}` || + endpoint.url !== `http://${endpoint.host}` || + typeof endpoint.instanceToken !== "string" || + !INSTANCE_TOKEN_PATTERN.test(endpoint.instanceToken) || + Object.keys(value).length !== 4 + ) { + throw new Error("Client endpoint is invalid"); + } + return endpoint as ClientEndpoint; +} diff --git a/scripts/client-link-gate.ts b/scripts/client-link-gate.ts new file mode 100644 index 0000000..c769cfa --- /dev/null +++ b/scripts/client-link-gate.ts @@ -0,0 +1,320 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { networkInterfaces, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { reservePort, withDeadline } from "./browser-gate-harness"; + +const ROOT = resolve(import.meta.dirname, ".."); +const BUILD_ROOT = join(ROOT, "build", "client-check"); + +interface GateResult { + passed: boolean; + linkCreated: boolean; + invitationUsesLink: boolean; + remotePage: boolean; + remoteWebSocket: boolean; + clientExited: boolean; + linkClosed: boolean; + error: string | null; + stage: string; +} + +class LineCapture { + readonly lines: string[] = []; + private pending = ""; + private stderr = ""; + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.on("data", (chunk: Buffer) => { + this.pending += chunk.toString(); + for (;;) { + const newline = this.pending.indexOf("\n"); + if (newline < 0) return; + const line = this.pending.slice(0, newline).trim(); + this.pending = this.pending.slice(newline + 1); + if (line) this.lines.push(line); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + this.stderr = (this.stderr + chunk.toString()).slice(-2_048); + }); + } + + async wait( + predicate: (line: string) => boolean, + timeoutMs = 45_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const line = this.lines.find(predicate); + if (line) return line; + if (this.child.exitCode !== null || this.child.signalCode !== null) { + throw new Error( + `Client exited before readiness (${String(this.child.exitCode)})` + + (this.stderr.trim() ? `: ${this.stderr.trim()}` : ""), + ); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + throw new Error("Client output timed out"); + } +} + +function run( + command: string, + args: string[], + cwd = ROOT, + environment: NodeJS.ProcessEnv = process.env, +): string { + const result = spawnSync(command, args, { + cwd, + env: environment, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout).trim() || `${command} failed`); + } + return result.stdout.trim(); +} + +function remoteOptions(): { + host: string; + user: string; + key: string; + ssh: string; +} { + const host = process.env.SCREENER_REMOTE_HOST?.trim(); + const key = process.env.SCREENER_REMOTE_SSH_KEY?.trim(); + if (!host || !key) { + throw new Error("SCREENER_REMOTE_HOST and SCREENER_REMOTE_SSH_KEY are required"); + } + return { + host, + key, + user: process.env.SCREENER_REMOTE_USER?.trim() || "root", + ssh: process.env.SCREENER_SSH?.trim() || "ssh", + }; +} + +function transportArgs(key: string): string[] { + const bindAddress = process.env.SCREENER_REMOTE_BIND_ADDRESS?.trim(); + return [ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=10", + ...(bindAddress ? ["-o", `BindAddress=${bindAddress}`] : []), + "-i", key, + ]; +} + +function localLANAddress(): string { + const addresses = Object.values(networkInterfaces()) + .flatMap((entries) => entries ?? []) + .filter((entry) => entry.family === "IPv4" && !entry.internal) + .map((entry) => entry.address); + const address = addresses.find((value) => + /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(value), + ); + if (!address) throw new Error("A private LAN IPv4 address is required"); + return address; +} + +async function waitForRemotePage( + ssh: string, + transport: string[], + destination: string, + url: string, +): Promise { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const result = spawnSync(ssh, [ + ...transport, + destination, + "curl", "--fail", "--silent", url, + ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + if (result.status === 0 && result.stdout.includes('
')) { + return result.stdout; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + throw new Error("public Viewer page did not become reachable"); +} + +async function createInvitation(port: number, password: string): Promise { + const origin = `http://localhost:${port}`; + const access = await fetch(`${origin}/api/site-access`, { + method: "POST", + headers: { Authorization: `Bearer ${password}`, Origin: origin }, + }); + const cookie = access.headers.get("set-cookie")?.split(";", 1)[0]; + if (!access.ok || !cookie) throw new Error("Local access authentication failed"); + const room = await fetch(`${origin}/api/rooms`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + Origin: origin, + }, + body: JSON.stringify({ codeEntryPolicy: "open" }), + }); + const value = await room.json() as { inviteUrl?: unknown }; + if (!room.ok || typeof value.inviteUrl !== "string") { + throw new Error("Local room creation failed"); + } + return value.inviteUrl; +} + +async function stopClient(child: ChildProcessWithoutNullStreams | null): Promise { + if (!child || child.exitCode !== null || child.signalCode !== null) return true; + child.stdin.write("\n"); + try { + await withDeadline( + () => new Promise((resolveExit) => child.once("exit", () => resolveExit())), + Date.now() + 8_000, + ); + } catch { + child.kill(); + } + return child.exitCode !== null || child.signalCode !== null; +} + +async function waitUntilLinkCloses(origin: string): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`${origin}/healthz`, { cache: "no-store" }); + if (!response.ok) return true; + } catch { + return true; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + return false; +} + +async function main(): Promise { + if (process.env.SCREENER_CLIENT_LINK_GATE !== "true") { + throw new Error("SCREENER_CLIENT_LINK_GATE=true is required"); + } + const remote = remoteOptions(); + const transport = transportArgs(remote.key); + const destination = `${remote.user}@${remote.host}`; + const go = process.env.SCREENER_GO?.trim() || "go"; + const configuredClient = process.env.SCREENER_CLIENT_EXE?.trim(); + const node = process.env.SCREENER_CLIENT_NODE?.trim() || process.execPath; + const app = process.env.SCREENER_CLIENT_APP?.trim() || ROOT; + const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() || + join(BUILD_ROOT, process.platform === "win32" ? "cloudflared.exe" : "cloudflared"); + const profile = await mkdtemp(join(tmpdir(), "screener-client-link-")); + const port = await reservePort(); + const clientBinary = configuredClient || join( + BUILD_ROOT, + process.platform === "win32" ? "screener-client.exe" : "screener-client", + ); + let client: ChildProcessWithoutNullStreams | null = null; + let publicOrigin = ""; + const result: GateResult = { + passed: false, + linkCreated: false, + invitationUsesLink: false, + remotePage: false, + remoteWebSocket: false, + clientExited: false, + linkClosed: false, + error: null, + stage: "setup", + }; + await mkdir(BUILD_ROOT, { recursive: true }); + try { + result.stage = "build"; + if (!configuredClient) { + run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"], + join(ROOT, "native", "client")); + } + result.stage = "client-start"; + client = spawn(clientBinary, [ + "--link", + "--node", node, + "--app", app, + "--config", join(profile, "client.json"), + "--port", String(port), + "--lan-address", localLANAddress(), + "--tunnel-process", tunnel, + ], { + cwd: ROOT, + env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" }, + stdio: "pipe", + windowsHide: true, + }); + const output = new LineCapture(client); + const passwordLine = await output.wait( + (line) => line.startsWith("Local access password: "), + ); + const originLine = await output.wait( + (line) => line.startsWith("Public invitation origin: "), + ); + publicOrigin = originLine.slice("Public invitation origin: ".length); + result.linkCreated = /^https:\/\/[a-z0-9-]+\.trycloudflare\.com$/.test(publicOrigin); + const invitation = await createInvitation( + port, + passwordLine.slice("Local access password: ".length), + ); + const invite = new URL(invitation); + result.invitationUsesLink = invite.origin === publicOrigin && /^#v=/.test(invite.hash); + + result.stage = "remote-page"; + const page = await waitForRemotePage( + remote.ssh, + transport, + destination, + invitation.split("#", 1)[0]!, + ); + result.remotePage = page.includes('
'); + + result.stage = "remote-websocket"; + const websocketProbe = [ + "curl --http1.1 --include --silent --no-buffer --max-time 5", + `--header ${JSON.stringify(`Origin:${publicOrigin}`)}`, + `--header ${JSON.stringify("Upgrade:websocket")}`, + `--header ${JSON.stringify("Connection:Upgrade")}`, + `--header ${JSON.stringify("Sec-WebSocket-Version:13")}`, + `--header ${JSON.stringify("Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==")}`, + JSON.stringify(new URL("/signal", publicOrigin).toString()), + "2>/dev/null || true", + ].join(" "); + const websocketDeadline = Date.now() + 20_000; + while (Date.now() < websocketDeadline && !result.remoteWebSocket) { + const upgrade = run(remote.ssh, [ + ...transport, + destination, + websocketProbe, + ]); + result.remoteWebSocket = upgrade.includes("101 Switching Protocols"); + if (!result.remoteWebSocket) { + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + } + result.passed = result.linkCreated && result.invitationUsesLink && + result.remotePage && result.remoteWebSocket; + if (!result.passed) throw new Error("one-link control path was incomplete"); + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } finally { + result.stage = result.passed ? "cleanup" : result.stage; + result.clientExited = await stopClient(client); + result.linkClosed = publicOrigin ? await waitUntilLinkCloses(publicOrigin) : true; + await rm(profile, { recursive: true, force: true }); + result.passed = result.passed && result.clientExited && result.linkClosed; + if (!result.passed && result.error === null) result.error = "cleanup failed"; + process.stdout.write(JSON.stringify(result) + "\n"); + } + if (!result.passed) process.exitCode = 1; +} + +void main().catch((error: unknown) => { + process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n"); + process.exitCode = 1; +}); diff --git a/scripts/client-local-gate.ts b/scripts/client-local-gate.ts new file mode 100644 index 0000000..ae621a7 --- /dev/null +++ b/scripts/client-local-gate.ts @@ -0,0 +1,229 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + fetchJsonBefore, + reservePort, + waitForSample, + waitForVersion, + withDeadline, +} from "./browser-gate-harness"; +import { + decodeClientEndpoint, + type ClientEndpoint as Endpoint, +} from "./client-gate-endpoint"; + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); + +interface GateReport { + passed: boolean; + platform: NodeJS.Platform; + browser: string | null; + clientStarted: boolean; + localServerReady: boolean; + bootstrapAuthenticated: boolean; + bootstrapRemoved: boolean; + hostReady: boolean; + lanInvite: boolean; + clientExited: boolean; + browserExited: boolean; + portsClosed: boolean; + profileRemoved: boolean; + error: string | null; +} + +interface LocalPageState { + bootstrapPresent: boolean; + access: { required: boolean; authenticated: boolean } | null; + hostReady: boolean; +} + +const LOCAL_PAGE_STATE = `fetch('/api/site-access') + .then((response) => response.json()) + .catch(() => null) + .then((access) => ({ + bootstrapPresent: location.hash.startsWith('#client-access='), + access, + hostReady: Boolean(document.querySelector('.lr-host-personal-controls')), +}))`; + +async function readEndpoint( + client: ChildProcessWithoutNullStreams, +): Promise { + let buffered = ""; + return withDeadline( + () => new Promise((resolveEndpoint, rejectEndpoint) => { + const onData = (chunk: Buffer) => { + buffered += chunk.toString(); + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + client.stdout.off("data", onData); + try { + resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline))); + } catch (error) { + rejectEndpoint(error); + } + }; + client.stdout.on("data", onData); + client.once("error", rejectEndpoint); + client.once("exit", (code) => + rejectEndpoint(new Error(`Client exited before readiness (${code ?? "signal"})`)), + ); + }), + Date.now() + 10_000, + ); +} + +async function main(): Promise { + if (process.env.SCREENER_CLIENT_LOCAL_GATE !== "true") { + throw new Error("Local Client gate was not explicitly enabled"); + } + const browserPath = process.env.CHROME_PATH?.trim(); + const clientPath = process.env.SCREENER_CLIENT_EXE?.trim(); + const nodePath = process.env.SCREENER_CLIENT_NODE?.trim(); + const appDirectory = process.env.SCREENER_CLIENT_APP?.trim() || ROOT; + const lanAddress = process.env.SCREENER_CLIENT_GATE_LAN_ADDRESS?.trim(); + if (!browserPath || !clientPath || !nodePath || !lanAddress) { + throw new Error( + "CHROME_PATH, SCREENER_CLIENT_EXE, SCREENER_CLIENT_NODE, and SCREENER_CLIENT_GATE_LAN_ADDRESS are required", + ); + } + + const report: GateReport = { + passed: false, + platform: process.platform, + browser: null, + clientStarted: false, + localServerReady: false, + bootstrapAuthenticated: false, + bootstrapRemoved: false, + hostReady: false, + lanInvite: false, + clientExited: false, + browserExited: false, + portsClosed: false, + profileRemoved: false, + error: null, + }; + const appPort = await reservePort(); + const debugPort = await reservePort(); + const profile = await mkdtemp(join(tmpdir(), "screener-client-loopback-")); + const configPath = join(profile, "client.json"); + let client: ChildProcessWithoutNullStreams | null = null; + let browser: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let loopbackPort = 0; + try { + client = spawn(clientPath, [ + "--node", nodePath, + "--app", appDirectory, + "--config", configPath, + "--lan-address", lanAddress, + "--port", String(appPort), + ], { + stdio: "pipe", + windowsHide: true, + env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" }, + }); + client.stderr.resume(); + const endpoint = await readEndpoint(client); + loopbackPort = endpoint.port; + report.clientStarted = true; + await waitForSample( + (deadline) => fetchJsonBefore<{ status: string }>( + `http://127.0.0.1:${appPort}/healthz`, + deadline, + ), + (health) => health.status === "ok", + 15_000, + ); + report.localServerReady = true; + const config = JSON.parse(await readFile(configPath, "utf8")) as { + localAccessPassword?: unknown; + }; + if (typeof config.localAccessPassword !== "string") { + throw new Error("Client access bootstrap is unavailable"); + } + + browser = spawn(browserPath, [ + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + "--headless=new", + ...(process.env.SCREENER_CLIENT_GATE_NO_SANDBOX === "true" + ? ["--no-sandbox"] + : []), + "--no-first-run", + "--no-proxy-server", + "--disable-logging", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + browser.stdout.resume(); + browser.stderr.resume(); + const version = await waitForVersion(debugPort, browser); + report.browser = version.Browser; + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + const page = await createPage( + cdp, + `http://localhost:${appPort}/#client-access=${config.localAccessPassword}`, + ); + const readState = (deadline: number) => + evaluate(cdp!, page, LOCAL_PAGE_STATE, deadline).then((value) => { + report.bootstrapAuthenticated = value.access?.required === true && + value.access.authenticated; + report.bootstrapRemoved = !value.bootstrapPresent; + report.hostReady = value.hostReady; + return value; + }); + await waitForSample( + readState, + (value) => value.access?.authenticated === true && + value.hostReady, + 15_000, + ); + report.lanInvite = await evaluate( + cdp, + page, + `fetch('/api/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ codeEntryPolicy: 'open' }), + }).then(async (response) => { + if (!response.ok) return false; + const room = await response.json(); + return typeof room.inviteUrl === 'string' && + room.inviteUrl.startsWith(${JSON.stringify(`http://${lanAddress}:${appPort}/r/`)}); + })`, + Date.now() + 5_000, + ); + } catch (error) { + report.error = error instanceof Error ? error.message : String(error); + } finally { + const cleanup = await cleanupRun({ + cdp, + native: client, + chrome: browser, + server: null, + profile, + ports: [appPort, debugPort, ...(loopbackPort ? [loopbackPort] : [])], + }); + report.clientExited = cleanup.nativeExited; + report.browserExited = cleanup.browserExited; + report.portsClosed = cleanup.portsClosed; + report.profileRemoved = cleanup.profileRemoved; + } + report.passed = report.clientStarted && report.localServerReady && + report.bootstrapAuthenticated && report.bootstrapRemoved && report.hostReady && + report.lanInvite && report.clientExited && report.browserExited && + report.portsClosed && report.profileRemoved && report.error === null; + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.passed) process.exitCode = 1; +} + +await main(); diff --git a/scripts/client-loopback-gate.ts b/scripts/client-loopback-gate.ts new file mode 100644 index 0000000..c731eb2 --- /dev/null +++ b/scripts/client-loopback-gate.ts @@ -0,0 +1,303 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + waitForVersion, + withDeadline, +} from "./browser-gate-harness"; +import { + decodeClientEndpoint, + type ClientEndpoint as Endpoint, +} from "./client-gate-endpoint"; +import { NATIVE_CLIENT_PROTOCOL, NATIVE_CLIENT_SUBPROTOCOL } from "../src/client/native/wire"; + +const PAGE_URL = "https://share.bonfire.icu/"; +const NATIVE_PROTOCOL = NATIVE_CLIENT_PROTOCOL; +const NATIVE_SUBPROTOCOL = NATIVE_CLIENT_SUBPROTOCOL; + +interface GateReport { + passed: boolean; + platform: NodeJS.Platform; + browser: string | null; + hostedExpectation: "blocked" | "connected"; + clientStarted: boolean; + localPageConnected: boolean; + localPageError: string | null; + localPageEvents: string[]; + hostedPageConnected: boolean; + hostedPageError: string | null; + hostedPageEvents: string[]; + hostedPageBlockedByBrowser: boolean; + clientExited: boolean; + browserExited: boolean; + profileRemoved: boolean; +} + +async function readEndpoint( + client: ChildProcessWithoutNullStreams, +): Promise { + let buffered = ""; + return await withDeadline( + () => new Promise((resolveEndpoint, rejectEndpoint) => { + const onData = (chunk: Buffer) => { + buffered += chunk.toString(); + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + client.stdout.off("data", onData); + try { + resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline))); + } catch (error) { + rejectEndpoint(error instanceof Error ? error : new Error("Client endpoint is invalid")); + } + }; + client.stdout.on("data", onData); + client.once("error", rejectEndpoint); + client.once("exit", (code) => { + rejectEndpoint(new Error(`Client exited before endpoint (${code ?? "signal"})`)); + }); + }), + Date.now() + 8_000, + ); +} + +async function browserHandshake( + cdp: CdpConnection, + page: { sessionId: string }, + endpoint: Endpoint, +): Promise<{ health: boolean; responses: string[]; error: string | null; events: string[] }> { + const endpointJSON = JSON.stringify({ + host: "127.0.0.1", + expectedPort: endpoint.port, + portStart: 39721, + portEnd: 39730, + }); + return await evaluate( + cdp, + page, + `(() => new Promise((resolve) => { + const expected = ${endpointJSON}; + const result = { health: false, responses: [], error: null, events: [] }; + const finish = () => resolve(result); + const discover = async () => { + for (let port = expected.portStart; port <= expected.portEnd; port += 1) { + const probeController = new AbortController(); + const probeTimer = setTimeout(() => probeController.abort(), 500); + try { + const response = await fetch("http://" + expected.host + ":" + port + "/health", { + cache: "no-store", signal: probeController.signal, targetAddressSpace: "loopback", + }); + if (!response.ok) continue; + const health = await response.json(); + if (health.protocol === ${NATIVE_PROTOCOL} && health.service === "screener-client" && + health.port === port && typeof health.instanceToken === "string" && + health.nativeMedia?.video === false && + health.nativeMedia?.processAudio === false && + health.nativeMedia?.systemAudio === false && + health.nativeMedia?.hardwareH264 === false) { + return { port, instanceToken: health.instanceToken }; + } + } catch {} finally { + clearTimeout(probeTimer); + } + } + throw new Error("Client not found"); + }; + discover() + .then((endpoint) => { + result.health = endpoint.port === expected.expectedPort; + const socket = new WebSocket( + "ws://" + expected.host + ":" + endpoint.port + "/control", + ["${NATIVE_SUBPROTOCOL}." + endpoint.instanceToken], + ); + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + finish(); + }; + const timer = setTimeout(() => { result.events.push("timeout"); result.error = "control timeout"; socket.close(); settle(); }, 8000); + socket.onopen = () => { + const expectedProtocol = "${NATIVE_SUBPROTOCOL}." + endpoint.instanceToken; + result.events.push(socket.protocol === expectedProtocol ? "open" : "protocol-mismatch"); + if (socket.protocol !== expectedProtocol) { + result.error = "control protocol mismatch"; + socket.close(); + settle(); + return; + } + socket.send(JSON.stringify({ version: ${NATIVE_PROTOCOL}, id: "request_hello", type: "hello" })); + }; + socket.onclose = (event) => { result.events.push("close:" + event.code); if (!settled && !result.error) result.error = "control closed"; settle(); }; + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + result.events.push("message:" + message.type); + result.responses.push(message.type); + if (message.type === "ready") { + try { socket.send(JSON.stringify({ version: ${NATIVE_PROTOCOL}, id: "request_ping", type: "ping" })); } catch (error) { result.error = String(error?.message || error); settle(); } + } else if (message.type === "pong") { + socket.close(1000, "gate complete"); + settle(); + } + }; + socket.onerror = () => { result.events.push("error"); result.error = "control socket error"; socket.close(); settle(); }; + }) + .catch((error) => { result.error = String(error?.message || error); finish(); }); + }))()`, + Date.now() + 15_000, + ); +} + +async function main(): Promise { + if (process.platform !== "win32" && process.platform !== "darwin" && process.platform !== "linux") { + throw new Error("loopback gate requires a desktop platform"); + } + if (process.env.SCREENER_CLIENT_LOOPBACK_GATE !== "true") { + throw new Error("loopback gate was not explicitly enabled"); + } + const browserPath = process.env.CHROME_PATH?.trim(); + const clientPath = process.env.SCREENER_CLIENT_EXE?.trim(); + if (!browserPath || !clientPath) { + throw new Error("CHROME_PATH and SCREENER_CLIENT_EXE are required"); + } + const disposableNoSandbox = + process.env.SCREENER_CLIENT_GATE_NO_SANDBOX === "true"; + const grantLoopback = + process.env.SCREENER_CLIENT_GATE_GRANT_LOOPBACK === "true"; + + const report: GateReport = { + passed: false, + platform: process.platform, + browser: null, + hostedExpectation: grantLoopback ? "connected" : "blocked", + clientStarted: false, + localPageConnected: false, + localPageError: null, + localPageEvents: [], + hostedPageConnected: false, + hostedPageError: null, + hostedPageEvents: [], + hostedPageBlockedByBrowser: false, + clientExited: false, + browserExited: false, + profileRemoved: false, + }; + let profile: string | null = null; + let client: ChildProcessWithoutNullStreams | null = null; + let browser: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let debugPort = 0; + try { + profile = await mkdtemp(join(tmpdir(), "screener-client-loopback-")); + client = spawn(clientPath, [ + "--site", PAGE_URL, + "--config", join(profile, "client.json"), + ], { + stdio: "pipe", + windowsHide: true, + env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" }, + }); + client.stderr.resume(); + const endpoint = await readEndpoint(client); + report.clientStarted = true; + + const portServer = createServer(); + debugPort = await new Promise((resolvePort, rejectPort) => { + portServer.once("error", rejectPort); + portServer.listen(0, "127.0.0.1", () => { + const address = portServer.address(); + if (!address || typeof address === "string") { + rejectPort(new Error("debug port reservation failed")); + return; + } + portServer.close((error) => error ? rejectPort(error) : resolvePort(address.port)); + }); + }); + browser = spawn(browserPath, [ + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + "--headless=new", + ...(disposableNoSandbox ? ["--no-sandbox"] : []), + "--no-first-run", + "--no-proxy-server", + "--disable-logging", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + browser.stdout.resume(); + browser.stderr.resume(); + const version = await waitForVersion(debugPort, browser); + report.browser = version.Browser; + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + if (grantLoopback) { + await cdp.call( + "Browser.setPermission", + { + permission: { name: "loopback-network" }, + setting: "granted", + origin: PAGE_URL, + }, + undefined, + Date.now() + 5_000, + ); + } + + const localPage = await createPage(cdp, endpoint.url + "/health"); + const localResult = await browserHandshake(cdp, localPage, endpoint); + report.localPageConnected = localResult.health && + localResult.responses.join(",") === "ready,pong"; + report.localPageError = localResult.error; + report.localPageEvents = localResult.events; + + const hostedPage = await createPage(cdp, PAGE_URL); + const hostedResult = await browserHandshake(cdp, hostedPage, endpoint); + report.hostedPageConnected = hostedResult.health && + hostedResult.responses.join(",") === "ready,pong"; + report.hostedPageError = hostedResult.error; + report.hostedPageEvents = hostedResult.events; + report.hostedPageBlockedByBrowser = !report.hostedPageConnected && + hostedResult.error !== null; + } finally { + const cleanup = await cleanupRun({ + cdp, + native: client, + chrome: browser, + server: null, + profile, + ports: debugPort > 0 ? [debugPort] : [], + }); + report.browserExited = cleanup.browserExited; + report.clientExited = cleanup.nativeExited; + if (profile) { + report.profileRemoved = cleanup.profileRemoved; + if (!report.profileRemoved) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + const retry = await cleanupRun({ + cdp: null, + native: null, + chrome: null, + server: null, + profile, + ports: [], + }); + report.profileRemoved = retry.profileRemoved; + } + } + } + const hostedExpectationMet = grantLoopback + ? report.hostedPageConnected + : report.hostedPageBlockedByBrowser; + report.passed = report.clientStarted && report.localPageConnected && + hostedExpectationMet && + report.clientExited && report.browserExited && report.profileRemoved; + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.passed) process.exitCode = 1; +} + +await main(); diff --git a/scripts/client-media-gate.ts b/scripts/client-media-gate.ts new file mode 100644 index 0000000..085fac7 --- /dev/null +++ b/scripts/client-media-gate.ts @@ -0,0 +1,844 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { copyFile, mkdir, mkdtemp } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + reservePort, + waitForSample, + waitForVersion, + withDeadline, +} from "./browser-gate-harness"; +import { + decodeClientEndpoint, + type ClientEndpoint as Endpoint, +} from "./client-gate-endpoint"; + +const ROOT = resolve(import.meta.dirname, ".."); +const BUILD_ROOT = join(ROOT, "build", "client-check"); +const SOURCE_TITLE = "Screener Native Gate Source"; +const GATE_STUN_URL = + process.env.SCREENER_CLIENT_GATE_STUN_URL?.trim() || + "stun:share.bonfire.icu:3478"; + +interface Probe { + protocol: number; + processAudio: boolean; + systemAudio: boolean; + adapters: Array<{ + index: number; + hardwareH264: Array<{ index: number }>; + }>; +} + +interface WindowTarget { + kind: "window" | "display"; + sourceId: string; + pid?: number; + creationTime?: string; + title: string; +} + +interface CaptureEvidence { + starting: boolean; + active: boolean; + frames: number; + keyFrameRequested: boolean; + recoveryFrame: boolean; + audioReady: boolean; + audioFrames: number; + audioNonZeroSamples: number; +} + +interface MediaEvidence { + captureActive: boolean; + connected: boolean; + connectedEdges: number; + frames: number; + edgeFrames: number[]; + audioPackets: number; + edgeAudioPackets: number[]; + audioEnergy: number; + edgeAudioEnergy: number[]; + audioSamples: number; + edgeAudioSamples: number[]; + audioBytes: number; + edgeAudioBytes: number[]; + audioAvailable: boolean; + width: number; + height: number; + localType: string | null; + remoteType: string | null; + nativeCandidateTypes: string[]; + error: string | null; +} + +function run(command: string, args: string[], cwd = ROOT): string { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout).trim() || command + " failed"); + } + return result.stdout.trim(); +} + +function powershell(): string { + const configured = process.env.SCREENER_POWERSHELL?.trim(); + if (configured) return configured; + return join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function sourceHTML(): string { + return [ + "" + SOURCE_TITLE + "", + "", + "", + "", + ].join(""); +} + +function startPageServer(port: number) { + const server = createServer((_request, response) => { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(sourceHTML()); + }); + return new Promise<{ close(): Promise }>((resolveServer, rejectServer) => { + server.once("error", rejectServer); + server.listen(port, "127.0.0.1", () => { + resolveServer({ + close: () => new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + }), + }); + }); + }); +} + +function observeCapture( + child: ChildProcessWithoutNullStreams, + evidence: CaptureEvidence, +): void { + let buffered = Buffer.alloc(0); + let framesAtRequest = 0; + child.stdout.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 28) { + if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 1) { + child.kill(); + return; + } + const kind = buffered[5]!; + const flags = buffered[6]!; + const size = buffered.readUInt32BE(24); + const maximum = kind === 3 ? 4 * 1024 : 1024 * 1024; + if (size === 0 || size > maximum) { + child.kill(); + return; + } + if (buffered.length < 28 + size) return; + const payload = buffered.subarray(28, 28 + size); + buffered = buffered.subarray(28 + size); + if (kind === 3) { + const state = JSON.parse(payload.toString("utf8")) as { state?: string }; + evidence.starting ||= state.state === "starting"; + evidence.active ||= state.state === "active"; + } else if (kind === 2) { + evidence.frames += 1; + if (!evidence.keyFrameRequested && evidence.frames >= 5) { + evidence.keyFrameRequested = true; + framesAtRequest = evidence.frames; + child.stdin.write("K"); + } else if (evidence.keyFrameRequested && evidence.frames > framesAtRequest && flags === 1) { + evidence.recoveryFrame = true; + } + } + } + }); +} + +function observeAudioCapture( + child: ChildProcessWithoutNullStreams, + evidence: CaptureEvidence, +): void { + let buffered = Buffer.alloc(0); + child.stdout.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 28) { + if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 1) { + child.kill(); + return; + } + const kind = buffered[5]!; + const size = buffered.readUInt32BE(24); + const maximum = kind === 3 ? 4 * 1024 : 1024 * 1024; + if (size === 0 || size > maximum || buffered.length < 28 + size) { + if (size === 0 || size > maximum) child.kill(); + return; + } + const payload = buffered.subarray(28, 28 + size); + buffered = buffered.subarray(28 + size); + if (kind === 3) { + const state = JSON.parse(payload.toString("utf8")) as { + state?: string; + audio?: boolean; + }; + evidence.audioReady ||= state.state === "active" && state.audio === true; + } else if (kind === 1) { + evidence.audioFrames += 1; + for (let offset = 0; offset + 1 < payload.length; offset += 2) { + if (payload.readInt16LE(offset) !== 0) { + evidence.audioNonZeroSamples += 1; + } + } + } + } + }); +} + +async function stopCapture(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) { + if (child.exitCode !== 0) throw new Error("Native capture exited unsuccessfully"); + return; + } + child.stdin.write("\n"); + const code = await withDeadline( + () => new Promise((resolveExit) => child.once("exit", resolveExit)), + Date.now() + 3_000, + ); + if (code !== 0) throw new Error("Native capture did not stop cleanly"); +} + +async function readEndpoint(child: ChildProcessWithoutNullStreams): Promise { + let buffered = ""; + return withDeadline( + () => new Promise((resolveEndpoint, rejectEndpoint) => { + const onData = (chunk: Buffer) => { + buffered += chunk.toString(); + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + child.stdout.off("data", onData); + try { + resolveEndpoint(decodeClientEndpoint(buffered.slice(0, newline))); + } catch (error) { + rejectEndpoint(error); + } + }; + child.stdout.on("data", onData); + child.once("error", rejectEndpoint); + child.once("exit", (code) => { + rejectEndpoint(new Error("Client exited before readiness (" + String(code) + ")")); + }); + }), + Date.now() + 10_000, + ); +} + +async function browserMediaGate(input: { + endpoint: Endpoint; + sourceTitle: string; + sourceKind: "window" | "display"; + stunUrl: string; +}): Promise { + let socket: WebSocket | null = null; + const peers: RTCPeerConnection[] = []; + const result: MediaEvidence = { + captureActive: false, + connected: false, + connectedEdges: 0, + frames: 0, + edgeFrames: [], + audioPackets: 0, + edgeAudioPackets: [], + audioEnergy: 0, + edgeAudioEnergy: [], + audioSamples: 0, + edgeAudioSamples: [], + audioBytes: 0, + edgeAudioBytes: [], + audioAvailable: false, + width: 0, + height: 0, + localType: null, + remoteType: null, + nativeCandidateTypes: [], + error: null, + }; + try { + const healthResponse = await fetch(input.endpoint.url + "/health", { + cache: "no-store", + targetAddressSpace: "loopback", + } as RequestInit); + const health = await healthResponse.json(); + if ( + health.protocol !== 5 || + health.service !== "screener-client" || + health.instanceToken !== input.endpoint.instanceToken || + health.nativeMedia?.video !== true || + health.nativeMedia?.hardwareH264 !== true + ) { + throw new Error("Native Client health is not ready"); + } + result.audioAvailable = input.sourceKind === "window" + ? health.nativeMedia.processAudio === true + : health.nativeMedia.systemAudio === true; + + socket = new WebSocket( + "ws://127.0.0.1:" + input.endpoint.port + "/control", + ["screener-client-v5." + input.endpoint.instanceToken], + ); + await new Promise((resolveOpen, rejectOpen) => { + const timer = window.setTimeout( + () => rejectOpen(new Error("Native control open timed out")), + 5_000, + ); + socket!.onopen = () => { + window.clearTimeout(timer); + resolveOpen(); + }; + socket!.onerror = () => { + window.clearTimeout(timer); + rejectOpen(new Error("Native control failed to open")); + }; + }); + + let requestSequence = 0; + let eventError: Error | null = null; + const edges = new Map; + }>(); + const pending = new Map void; + reject: (error: Error) => void; + timer: number; + }>(); + const request = (type: string, fields: Record = {}) => { + requestSequence += 1; + const id = "request_" + String(requestSequence).padStart(8, "0"); + return new Promise((resolveRequest, rejectRequest) => { + const timer = window.setTimeout(() => { + pending.delete(id); + rejectRequest(new Error("Native request timed out: " + type)); + }, 8_000); + pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); + socket!.send(JSON.stringify({ version: 5, id, type, ...fields })); + }); + }; + socket.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (typeof message.id === "string") { + const active = pending.get(message.id); + if (active) { + pending.delete(message.id); + window.clearTimeout(active.timer); + active.resolve(message); + } + return; + } + if (message.type === "capture-state" && message.state === "active") { + result.captureActive = true; + } else if (message.type === "edge-state") { + const edge = edges.get(message.connectionId); + if (edge) edge.connected = message.state === "connected"; + } else if (message.type === "edge-path") { + result.localType = message.localType; + result.remoteType = message.remoteType; + } else if (message.type === "edge-candidate") { + const candidate = message.candidate as RTCIceCandidateInit | null; + const fields = typeof candidate?.candidate === "string" + ? candidate.candidate.split(/\s+/) + : []; + const typeIndex = fields.indexOf("typ"); + if (typeIndex >= 0 && fields[typeIndex + 1] && + !result.nativeCandidateTypes.includes(fields[typeIndex + 1]!)) { + result.nativeCandidateTypes.push(fields[typeIndex + 1]!); + } + const edge = edges.get(message.connectionId); + if (edge?.remoteDescriptionSet) { + void edge.peer.addIceCandidate(candidate).catch((error) => { + eventError = error instanceof Error ? error : new Error(String(error)); + }); + } else if (edge) { + edge.queuedCandidates.push(candidate); + } + } else if (message.type === "share-ended" && message.failed) { + eventError = new Error("Native share ended unexpectedly"); + } + }; + socket.onclose = () => { + for (const active of pending.values()) { + window.clearTimeout(active.timer); + active.reject(new Error("Native control closed")); + } + pending.clear(); + }; + + const ready = await request("hello"); + if (ready.type !== "ready") throw new Error("Native hello was rejected"); + const options = await request("capture-options"); + const adapter = options.adapters?.find( + (candidate: { hardwareH264?: unknown[] }) => candidate.hardwareH264?.length, + ); + const encoder = adapter?.hardwareH264?.[0]; + const sources = await request("list-sources"); + const target = sources.sources?.find( + (candidate: { kind?: string; title?: string }) => + candidate.kind === input.sourceKind && + (input.sourceKind === "display" || + candidate.title?.includes(input.sourceTitle)), + ); + if (!adapter || !encoder || !target) { + throw new Error("Native capture selection is unavailable"); + } + + const shareId = "share_gate_0001"; + const connectionId = "edge_gate_0001"; + const started = await request("start-share", { + shareId, + source: target, + audio: result.audioAvailable, + adapterIndex: adapter.index, + encoderIndex: encoder.index, + edgeCapacity: 2, + }); + if (started.shareId !== shareId || started.audio !== result.audioAvailable) { + throw new Error("Native share audio capability was not reported consistently"); + } + + const connectEdge = async (nextConnectionId: string) => { + const peer = new RTCPeerConnection(); + peers.push(peer); + if (result.audioAvailable) { + peer.addTransceiver("audio", { direction: "recvonly" }); + } + const video = document.createElement("video"); + video.autoplay = true; + video.volume = 0.001; + video.playsInline = true; + document.body.append(video); + const stream = new MediaStream(); + video.srcObject = stream; + const edge = { + peer, + video, + stream, + connected: false, + remoteDescriptionSet: false, + queuedCandidates: [] as Array, + }; + edges.set(nextConnectionId, edge); + peer.ontrack = (event) => { + if (!stream.getTrackById(event.track.id)) { + stream.addTrack(event.track); + } + void video.play(); + }; + peer.onicecandidate = (event) => { + void request("edge-candidate", { + shareId, + connectionId: nextConnectionId, + candidate: event.candidate?.toJSON() ?? null, + }).catch((error) => { + eventError = error instanceof Error ? error : new Error(String(error)); + }); + }; + const offer = await request("prepare-edge", { + shareId, + connectionId: nextConnectionId, + iceServers: [{ urls: [input.stunUrl] }], + }); + await peer.setRemoteDescription({ type: "offer", sdp: offer.sdp }); + edge.remoteDescriptionSet = true; + for (const candidate of edge.queuedCandidates.splice(0)) { + await peer.addIceCandidate(candidate); + } + const answer = await peer.createAnswer(); + await peer.setLocalDescription(answer); + await request("edge-answer", { + shareId, + connectionId: nextConnectionId, + sdp: answer.sdp, + }); + }; + await Promise.all([ + connectEdge(connectionId), + connectEdge("edge_gate_0002"), + ]); + + const deadline = performance.now() + 20_000; + while (performance.now() < deadline) { + if (eventError) throw eventError; + result.connectedEdges = [...edges.values()].filter((edge) => edge.connected).length; + result.connected = result.connectedEdges === 2; + result.edgeFrames = [...edges.values()].map( + (edge) => edge.video.getVideoPlaybackQuality().totalVideoFrames, + ); + result.frames = Math.min(...result.edgeFrames); + result.edgeAudioPackets = []; + result.edgeAudioEnergy = []; + result.edgeAudioSamples = []; + result.edgeAudioBytes = []; + for (const edge of edges.values()) { + let packets = 0; + let energy = 0; + let samples = 0; + let bytes = 0; + for (const [, report] of await edge.peer.getStats()) { + if (report.type === "inbound-rtp" && + (report.kind === "audio" || report.mediaType === "audio")) { + packets = Math.max(packets, Number(report.packetsReceived) || 0); + energy = Math.max(energy, Number(report.totalAudioEnergy) || 0); + samples = Math.max(samples, Number(report.totalSamplesReceived) || 0); + bytes = Math.max(bytes, Number(report.bytesReceived) || 0); + } + } + result.edgeAudioPackets.push(packets); + result.edgeAudioEnergy.push(energy); + result.edgeAudioSamples.push(samples); + result.edgeAudioBytes.push(bytes); + } + result.audioPackets = result.edgeAudioPackets.length > 0 + ? Math.min(...result.edgeAudioPackets) + : 0; + result.audioEnergy = result.edgeAudioEnergy.length > 0 + ? Math.min(...result.edgeAudioEnergy) + : 0; + result.audioSamples = result.edgeAudioSamples.length > 0 + ? Math.min(...result.edgeAudioSamples) + : 0; + result.audioBytes = result.edgeAudioBytes.length > 0 + ? Math.min(...result.edgeAudioBytes) + : 0; + const videos = [...edges.values()].map((edge) => edge.video); + result.width = videos.every((video) => video.videoWidth === 1280) ? 1280 : 0; + result.height = videos.every((video) => video.videoHeight === 720) ? 720 : 0; + if ( + result.captureActive && + result.connected && + result.frames >= 30 && + (!result.audioAvailable || + (result.audioPackets > 10 && result.audioEnergy > 0)) && + result.width === 1280 && + result.height === 720 && + result.nativeCandidateTypes.includes("srflx") + ) { + break; + } + await new Promise((resolveWait) => window.setTimeout(resolveWait, 100)); + } + if ( + !result.captureActive || + !result.connected || + result.frames < 30 || + (result.audioAvailable && + (result.audioPackets <= 10 || result.audioEnergy <= 0)) || + result.width !== 1280 || + result.height !== 720 || + !result.nativeCandidateTypes.includes("srflx") + ) { + throw new Error("Native H264 did not reach decoded Browser video"); + } + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } finally { + for (const peer of peers) peer.close(); + socket?.close(1000, "gate complete"); + } + return result; +} + +async function main(): Promise { + if (process.platform !== "win32") { + throw new Error("The native capture gate currently requires Windows"); + } + if (process.env.SCREENER_CLIENT_MEDIA_GATE !== "true") { + throw new Error("SCREENER_CLIENT_MEDIA_GATE=true is required"); + } + const chromePath = process.env.CHROME_PATH?.trim(); + if (!chromePath) throw new Error("CHROME_PATH is required"); + + const profile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + // Keep network-capable binaries at a stable repository path. Windows + // associates its firewall decision with the full executable path; the + // disposable Browser profile remains in the system temp directory. + const buildRoot = BUILD_ROOT; + await mkdir(buildRoot, { recursive: true }); + const pagePort = await reservePort(); + const debugPort = await reservePort(); + let server: Awaited> | null = null; + let chrome: ChildProcessWithoutNullStreams | null = null; + let capture: ChildProcessWithoutNullStreams | null = null; + let audioCapture: ChildProcessWithoutNullStreams | null = null; + let client: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let clientPort = 0; + let browser: string | null = null; + const evidence: CaptureEvidence = { + starting: false, + active: false, + frames: 0, + keyFrameRequested: false, + recoveryFrame: false, + audioReady: false, + audioFrames: 0, + audioNonZeroSamples: 0, + }; + let media: MediaEvidence = { + captureActive: false, + connected: false, + connectedEdges: 0, + frames: 0, + edgeFrames: [], + width: 0, + height: 0, + localType: null, + remoteType: null, + nativeCandidateTypes: [], + error: "not run", + audioPackets: 0, + edgeAudioPackets: [], + audioEnergy: 0, + edgeAudioEnergy: [], + audioSamples: 0, + edgeAudioSamples: [], + audioBytes: 0, + edgeAudioBytes: [], + audioAvailable: false, + }; + let error: string | null = null; + let cleanup = { + browserExited: false, + nativeExited: false, + serverClosed: false, + portsClosed: false, + profileRemoved: false, + }; + try { + run(powershell(), [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"), + "-OutputDirectory", + buildRoot, + ]); + const executable = join(buildRoot, "screener-client-capture.exe"); + const probe = JSON.parse(run(executable, ["--probe"])) as Probe; + const adapter = probe.adapters.find((candidate) => candidate.hardwareH264.length > 0); + const encoder = adapter?.hardwareH264[0]; + if (probe.protocol !== 3 || !adapter || !encoder) { + throw new Error("No hardware H264 capture path is available"); + } + + server = await startPageServer(pagePort); + chrome = spawn(chromePath, [ + "--remote-debugging-port=" + debugPort, + "--user-data-dir=" + profile, + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-logging", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--window-size=1280,900", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + chrome.stdout.resume(); + chrome.stderr.resume(); + const version = await waitForVersion(debugPort, chrome); + browser = version.Browser; + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + const sourcePage = await createPage( + cdp, + "http://127.0.0.1:" + pagePort + "/source", + undefined, + true, + ); + const audioState = await evaluate( + cdp, + sourcePage, + `(() => { + const context = new AudioContext(); + const gain = context.createGain(); + gain.gain.value = 0.05; + const oscillator = context.createOscillator(); + oscillator.frequency.value = 440; + oscillator.connect(gain).connect(context.destination); + oscillator.start(); + globalThis.__screenerAudioGate = { context, oscillator }; + return context.resume().then(() => context.state); + })()`, + Date.now() + 5_000, + ); + if (audioState !== "running") { + throw new Error("Browser could not start the selected-window audio source"); + } + const targets = JSON.parse(run(executable, ["--list"])) as WindowTarget[]; + const sourceKind: "window" | "display" = + process.env.SCREENER_CLIENT_MEDIA_SOURCE === "display" + ? "display" + : "window"; + const target = targets.find( + (candidate) => candidate.kind === sourceKind && + (sourceKind === "display" || candidate.title.includes(SOURCE_TITLE)), + ); + if (!target) throw new Error("The animated capture target was not enumerated"); + + capture = spawn(executable, [ + "--capture-video", + target.kind, + target.sourceId, + String(target.pid ?? 0), + target.creationTime ?? "0", + "--adapter-index", + String(adapter.index), + "--mft-index", + String(encoder.index), + "--protocol-v3", + ], { stdio: "pipe", windowsHide: true }); + capture.stderr.resume(); + observeCapture(capture, evidence); + await waitForSample( + async () => ({ ...evidence }), + (sample) => + sample.starting && sample.active && sample.frames >= 30 && + sample.keyFrameRequested && sample.recoveryFrame, + 15_000, + ); + await stopCapture(capture); + capture = null; + + const audioAvailable = sourceKind === "window" + ? probe.processAudio + : probe.systemAudio; + if (audioAvailable) { + audioCapture = spawn(executable, [ + "--capture-audio", + target.kind, + String(target.pid ?? 0), + target.creationTime ?? "0", + ], { stdio: "pipe", windowsHide: true }); + audioCapture.stderr.resume(); + observeAudioCapture(audioCapture, evidence); + await waitForSample( + async () => ({ ...evidence }), + (sample) => + sample.audioReady && sample.audioFrames >= 10 && + sample.audioNonZeroSamples > 0, + 10_000, + ); + await stopCapture(audioCapture); + audioCapture = null; + } + + const packageRoot = join(buildRoot, "media-package"); + const nativeRoot = join(packageRoot, "runtime", "native"); + await mkdir(nativeRoot, { recursive: true }); + await copyFile( + executable, + join(nativeRoot, "screener-client-capture.exe"), + ); + const clientExecutable = join(packageRoot, "screener-client.exe"); + const go = process.env.SCREENER_GO?.trim() || "go"; + run( + go, + ["build", "-trimpath", "-o", clientExecutable, "./cmd/screener-client"], + join(ROOT, "native", "client"), + ); + client = spawn(clientExecutable, [ + "--site", + "http://127.0.0.1:" + pagePort, + "--config", + join(profile, "client.json"), + ], { + stdio: "pipe", + windowsHide: true, + env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" }, + }); + client.stderr.resume(); + const endpoint = await readEndpoint(client); + clientPort = endpoint.port; + const controlPage = await createPage( + cdp, + "http://127.0.0.1:" + pagePort + "/control", + undefined, + true, + ); + media = await evaluate( + cdp, + controlPage, + "((__name) => (" + browserMediaGate.toString() + ")(" + JSON.stringify({ + endpoint, + sourceTitle: SOURCE_TITLE, + sourceKind, + stunUrl: GATE_STUN_URL, + }) + "))((target) => target)", + Date.now() + 35_000, + ); + if (media.error) throw new Error(media.error); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } finally { + cleanup = await cleanupRun({ + cdp, + native: client ?? capture ?? audioCapture, + chrome, + server, + profile, + ports: [pagePort, debugPort, ...(clientPort ? [clientPort] : [])], + }); + } + const passed = error === null && evidence.starting && evidence.active && + evidence.frames >= 30 && evidence.recoveryFrame && media.error === null && + (evidence.audioFrames === 0 || evidence.audioNonZeroSamples > 0) && + media.captureActive && media.connected && media.frames >= 30 && + media.connectedEdges === 2 && media.edgeFrames.length === 2 && + (!media.audioAvailable || + (media.audioPackets > 10 && media.audioEnergy > 0)) && + media.width === 1280 && media.height === 720 && + media.nativeCandidateTypes.includes("srflx") && + cleanup.browserExited && cleanup.nativeExited && cleanup.serverClosed && + cleanup.portsClosed && cleanup.profileRemoved; + process.stdout.write(JSON.stringify({ passed, browser, capture: evidence, media, cleanup, error }) + "\n"); + if (!passed) process.exitCode = 1; +} + +await main(); diff --git a/scripts/client-native-host-gate.ts b/scripts/client-native-host-gate.ts new file mode 100644 index 0000000..d4cff0c --- /dev/null +++ b/scripts/client-native-host-gate.ts @@ -0,0 +1,938 @@ +import { + spawn, + spawnSync, + type ChildProcess, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + reservePort, + type PageHandle, + waitForVersion, + withDeadline, +} from "./browser-gate-harness"; +import { + decodeClientEndpoint, + type ClientEndpoint as Endpoint, +} from "./client-gate-endpoint"; + +const ROOT = resolve(import.meta.dirname, ".."); +const BUILD_ROOT = join(ROOT, "build", "client-check"); +export const SOURCE_TITLE = "Screener Native Gate Source"; + +type GateMode = "local" | "cross-nat" | "one-link"; + +interface GateResult { + passed: boolean; + hostNativeActive: boolean; + hostInvite: boolean; + nativeQualityEvidence: boolean; + viewerConnected: boolean; + viewerFrames: number; + viewerWidth: number; + viewerHeight: number; + remoteViewerConnected: boolean; + remoteViewerPackets: number; + remoteLocalCandidateType: string; + remoteRemoteCandidateType: string; + remoteNatPath: boolean; + remotePeerExited: boolean | null; + reverseSignalTunnelClosed: boolean | null; + sourceFailureEndedShare: boolean | null; + replacementViewerConnected: boolean | null; + replacementViewerFrames: number | null; + mode: GateMode; + cleanup: Awaited>; + error: string | null; + stage?: string; +} + +interface RemoteGateOptions { + host: string; + user: string; + key: string; + signalPort: number; + bindAddress: string | null; +} + +interface RemoteGateResult { + passed: boolean; + packets: number; + localCandidateType: string; + remoteCandidateType: string; + error: string | null; +} + +function run( + command: string, + args: string[], + cwd = ROOT, + environment: NodeJS.ProcessEnv = process.env, +): string { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: environment, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout).trim() || command + " failed"); + } + return result.stdout.trim(); +} + +async function waitForChild( + child: ChildProcessWithoutNullStreams | ChildProcess, + timeoutMs: number, +): Promise { + if (child.exitCode !== null) return child.exitCode; + return withDeadline( + () => new Promise((resolveExit, rejectExit) => { + child.once("error", rejectExit); + child.once("exit", resolveExit); + }), + Date.now() + timeoutMs, + ); +} + +async function stopChild(child: ChildProcessWithoutNullStreams | ChildProcess | null): Promise { + if (!child || child.exitCode !== null || child.signalCode !== null) return true; + child.kill(); + try { + await waitForChild(child, 5_000); + } catch { + if (process.platform === "win32" && child.pid !== undefined) { + spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + try { + await waitForChild(child, 2_000); + } catch { + // The process state below is the final cleanup check. + } + } + } + return child.exitCode !== null || child.signalCode !== null; +} + +async function selectNativeSource( + cdp: CdpConnection, + page: PageHandle, + kind: "window" | "display", + title?: string, +): Promise { + const selector = kind === "display" + ? "button[data-native-source^='display:']" + : "button[data-native-source]"; + const predicate = title + ? `(button) => button.textContent?.includes(${JSON.stringify(title)})` + : "() => true"; + await waitForValue( + (deadline) => evaluate( + cdp, + page, + `([...document.querySelectorAll(${JSON.stringify(selector)})].some(${predicate}))`, + deadline, + ), + Boolean, + 15_000, + ); + await evaluate( + cdp, + page, + `([...document.querySelectorAll(${JSON.stringify(selector)})].find(${predicate}))?.click()`, + Date.now() + 5_000, + ); +} + +function remoteOptions(): RemoteGateOptions { + const host = process.env.SCREENER_REMOTE_HOST?.trim(); + const user = process.env.SCREENER_REMOTE_USER?.trim() || "root"; + const key = process.env.SCREENER_REMOTE_SSH_KEY?.trim(); + const signalPortText = process.env.SCREENER_REMOTE_SIGNAL_PORT?.trim() || "49721"; + const signalPort = Number(signalPortText); + if (!host || !key || !Number.isInteger(signalPort) || signalPort < 1024 || signalPort > 65_535) { + throw new Error( + "SCREENER_REMOTE_HOST, SCREENER_REMOTE_SSH_KEY, and a valid SCREENER_REMOTE_SIGNAL_PORT are required", + ); + } + return { + host, + user, + key, + signalPort, + bindAddress: process.env.SCREENER_REMOTE_BIND_ADDRESS?.trim() || null, + }; +} + +function remoteTransportOptions(options: RemoteGateOptions): string[] { + return [ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=10", + ...(options.bindAddress + ? ["-o", `BindAddress=${options.bindAddress}`] + : []), + "-i", options.key, + ]; +} + +async function runRemotePeerGate( + binary: string, + options: RemoteGateOptions, + signalUrl: string, + roomId: string, + viewerGrant: string, + origin: string, +): Promise { + const ssh = process.env.SCREENER_SSH?.trim() || "ssh"; + const scp = process.env.SCREENER_SCP?.trim() || "scp"; + const destination = `${options.user}@${options.host}`; + const remotePath = `/tmp/screener-peer-gate-${randomBytes(8).toString("hex")}`; + const transportOptions = remoteTransportOptions(options); + try { + run(scp, [...transportOptions, binary, `${destination}:${remotePath}`]); + run(ssh, [...transportOptions, destination, "chmod", "700", remotePath]); + const remote = spawn( + ssh, + [ + ...transportOptions, + "-T", + destination, + remotePath, + "--config", + "-", + ], + { stdio: "pipe", windowsHide: true }, + ); + let stdout = ""; + remote.stdout.on("data", (chunk: Buffer) => { + stdout = (stdout + chunk.toString()).slice(-16_384); + }); + remote.stderr.resume(); + remote.stdin.end(JSON.stringify({ + signalUrl, + origin, + roomId, + viewerGrant, + timeoutSeconds: 45, + minPackets: 30, + })); + let exitCode: number | null; + try { + exitCode = await waitForChild(remote, 60_000); + } catch { + await stopChild(remote); + return { + passed: false, + packets: 0, + localCandidateType: "", + remoteCandidateType: "", + error: "remote peer gate timed out", + }; + } + const line = stdout + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + let parsed: Partial = {}; + if (line) { + try { + parsed = JSON.parse(line) as Partial; + } catch { + parsed = {}; + } + } + return { + passed: parsed.passed === true && exitCode === 0, + packets: typeof parsed.packets === "number" ? parsed.packets : 0, + localCandidateType: typeof parsed.localCandidateType === "string" + ? parsed.localCandidateType + : "", + remoteCandidateType: typeof parsed.remoteCandidateType === "string" + ? parsed.remoteCandidateType + : "", + error: typeof parsed.error === "string" + ? parsed.error + : exitCode === 0 ? null : "remote peer gate failed", + }; + } finally { + try { + run(ssh, [...transportOptions, destination, "rm", "-f", remotePath]); + } catch { + // The temporary binary is harmless if an already-closed SSH session + // prevents cleanup; the gate never places it in the repository. + } + } +} + +export function powershell(): string { + return process.env.SCREENER_POWERSHELL?.trim() || join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function sourceHTML(): string { + return [ + "" + SOURCE_TITLE + "", + "", + "", + ].join(""); +} + +export async function sourceServer(port: number): Promise<{ close(): Promise }> { + const server = createServer((_request, response) => { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(sourceHTML()); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(port, "127.0.0.1", resolveListen); + }); + return { + close: () => new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + }), + }; +} + +export async function startSourceBrowser( + chromePath: string, + profile: string, + debugPort: number, + sourcePort: number, +): Promise<{ + child: ChildProcessWithoutNullStreams; + cdp: CdpConnection; +}> { + const child = spawn(chromePath, [ + "--remote-debugging-port=" + debugPort, + "--user-data-dir=" + profile, + "--no-first-run", "--no-default-browser-check", + "--disable-extensions", "--disable-logging", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--app=http://127.0.0.1:" + sourcePort + "/", + ], { stdio: "pipe", windowsHide: true }); + child.stdout.resume(); + child.stderr.resume(); + const version = await waitForVersion(debugPort, child); + const cdp = await CdpConnection.connect( + version.webSocketDebuggerUrl, + Date.now() + 10_000, + ); + return { child, cdp }; +} + +export async function closeSourceBrowser( + child: ChildProcessWithoutNullStreams, + cdp: CdpConnection, +): Promise { + try { + await cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); + } catch {} + cdp.close(); + try { + await waitForChild(child, 5_000); + return true; + } catch { + return stopChild(child); + } +} + +export async function waitForCaptureWindow(captureBinary: string): Promise { + await waitForValue( + async () => JSON.parse(run(captureBinary, ["--list"])) as Array<{ + title?: unknown; + }>, + (targets) => targets.some((target) => + typeof target.title === "string" && target.title.includes(SOURCE_TITLE) + ), + 10_000, + ); +} + +async function waitForValue( + sample: (deadline: number) => Promise, + accept: (value: T) => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const value = await withDeadline(() => sample(deadline), deadline); + if (accept(value)) return value; + } catch { + // A transient page or signaling state is retried within the gate bound. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("Native Host gate timed out"); +} + +async function readClientEndpoint( + child: ChildProcessWithoutNullStreams, + requirePublicOrigin = false, +): Promise<{ + endpoint: Endpoint; + password: string; + publicOrigin: string | null; +}> { + let buffered = ""; + const lines: string[] = []; + let endpoint: Endpoint | null = null; + return withDeadline( + () => new Promise((resolveEndpoint, rejectEndpoint) => { + const onData = (chunk: Buffer) => { + buffered += chunk.toString(); + for (;;) { + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + if (!line) continue; + lines.push(line); + try { + endpoint = decodeClientEndpoint(line); + } catch { + // Informational lines are printed after the endpoint. + } + const passwordLine = lines.find((entry) => entry.startsWith("Local access password: ")); + const publicOriginLine = lines.find((entry) => + entry.startsWith("Public invitation origin: ") + ); + if (endpoint && passwordLine && (!requirePublicOrigin || publicOriginLine)) { + child.stdout.off("data", onData); + resolveEndpoint({ + endpoint, + password: passwordLine.slice("Local access password: ".length), + publicOrigin: + publicOriginLine?.slice("Public invitation origin: ".length) ?? null, + }); + return; + } + } + }; + child.stdout.on("data", onData); + child.once("error", rejectEndpoint); + child.once("exit", (code) => + rejectEndpoint(new Error("Client exited before readiness (" + String(code) + ")")), + ); + }), + Date.now() + 15_000, + ); +} + +async function main(): Promise { + if (process.platform !== "win32") { + throw new Error("The native Host gate currently requires Windows"); + } + if (process.env.SCREENER_CLIENT_NATIVE_HOST_GATE !== "true") { + throw new Error("SCREENER_CLIENT_NATIVE_HOST_GATE=true is required"); + } + const crossNat = process.env.SCREENER_CLIENT_CROSS_NAT_GATE === "true"; + const linkMedia = process.env.SCREENER_CLIENT_LINK_MEDIA_GATE === "true"; + if (crossNat && linkMedia) { + throw new Error("Cross-NAT and one-link gate modes are mutually exclusive"); + } + const mode: GateMode = linkMedia ? "one-link" : crossNat ? "cross-nat" : "local"; + const sourceKind: "window" | "display" = + process.env.SCREENER_CLIENT_NATIVE_HOST_SOURCE === "display" + ? "display" + : "window"; + const remote = mode === "local" ? null : remoteOptions(); + const chromePath = process.env.CHROME_PATH?.trim(); + if (!chromePath) throw new Error("CHROME_PATH is required"); + const go = process.env.SCREENER_GO?.trim() || "go"; + const node = process.env.SCREENER_NODE?.trim() || process.execPath; + const tunnel = process.env.SCREENER_CLOUDFLARED?.trim() || join( + BUILD_ROOT, + "cloudflared.exe", + ); + const profile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + const sourceProfile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + await mkdir(BUILD_ROOT, { recursive: true }); + const sourcePort = await reservePort(); + const appPort = await reservePort(); + const debugPort = await reservePort(); + const sourceDebugPort = await reservePort(); + const clientConfig = join(profile, "client.json"); + const captureBuild = BUILD_ROOT; + const clientBinary = join(BUILD_ROOT, "screener-client.exe"); + const captureBinary = join(captureBuild, "screener-client-capture.exe"); + const remoteBinary = join(BUILD_ROOT, "screener-peer-gate-linux"); + let source: { close(): Promise } | null = null; + let client: ChildProcessWithoutNullStreams | null = null; + let sourceChrome: ChildProcessWithoutNullStreams | null = null; + let sourceCdp: CdpConnection | null = null; + let chrome: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let clientPort = 0; + let clientDiagnostics = ""; + let remoteTunnel: ChildProcess | null = null; + let stage = "setup"; + const result: GateResult = { + passed: false, + hostNativeActive: false, + hostInvite: false, + nativeQualityEvidence: false, + viewerConnected: false, + viewerFrames: 0, + viewerWidth: 0, + viewerHeight: 0, + remoteViewerConnected: false, + remoteViewerPackets: 0, + remoteLocalCandidateType: "", + remoteRemoteCandidateType: "", + remoteNatPath: false, + remotePeerExited: null, + reverseSignalTunnelClosed: mode === "cross-nat" ? false : null, + sourceFailureEndedShare: mode === "local" ? false : null, + replacementViewerConnected: mode === "local" ? false : null, + replacementViewerFrames: mode === "local" ? 0 : null, + mode, + cleanup: { + browserExited: false, + nativeExited: false, + serverClosed: false, + portsClosed: false, + profileRemoved: false, + }, + error: null, + }; + try { + stage = "application-build"; + run(process.env.ComSpec || "cmd.exe", [ + "/d", "/s", "/c", "npm run build", + ]); + stage = "source-server"; + source = await sourceServer(sourcePort); + stage = "capture-build"; + run(powershell(), [ + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"), + "-OutputDirectory", captureBuild, + ]); + stage = "client-build"; + run(go, [ + "build", "-trimpath", "-o", clientBinary, "./cmd/screener-client", + ], join(ROOT, "native", "client")); + if (remote) { + stage = "remote-peer-build"; + run( + go, + ["build", "-trimpath", "-o", remoteBinary, "./cmd/screener-peer-gate"], + join(ROOT, "native", "client"), + { ...process.env, GOOS: "linux", GOARCH: "amd64", CGO_ENABLED: "0" }, + ); + } + stage = "source-browser"; + ({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser( + chromePath, + sourceProfile, + sourceDebugPort, + sourcePort, + )); + await waitForCaptureWindow(captureBinary); + stage = "client-start"; + client = spawn(clientBinary, [ + mode === "one-link" ? "--link" : "--local", + ...(mode === "one-link" + ? ["--tunnel-process", tunnel] + : []), + "--capture-process", captureBinary, + "--node", node, + "--app", ROOT, + "--config", clientConfig, + "--port", String(appPort), + ], { + stdio: "pipe", + windowsHide: true, + env: { + ...process.env, + NODE_DEBUG: "screener-route", + PEER_ASSISTED_MEDIA: "true", + SCREENER_CLIENT_GATE_NO_BROWSER: "true", + ...(mode === "cross-nat" + ? { + STUN_URLS: + process.env.SCREENER_CLIENT_GATE_STUN_URLS?.trim() || + "stun:share.bonfire.icu:3478", + } + : {}), + }, + }); + client.stderr.on("data", (chunk: Buffer) => { + clientDiagnostics = (clientDiagnostics + chunk.toString()).slice(-262_144); + }); + stage = "client-ready"; + const clientInfo = await readClientEndpoint(client, mode === "one-link"); + clientPort = clientInfo.endpoint.port; + if (mode === "cross-nat" && remote) { + stage = "signaling-tunnel"; + const tunnel = spawn( + process.env.SCREENER_SSH?.trim() || "ssh", + [ + "-N", "-T", + ...remoteTransportOptions(remote), + "-o", "ExitOnForwardFailure=yes", + "-R", `127.0.0.1:${remote.signalPort}:127.0.0.1:${appPort}`, + `${remote.user}@${remote.host}`, + ], + { stdio: "ignore", windowsHide: true }, + ); + remoteTunnel = tunnel; + tunnel.on("error", () => undefined); + await new Promise((resolveTunnel) => setTimeout(resolveTunnel, 300)); + if (tunnel.exitCode !== null) { + throw new Error("signaling tunnel could not start"); + } + } + stage = "local-server-ready"; + await waitForValue( + async () => { + const response = await fetch("http://127.0.0.1:" + appPort + "/healthz"); + return response.ok; + }, + Boolean, + 15_000, + ); + stage = "host-browser"; + chrome = spawn(chromePath, [ + "--remote-debugging-port=" + debugPort, + "--user-data-dir=" + profile, + "--no-first-run", "--no-default-browser-check", + "--disable-extensions", "--disable-logging", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--window-size=1280,900", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + chrome.stdout.resume(); + chrome.stderr.resume(); + stage = "host-cdp"; + const version = await waitForVersion(debugPort, chrome); + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + stage = "host-page"; + const host = await createPage( + cdp, + "http://localhost:" + appPort + "/#client-access=" + clientInfo.password + + "&screener-client=1", + undefined, + true, + ); + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + "Boolean(document.querySelector('button.lr-tv-big.is-action'))", + deadline, + ), + Boolean, + 15_000, + ); + stage = "host-click"; + await evaluate( + cdp, + host, + "document.querySelector('button.lr-tv-big.is-action')?.click()", + Date.now() + 5_000, + ); + await selectNativeSource( + cdp, + host, + sourceKind, + sourceKind === "window" ? SOURCE_TITLE : undefined, + ); + stage = "host-native-share"; + let hostState: { invite: string | null; native: boolean }; + try { + hostState = await waitForValue( + (deadline) => evaluate<{ + invite: string | null; + native: boolean; + }>( + cdp!, + host, + "(() => ({invite: document.querySelector('.lr-invite-url')?.value || null, " + + "native: Boolean(document.querySelector('.lr-tv-overlay [aria-label]')), " + + "body: document.body.innerText.slice(0, 500)}))()", + deadline, + ), + (state) => state.invite !== null && state.native, + 40_000, + ); + } catch (error) { + const diagnostic = await evaluate( + cdp!, + host, + "({buttons: [...document.querySelectorAll('button')].map((button) => ({label: button.getAttribute('aria-label'), text: button.textContent?.slice(0, 120), disabled: button.disabled})), inputs: [...document.querySelectorAll('input')].map((input) => ({className: input.className, type: input.type, disabled: input.disabled}))})", + Date.now() + 2_000, + ).catch(() => null); + throw new Error("native host stage failed: " + + (error instanceof Error ? error.message : String(error)) + + " diagnostic=" + JSON.stringify(diagnostic)); + } + result.hostNativeActive = hostState.native; + result.hostInvite = hostState.invite !== null; + if (!hostState.invite) throw new Error("Host did not publish an invitation"); + const inviteURL = new URL(hostState.invite); + const roomMatch = /^\/r\/([1-9][0-9]{3})$/.exec(inviteURL.pathname); + const viewerGrant = new URLSearchParams(inviteURL.hash.slice(1)).get("v"); + if (!roomMatch || !viewerGrant) { + throw new Error("Host invitation is not a room grant"); + } + if (remote) { + stage = "remote-viewer"; + const signalUrl = mode === "one-link" + ? new URL("/signal", clientInfo.publicOrigin!).toString().replace(/^http/, "ws") + : `ws://127.0.0.1:${remote.signalPort}/signal`; + const signalOrigin = mode === "one-link" + ? clientInfo.publicOrigin! + : `http://localhost:${appPort}`; + const remoteResult = await runRemotePeerGate( + remoteBinary, + remote, + signalUrl, + roomMatch[1], + viewerGrant, + signalOrigin, + ); + result.remoteViewerConnected = remoteResult.passed; + result.remoteViewerPackets = remoteResult.packets; + result.remoteLocalCandidateType = remoteResult.localCandidateType; + result.remoteRemoteCandidateType = remoteResult.remoteCandidateType; + result.remoteNatPath = [ + remoteResult.localCandidateType, + remoteResult.remoteCandidateType, + ].some((value) => value === "srflx" || value === "prflx"); + result.remotePeerExited = true; + if (!remoteResult.passed || !result.remoteNatPath) { + throw new Error(remoteResult.error || "remote peer did not use a reflexive ICE path"); + } + } else { + const viewerURL = new URL(hostState.invite); + viewerURL.hostname = "localhost"; + stage = "viewer-page"; + const viewer = await createPage(cdp, viewerURL.toString(), undefined, true); + stage = "viewer-media"; + const viewerState = await waitForValue( + (deadline) => evaluate<{ + frames: number; + width: number; + height: number; + connected: boolean; + }>( + cdp!, + viewer, + "(() => { const video = document.querySelector('video'); " + + "if (!video) return {frames:0,width:0,height:0,connected:false}; " + + "const q=video.getVideoPlaybackQuality(); return {frames:q.totalVideoFrames," + + "width:video.videoWidth,height:video.videoHeight,connected:video.videoWidth>0&&video.videoHeight>0}; })()", + deadline, + ), + (state) => state.connected && state.frames >= 30 && + state.width === 1280 && state.height === 720, + 40_000, + ); + result.viewerConnected = viewerState.connected; + result.viewerFrames = viewerState.frames; + result.viewerWidth = viewerState.width; + result.viewerHeight = viewerState.height; + stage = "native-quality-evidence"; + result.nativeQualityEvidence = await waitForValue( + async () => /"event":"sender-quality-evidence"[^\n]*"state":"(healthy|degraded)"/ + .test(clientDiagnostics), + Boolean, + 12_000, + ); + + await evaluate( + cdp, + viewer, + `(() => { + const video = document.querySelector('video'); + if (!video) return false; + window.__screenerGateMedia = video.srcObject; + return window.__screenerGateMedia !== null; + })()`, + Date.now() + 5_000, + ); + if (sourceKind === "window") { + stage = "source-failure"; + if (!sourceChrome || !sourceCdp || + !(await closeSourceBrowser(sourceChrome, sourceCdp))) { + throw new Error("captured source did not close"); + } + sourceChrome = null; + sourceCdp = null; + stage = "host-source-failure"; + result.sourceFailureEndedShare = await waitForValue( + (deadline) => evaluate( + cdp!, + host, + "Boolean(document.querySelector('button.lr-tv-big.is-action'))", + deadline, + ), + Boolean, + 20_000, + ); + stage = "source-restart"; + ({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser( + chromePath, + sourceProfile, + sourceDebugPort, + sourcePort, + )); + await waitForCaptureWindow(captureBinary); + stage = "host-restart"; + await evaluate( + cdp, + host, + "document.querySelector('button.lr-tv-big.is-action')?.click()", + Date.now() + 5_000, + ); + await selectNativeSource( + cdp, + host, + sourceKind, + sourceKind === "window" ? SOURCE_TITLE : undefined, + ); + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + "Boolean(document.querySelector('.lr-tv-overlay[role=\"status\"]'))", + deadline, + ), + Boolean, + 40_000, + ); + stage = "viewer-replacement-media"; + result.replacementViewerConnected = await waitForValue( + (deadline) => evaluate( + cdp!, + viewer, + `(() => { + const video = document.querySelector('video'); + return Boolean(video && video.srcObject && + video.srcObject !== window.__screenerGateMedia && + video.videoWidth === 1280 && video.videoHeight === 720); + })()`, + deadline, + ), + Boolean, + 40_000, + ); + result.replacementViewerFrames = await evaluate( + cdp, + viewer, + `new Promise((resolve) => { + const video = document.querySelector('video'); + let frames = 0; + const next = () => video.requestVideoFrameCallback(() => { + frames += 1; + if (frames >= 30) resolve(frames); else next(); + }); + next(); + })`, + Date.now() + 10_000, + ); + } else { + // A display remains available while the test Browser is open, so this + // arm does not claim source-end or same-room reselection evidence. + result.sourceFailureEndedShare = null; + result.replacementViewerConnected = null; + result.replacementViewerFrames = null; + } + } + } catch (error) { + // Client stderr can contain implementation diagnostics or URLs; keep gate + // output independent of credentials and media-path identifiers. + result.error = error instanceof Error ? error.message : String(error); + result.stage = stage; + } finally { + if (remoteTunnel) { + result.reverseSignalTunnelClosed = await stopChild(remoteTunnel); + } + if (sourceChrome && sourceCdp) { + await closeSourceBrowser(sourceChrome, sourceCdp); + sourceChrome = null; + sourceCdp = null; + } else if (sourceChrome && sourceChrome.exitCode === null) { + sourceChrome.kill(); + } + result.cleanup = await cleanupRun({ + cdp, + native: client, + chrome, + server: source, + profile, + ports: [sourcePort, appPort, debugPort, ...(clientPort ? [clientPort] : [])], + }); + if (sourceProfile) { + const sourceCleanup = await cleanupRun({ + cdp: null, + native: sourceChrome, + chrome: null, + server: null, + profile: sourceProfile, + ports: [sourceDebugPort], + }); + result.cleanup.profileRemoved = + result.cleanup.profileRemoved && sourceCleanup.profileRemoved; + result.cleanup.portsClosed = + result.cleanup.portsClosed && sourceCleanup.portsClosed; + } + } + result.passed = result.error === null && result.hostNativeActive && + result.hostInvite && + (mode !== "local" + ? result.remoteViewerConnected && result.remoteViewerPackets >= 30 && + result.remoteNatPath && result.remotePeerExited === true && + (mode !== "cross-nat" || result.reverseSignalTunnelClosed === true) + : result.viewerConnected && result.viewerFrames >= 30 && + result.viewerWidth === 1280 && result.viewerHeight === 720 && + result.nativeQualityEvidence && + (sourceKind === "display" || + (result.sourceFailureEndedShare === true && + result.replacementViewerConnected === true && + (result.replacementViewerFrames ?? 0) >= 30))) && + result.cleanup.browserExited && result.cleanup.nativeExited && + result.cleanup.serverClosed && result.cleanup.portsClosed && + result.cleanup.profileRemoved; + process.stdout.write(JSON.stringify(result) + "\n"); + if (!result.passed) process.exitCode = 1; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + await main(); +} diff --git a/scripts/client-native-sfu-gate.ts b/scripts/client-native-sfu-gate.ts new file mode 100644 index 0000000..405b2ff --- /dev/null +++ b/scripts/client-native-sfu-gate.ts @@ -0,0 +1,390 @@ +import { + spawn, + spawnSync, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { AccessToken } from "livekit-server-sdk"; +import { createServer as createViteServer, type ViteDevServer } from "vite"; + +import { + CdpConnection, + cleanupRun, + createPage, + evaluate, + reservePort, + type PageHandle, + waitForVersion, +} from "./browser-gate-harness"; +import { + closeSourceBrowser, + powershell, + SOURCE_TITLE, + sourceServer, + startSourceBrowser, + waitForCaptureWindow, +} from "./client-native-host-gate"; +import { + decodeClientEndpoint, + type ClientEndpoint as Endpoint, +} from "./client-gate-endpoint"; + +const ROOT = resolve(import.meta.dirname, ".."); +const BUILD_ROOT = join(ROOT, "build", "client-check"); + +function run(command: string, args: string[], cwd = ROOT): void { + const result = spawnSync(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout).trim() || `${command} failed`); + } +} + +async function reserveUDPPort(): Promise { + const socket = createSocket("udp4"); + return await new Promise((resolvePort, rejectPort) => { + socket.once("error", rejectPort); + socket.bind(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close(() => resolvePort(address.port)); + }); + }); +} + +async function waitForHTTP(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + if ((await fetch(url)).ok) return; + } catch { + // The isolated service is still starting. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`service did not become ready: ${url}`); +} + +async function readEndpoint( + process: ChildProcessWithoutNullStreams, +): Promise { + return await new Promise((resolveEndpoint, rejectEndpoint) => { + const timer = setTimeout( + () => rejectEndpoint(new Error("native Client did not become ready")), + 10_000, + ); + let buffered = ""; + const onData = (chunk: Buffer) => { + buffered += chunk.toString(); + for (;;) { + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + try { + const value = decodeClientEndpoint(line); + clearTimeout(timer); + process.stdout.off("data", onData); + resolveEndpoint(value); + return; + } catch { + // Ignore non-JSON informational output. + } + } + }; + process.stdout.on("data", onData); + process.once("error", rejectEndpoint); + process.once("exit", () => rejectEndpoint(new Error("native Client exited"))); + }); +} + +async function issueToken( + apiKey: string, + apiSecret: string, + room: string, + identity: string, + publish: boolean, +): Promise { + const token = new AccessToken(apiKey, apiSecret, { identity, ttl: 300 }); + token.addGrant({ + roomJoin: true, + room, + canPublish: publish, + canSubscribe: !publish, + canPublishData: false, + }); + return await token.toJwt(); +} + +async function stopProcess(process: ChildProcessWithoutNullStreams | null): Promise { + if (!process || process.exitCode !== null || process.signalCode !== null) return true; + process.kill(); + return await new Promise((resolveExit) => { + const timer = setTimeout(() => resolveExit(false), 5_000); + process.once("exit", () => { + clearTimeout(timer); + resolveExit(true); + }); + }); +} + +async function main(): Promise { + if (process.platform !== "win32") { + throw new Error("The native SFU gate currently requires Windows"); + } + if (process.env.SCREENER_CLIENT_NATIVE_SFU_GATE !== "true") { + throw new Error("SCREENER_CLIENT_NATIVE_SFU_GATE=true is required"); + } + const chromePath = process.env.CHROME_PATH?.trim(); + const livekitPath = process.env.SCREENER_LIVEKIT_SERVER?.trim(); + if (!chromePath || !livekitPath) { + throw new Error("CHROME_PATH and SCREENER_LIVEKIT_SERVER are required"); + } + const go = process.env.SCREENER_GO?.trim() || "go"; + const sourcePort = await reservePort(); + const sourceDebugPort = await reservePort(); + const vitePort = await reservePort(); + const debugPort = await reservePort(); + const livekitPort = await reservePort(); + const livekitTCPPort = await reservePort(); + const livekitUDPPort = await reserveUDPPort(); + const profile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + const sourceProfile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + const configPath = join(profile, "client.json"); + const captureBinary = join(BUILD_ROOT, "screener-client-capture.exe"); + const clientBinary = join(BUILD_ROOT, "screener-client.exe"); + const origin = `http://127.0.0.1:${vitePort}`; + const livekitURL = `ws://127.0.0.1:${livekitPort}`; + const apiKey = `gate${randomBytes(8).toString("hex")}`; + const apiSecret = randomBytes(32).toString("base64url"); + const room = `native-sfu-${randomBytes(8).toString("hex")}`; + const result = { + passed: false, + hostPublished: false, + viewerFrames: 0, + viewerWidth: 0, + viewerHeight: 0, + cleanup: false, + error: null as string | null, + stage: "setup", + }; + let vite: ViteDevServer | null = null; + let livekit: ChildProcessWithoutNullStreams | null = null; + let client: ChildProcessWithoutNullStreams | null = null; + let source: { close(): Promise } | null = null; + let sourceChrome: ChildProcessWithoutNullStreams | null = null; + let sourceCdp: CdpConnection | null = null; + let chrome: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let hostPage: PageHandle | null = null; + let viewerPage: PageHandle | null = null; + let clientPort = 0; + try { + result.stage = "build"; + run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build"]); + run(powershell(), [ + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + join(ROOT, "native", "client", "platform", "windows", "capture", "build.ps1"), + "-OutputDirectory", BUILD_ROOT, + ]); + run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"], + join(ROOT, "native", "client")); + + result.stage = "livekit"; + livekit = spawn(livekitPath, [ + "--bind", "127.0.0.1", + "--node-ip", "127.0.0.1", + "--port", String(livekitPort), + "--udp-port", String(livekitUDPPort), + "--rtc.tcp_port", String(livekitTCPPort), + "--rtc.enable_loopback_candidate", + ], { + stdio: "pipe", + windowsHide: true, + env: { ...process.env, LIVEKIT_KEYS: `${apiKey}: ${apiSecret}` }, + }); + livekit.stdout.resume(); + livekit.stderr.resume(); + await waitForHTTP(`http://127.0.0.1:${livekitPort}`, 10_000); + + result.stage = "vite"; + vite = await createViteServer({ + root: ROOT, + appType: "custom", + logLevel: "silent", + server: { host: "127.0.0.1", port: vitePort, strictPort: true }, + plugins: [{ + name: "native-sfu-gate-page", + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url?.split("?", 1)[0] !== "/native-sfu-gate") { + next(); + return; + } + response.statusCode = 200; + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end(""); + }); + }, + }], + }); + await vite.listen(); + + result.stage = "source"; + source = await sourceServer(sourcePort); + ({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser( + chromePath, + sourceProfile, + sourceDebugPort, + sourcePort, + )); + await waitForCaptureWindow(captureBinary); + + result.stage = "client"; + client = spawn(clientBinary, [ + "--site", origin, + "--capture-process", captureBinary, + "--config", configPath, + ], { + stdio: "pipe", + windowsHide: true, + env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" }, + }); + client.stderr.resume(); + clientPort = (await readEndpoint(client)).port; + + result.stage = "browser"; + chrome = spawn(chromePath, [ + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-logging", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-features=WebRtcHideLocalIpsWithMdns", + "about:blank", + ], { stdio: "pipe", windowsHide: true }); + chrome.stdout.resume(); + chrome.stderr.resume(); + const version = await waitForVersion(debugPort, chrome); + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + hostPage = await createPage(cdp, `${origin}/native-sfu-gate`, undefined, true); + viewerPage = await createPage(cdp, `${origin}/native-sfu-gate`, undefined, true); + const hostToken = await issueToken(apiKey, apiSecret, room, "host", true); + const viewerToken = await issueToken(apiKey, apiSecret, room, "viewer", false); + + result.stage = "publish"; + const hostResult = await evaluate<{ audio?: boolean; error?: string }>( + cdp, + hostPage, + `(async () => { try { + const gate = await import('/scripts/client-native-sfu-page.ts'); + return await gate.startNativeSfuHost(${JSON.stringify({ + url: livekitURL, + token: hostToken, + sourceTitle: SOURCE_TITLE, + })}); + } catch (error) { + return { error: String(error?.stack || error?.message || error) }; + } })()`, + Date.now() + 30_000, + ); + if (hostResult.error) throw new Error(hostResult.error); + result.hostPublished = true; + + result.stage = "subscribe"; + const viewer = await evaluate<{ + frames?: number; + width?: number; + height?: number; + error?: string; + }>( + cdp, + viewerPage, + `(async () => { try { + const gate = await import('/scripts/client-native-sfu-page.ts'); + return await gate.startNativeSfuViewer(${JSON.stringify({ + url: livekitURL, + token: viewerToken, + })}); + } catch (error) { + return { error: String(error?.stack || error?.message || error) }; + } })()`, + Date.now() + 30_000, + ); + if (viewer.error) throw new Error(viewer.error); + result.viewerFrames = viewer.frames ?? 0; + result.viewerWidth = viewer.width ?? 0; + result.viewerHeight = viewer.height ?? 0; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } finally { + if (cdp && viewerPage) { + await evaluate( + cdp, + viewerPage, + "import('/scripts/client-native-sfu-page.ts').then((gate) => gate.stopNativeSfuGate())", + Date.now() + 5_000, + ).catch(() => undefined); + } + if (cdp && hostPage) { + await evaluate( + cdp, + hostPage, + "import('/scripts/client-native-sfu-page.ts').then((gate) => gate.stopNativeSfuGate())", + Date.now() + 5_000, + ).catch(() => undefined); + } + await vite?.close().catch(() => undefined); + if (sourceChrome && sourceCdp) { + await closeSourceBrowser(sourceChrome, sourceCdp).catch(() => false); + sourceChrome = null; + sourceCdp = null; + } + await source?.close().catch(() => undefined); + const livekitStopped = await stopProcess(livekit); + const cleanup = await cleanupRun({ + cdp, + native: client, + chrome, + server: null, + profile, + ports: [ + vitePort, + debugPort, + livekitPort, + livekitTCPPort, + ...(clientPort ? [clientPort] : []), + ], + }); + const sourceCleanup = await cleanupRun({ + cdp: null, + native: sourceChrome, + chrome: null, + server: null, + profile: sourceProfile, + ports: [sourcePort, sourceDebugPort], + }); + result.cleanup = livekitStopped && cleanup.browserExited && + cleanup.nativeExited && cleanup.portsClosed && cleanup.profileRemoved && + sourceCleanup.profileRemoved && sourceCleanup.portsClosed; + } + result.passed = result.error === null && result.hostPublished && + result.viewerFrames >= 30 && result.viewerWidth === 1280 && + result.viewerHeight === 720 && result.cleanup; + process.stdout.write(`${JSON.stringify(result)}\n`); + if (!result.passed) process.exitCode = 1; +} + +await main(); diff --git a/scripts/client-native-sfu-page.ts b/scripts/client-native-sfu-page.ts new file mode 100644 index 0000000..a733ee1 --- /dev/null +++ b/scripts/client-native-sfu-page.ts @@ -0,0 +1,140 @@ +import { DEFAULT_QUALITY_SETTINGS } from "../src/shared/protocol"; +import { NativeClient } from "../src/client/native/client"; +import { NativeMediaBridge } from "../src/client/native/media-bridge"; +import { SfuPublisher } from "../src/client/sfu/publisher"; + +let hostResources: { + client: NativeClient; + bridge: NativeMediaBridge; + publisher: SfuPublisher; + shareId: string; +} | null = null; +let viewerRoom: import("livekit-client").Room | null = null; + +export async function startNativeSfuHost(input: { + url: string; + token: string; + sourceTitle: string; +}): Promise<{ audio: boolean }> { + if (hostResources) throw new Error("native SFU Host is already active"); + const client = await NativeClient.connect(); + if (!client) throw new Error("native Client is unavailable"); + const shareId = "native_sfu_share_123456"; + let bridge: NativeMediaBridge | null = null; + let publisher: SfuPublisher | null = null; + try { + const adapters = await client.captureOptions(); + const adapter = adapters.find((candidate) => candidate.hardwareH264.length > 0); + const encoder = adapter?.hardwareH264[0]; + const sources = await client.sources(); + const target = sources.find((candidate) => + candidate.kind === "window" && + candidate.title.includes(input.sourceTitle) + ); + if (!adapter || !encoder || !target) { + throw new Error("native capture path is unavailable"); + } + const started = await client.startShare({ + shareId, + source: target, + audio: true, + adapterIndex: adapter.index, + encoderIndex: encoder.index, + edgeCapacity: 3, + }); + bridge = new NativeMediaBridge( + shareId, + client, + () => undefined, + started.audio, + ); + const stream = await bridge.start(); + publisher = new SfuPublisher(); + if (!(await publisher.connect({ url: input.url, token: input.token }))) { + throw new Error("native SFU publisher did not connect"); + } + if (!(await publisher.activate(stream, DEFAULT_QUALITY_SETTINGS, "h264"))) { + throw new Error("native SFU publisher did not activate"); + } + hostResources = { client, bridge, publisher, shareId }; + return { audio: stream.getAudioTracks().length > 0 }; + } catch (error) { + await publisher?.disconnect().catch(() => undefined); + bridge?.dispose(); + await client.stopShare(shareId).catch(() => undefined); + client.close(); + throw error; + } +} + +export async function startNativeSfuViewer(input: { + url: string; + token: string; +}): Promise<{ frames: number; width: number; height: number }> { + if (viewerRoom) throw new Error("native SFU Viewer is already active"); + const sdk = await import("livekit-client"); + const room = new sdk.Room({ + adaptiveStream: false, + disconnectOnPageLeave: false, + }); + viewerRoom = room; + const result = new Promise<{ frames: number; width: number; height: number }>( + (resolve, reject) => { + const timer = window.setTimeout( + () => reject(new Error("native SFU Viewer timed out")), + 20_000, + ); + room.on(sdk.RoomEvent.TrackSubscribed, (track, publication) => { + if (track.kind !== sdk.Track.Kind.Video) return; + publication.setVideoQuality(sdk.VideoQuality.HIGH); + const video = track.attach() as HTMLVideoElement; + video.muted = true; + video.autoplay = true; + document.body.append(video); + void video.play().catch(() => undefined); + let frames = 0; + const next = () => { + video.requestVideoFrameCallback(() => { + if (video.videoWidth === 1280 && video.videoHeight === 720) { + frames += 1; + } else { + frames = 0; + } + if (frames < 30) { + next(); + return; + } + window.clearTimeout(timer); + resolve({ + frames, + width: video.videoWidth, + height: video.videoHeight, + }); + }); + }; + next(); + }); + }, + ); + try { + await room.connect(input.url, input.token); + return await result; + } catch (error) { + await room.disconnect(false).catch(() => undefined); + viewerRoom = null; + throw error; + } +} + +export async function stopNativeSfuGate(): Promise { + const viewer = viewerRoom; + viewerRoom = null; + if (viewer) await viewer.disconnect(false).catch(() => undefined); + const host = hostResources; + hostResources = null; + if (!host) return; + await host.publisher.disconnect().catch(() => undefined); + host.bridge.dispose(); + await host.client.stopShare(host.shareId).catch(() => undefined); + host.client.close(); +} diff --git a/scripts/client-package-targets.mjs b/scripts/client-package-targets.mjs new file mode 100644 index 0000000..8e62b1d --- /dev/null +++ b/scripts/client-package-targets.mjs @@ -0,0 +1,48 @@ +export const CLIENT_PACKAGE_TARGETS = [ + { + id: "windows-amd64", + nodePlatform: "win32", + nodeArch: "x64", + goos: "windows", + goarch: "amd64", + clientName: "screener-client.exe", + nodeName: "node.exe", + tunnelName: "cloudflared.exe", + captureName: "screener-client-capture.exe", + tunnelAsset: "cloudflared-windows-amd64.exe", + tunnelSha256: "83e726ed18ea78c5ad5213c4c3a3a27051393950d2bc8ed4de69bec12d14eaae", + tunnelArchive: false, + }, + { + id: "linux-amd64", + nodePlatform: "linux", + nodeArch: "x64", + goos: "linux", + goarch: "amd64", + clientName: "screener-client", + nodeName: "node", + tunnelName: "cloudflared", + captureName: null, + tunnelAsset: "cloudflared-linux-amd64", + tunnelSha256: "f29324fe934d1e100617484c78deef803c4dc2cd351d645bbde42e96b4fccc5e", + tunnelArchive: false, + }, + { + id: "darwin-arm64", + nodePlatform: "darwin", + nodeArch: "arm64", + goos: "darwin", + goarch: "arm64", + clientName: "screener-client", + nodeName: "node", + tunnelName: "cloudflared", + captureName: "screener-client-capture", + tunnelAsset: "cloudflared-darwin-arm64.tgz", + tunnelSha256: "40c9144d86df8937c5b43293a1f7d2d2107029aa74725023dd46b1b27154352f", + tunnelArchive: true, + }, +]; + +export function clientPackageTarget(id) { + return CLIENT_PACKAGE_TARGETS.find((target) => target.id === id) ?? null; +} diff --git a/scripts/native-one-viewer-gate-core.ts b/scripts/native-one-viewer-gate-core.ts deleted file mode 100644 index 7d21dd1..0000000 --- a/scripts/native-one-viewer-gate-core.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { CleanupResult } from "./browser-gate-harness"; - -export interface ViewerIdentityEvidence { - socketCount: number; - socketOrdinal: number; - authGeneration: number; - connectionCount: number; - connectionOrdinal: number; - pcCount: number; - pcOrdinal: number; - trackCount: number; - trackOrdinal: number; - consistent: boolean; -} - -export interface SenderIdentityEvidence { - peerCount: number; - maxPeers: number; - slot: number; - edgeGeneration: number; -} - -export interface BridgeSendEvidence { - bridgeGeneration: number; - binarySendAttempts: number; - binarySendSucceeded: number; - binarySendFailed: number; -} - -export interface CriticalSenderEvidence { - encoderErrors: number; - fatalEvents: number; -} - -export interface OneViewerMediaCounters { - viewerPackets: number; - viewerDecoded: number; - viewerRendered: number; - pionPackets: number; - pionBytes: number; -} - -export interface OneViewerIdentity { - socketOrdinal: number; - authGeneration: number; - connectionOrdinal: number; - pcOrdinal: number; - trackOrdinal: number; - pionSlot: number; - pionEdgeGeneration: number; -} - -interface FinalizableReport { - status: "passed" | "failed"; - failedStage: string | null; - stages: Record | undefined>; -} - -export function captureOneViewerIdentity( - viewer: ViewerIdentityEvidence, - sender: SenderIdentityEvidence, -): OneViewerIdentity | null { - if (!viewer.consistent || viewer.socketCount !== 1 || viewer.authGeneration !== 1 || - viewer.connectionCount !== 1 || viewer.pcCount !== 1 || viewer.trackCount !== 1 || - sender.peerCount !== 1 || sender.maxPeers > 1 || sender.edgeGeneration !== 1 || - viewer.socketOrdinal !== 1 || viewer.connectionOrdinal !== 1 || viewer.pcOrdinal !== 1 || - viewer.trackOrdinal !== 1 || sender.slot <= 0) return null; - return { - socketOrdinal: viewer.socketOrdinal, - authGeneration: viewer.authGeneration, - connectionOrdinal: viewer.connectionOrdinal, - pcOrdinal: viewer.pcOrdinal, - trackOrdinal: viewer.trackOrdinal, - pionSlot: sender.slot, - pionEdgeGeneration: sender.edgeGeneration, - }; -} - -export function retainsOneViewerIdentity( - baseline: OneViewerIdentity, - viewer: ViewerIdentityEvidence, - sender: SenderIdentityEvidence, -): boolean { - const current = captureOneViewerIdentity(viewer, sender); - return current !== null && Object.keys(baseline).every( - (key) => current[key as keyof OneViewerIdentity] === baseline[key as keyof OneViewerIdentity], - ); -} - -export function retainsFirstBridgeSend(evidence: BridgeSendEvidence): boolean { - return evidence.bridgeGeneration === 1 && evidence.binarySendAttempts > 0 && - evidence.binarySendSucceeded > 0 && evidence.binarySendFailed === 0; -} - -export function hasNoCriticalSenderErrors(evidence: CriticalSenderEvidence): boolean { - return evidence.encoderErrors === 0 && evidence.fatalEvents === 0; -} - -export function hasOneViewerPionProof( - sameGeneration: boolean, - before: OneViewerMediaCounters, - current: OneViewerMediaCounters, -): boolean { - const viewerAdvanced = current.viewerPackets > before.viewerPackets && - current.viewerDecoded > before.viewerDecoded && current.viewerRendered > before.viewerRendered; - const pionAdvanced = current.pionPackets > before.pionPackets && current.pionBytes > before.pionBytes; - // Viewer growth proves the current window when the 2s Pion diagnostics snapshot has not advanced. - const pionAbsolute = current.pionPackets > 0 && current.pionBytes > 0; - return sameGeneration && viewerAdvanced && (pionAdvanced || pionAbsolute); -} - -export async function verifyFinalSenderEvidence( - sample: () => Promise, - retain: (evidence: T) => CriticalSenderEvidence, -): Promise { - try { - const evidence = await sample(); - const retained = retain(evidence); - return hasNoCriticalSenderErrors(evidence) && hasNoCriticalSenderErrors(retained); - } catch { - return false; - } -} - -export async function finalizeGate( - report: FinalizableReport, - gateSucceeded: boolean, - cleanup: () => Promise, -): Promise { - let result: CleanupResult; - try { - result = await cleanup(); - } catch { - result = { browserExited: false, nativeExited: false, serverClosed: false, - portsClosed: false, profileRemoved: false }; - } - const complete = Object.values(result).every(Boolean); - report.stages.cleanup = { ...result, complete }; - if (gateSucceeded && complete) { - report.status = "passed"; - report.failedStage = null; - } else if (!complete) { - report.status = "failed"; - report.failedStage = "cleanup"; - } -} diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts deleted file mode 100644 index 29471b3..0000000 --- a/scripts/native-one-viewer-gate.ts +++ /dev/null @@ -1,922 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -import { createScreenerServer, type ScreenerServer } from "../src/server/app"; -import { loadConfig } from "../src/server/config"; -import { - ROOM_CAPACITY, - RoomStore, - type ConnectedParticipant, - type ConnectParticipantInput, - type RoomStoreOptions, -} from "../src/server/room-store"; -import { - SenderStartLedger, - waitForSenderStart, - type SenderStartObservation, -} from "./native-one-viewer-ledger"; -import { - CdpConnection, - cleanupRun, - createPage, - evaluate, - reservePort, - waitForSample, - waitForVersion, - type PageHandle, -} from "./browser-gate-harness"; -import { - captureOneViewerIdentity, - finalizeGate, - hasNoCriticalSenderErrors, - hasOneViewerPionProof, - retainsFirstBridgeSend, - retainsOneViewerIdentity, - verifyFinalSenderEvidence, - type OneViewerIdentity, - type SenderIdentityEvidence, - type ViewerIdentityEvidence, -} from "./native-one-viewer-gate-core"; - -type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup"; - -interface GateReport { - schemaVersion: 1; - status: "passed" | "failed"; - failedStage: Stage | null; - stages: Record | undefined>; - limitations: string[]; -} - -interface SenderSnapshot { - getDisplayMediaRequested: boolean; - getDisplayMediaResolved: boolean; - nativeHardwareStarting: boolean; - nativeHardwareActive: boolean; - nativeAdapterLuid: string | null; - ready: number; - configAccepted: number; - encoderInstances: number; - encoderOutputs: number; - encoderErrors: number; - fatalEvents: number; - bridgeGeneration: number; - binarySendAttempts: number; - binarySendSucceeded: number; - binarySendFailed: number; - diagnosticsObserved: boolean; - postSendDiagnosticsObserved: boolean; - postSendDiagnosticsSequence: number; - activeViewers: number; - maxPeers: number; - config: { - codec: string | null; - videoSource: string | null; - width: number | null; - height: number | null; - fps: number | null; - bitrate: number | null; - encoderInstances: number | null; - } | null; - media: { - framesWritten: number; - sourceRtpPacketsWritten: number; - sourceRtpBytesWritten: number; - } | null; - peers: Array<{ - slot: number; - edgeGeneration: number; - connectionState: string; - iceConnectionState: string; - route: string; - packetsSent: number; - bytesSent: number; - }>; -} - -type GateMode = "vp8" | "h264" | "native-h264"; - -export function parseGateMode(value: string | undefined): GateMode { - const normalized = value?.trim(); - return normalized === "h264" || normalized === "native-h264" ? normalized : "vp8"; -} - -interface ViewerSnapshot { - signaling: { - authenticateSent: number; - authenticatedReceived: number; - offersReceived: number; - answersSent: number; - candidatesReceived: number; - candidatesSent: number; - }; - identity: ViewerIdentityEvidence; - connectionState: string; - iceConnectionState: string; - packetsReceived: number; - bytesReceived: number; - framesDecoded: number; - videoReadyState: number; - videoWidth: number; - videoHeight: number; - videoCurrentTime: number; - renderedFrames: number; - audioTrackCount: number; - audioPacketsReceived: number; - audioBytesReceived: number; -} - -const root = resolve(import.meta.dirname, ".."); - -class ObservedRoomStore extends RoomStore { - constructor(options: RoomStoreOptions, private readonly onHostAuthenticated: () => void) { - super(options); - } - - override connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { - const participant = super.connectParticipant(input); - if (input.role === "host") this.onHostAuthenticated(); - return participant; - } -} - -function observeNodeStages(server: ScreenerServer, ledger: SenderStartLedger): void { - server.httpServer.prependListener("request", (request, response) => { - const path = new URL(request.url ?? "/", "http://gate.invalid").pathname; - if (request.method === "POST" && path === "/api/site-access") { - response.once("finish", () => { - if (response.statusCode === 200) ledger.record({ siteAccessAccepted: true }); - }); - } else if (request.method === "POST" && path === "/api/rooms") { - response.once("finish", () => { - if (response.statusCode === 201) ledger.record({ roomCreated: true }); - }); - } - }); -} - -function senderStartObservation( - snapshot: SenderSnapshot, - expectedCodec = "vp8", - expectedVideoSource = "browser", -): Partial { - const nativeWindow = expectedVideoSource === "native-window-h264"; - return { - getDisplayMediaRequested: snapshot.getDisplayMediaRequested, - getDisplayMediaResolved: snapshot.getDisplayMediaResolved, - bridgeConnected: snapshot.ready > 0, - fixedHighConfigured: snapshot.configAccepted > 0 && - snapshot.config?.codec === expectedCodec && snapshot.config.videoSource === expectedVideoSource && - snapshot.config.width === 1280 && - snapshot.config.height === 720 && snapshot.config.fps === 30 && - snapshot.config.bitrate === 3_000_000 && snapshot.config.encoderInstances === 1, - firstEncodedChunk: nativeWindow - ? snapshot.nativeHardwareActive && (snapshot.media?.framesWritten ?? 0) > 0 - : snapshot.encoderOutputs > 0, - bridgeGeneration: snapshot.bridgeGeneration, - binarySendAttempts: snapshot.binarySendAttempts, - binarySendSucceeded: snapshot.binarySendSucceeded, - binarySendFailed: snapshot.binarySendFailed, - encoderInstances: snapshot.encoderInstances, - diagnosticsObserved: snapshot.diagnosticsObserved, - postSendDiagnosticsObserved: snapshot.postSendDiagnosticsObserved, - postSendDiagnosticsSequence: snapshot.postSendDiagnosticsSequence, - encoderErrors: snapshot.encoderErrors, - fatalEvents: snapshot.fatalEvents, - ...(snapshot.media === null ? {} : { - framesWritten: snapshot.media.framesWritten, - sourceRtpPacketsWritten: snapshot.media.sourceRtpPacketsWritten, - sourceRtpBytesWritten: snapshot.media.sourceRtpBytesWritten, - }), - }; -} - -function retainsExpectedBridge(snapshot: SenderSnapshot, nativeWindow: boolean): boolean { - return nativeWindow - ? snapshot.bridgeGeneration === 1 && snapshot.binarySendFailed === 0 - : retainsFirstBridgeSend(snapshot); -} - -function nativeSenderStartComplete( - snapshot: SenderSnapshot, - ledger: SenderStartObservation, -): boolean { - return !snapshot.getDisplayMediaRequested && !snapshot.getDisplayMediaResolved && - snapshot.nativeHardwareStarting && snapshot.nativeHardwareActive && - typeof snapshot.nativeAdapterLuid === "string" && snapshot.nativeAdapterLuid.length > 0 && - snapshot.encoderInstances === 0 && snapshot.encoderErrors === 0 && snapshot.fatalEvents === 0 && - snapshot.config?.videoSource === "native-window-h264" && - ledger.siteAccessAccepted && ledger.roomCreated && ledger.hostWssAuthenticated && - ledger.bridgeConnected && ledger.fixedHighConfigured && ledger.firstEncodedChunk && - ledger.bridgeGeneration === 1 && ledger.binarySendFailed === 0 && - ledger.diagnosticsObserved && ledger.framesWritten > 0 && - ledger.sourceRtpPacketsWritten > 0 && ledger.sourceRtpBytesWritten > 0; -} - -function requiredEnvironmentPath(name: string): string { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`${name} is required`); - return value; -} - -function appendLedgerRecord(path: string, record: object): void { - const descriptor = openSync(path, "a"); - try { - writeFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" }); - fsyncSync(descriptor); - } finally { - closeSync(descriptor); - } -} - -async function main(): Promise { - const chromePath = requiredEnvironmentPath("SCREENER_NATIVE_GATE_CHROME"); - const goPath = process.env.SCREENER_NATIVE_GATE_GO?.trim() || "go"; - const gateMode = parseGateMode(process.env.SCREENER_NATIVE_GATE_CODEC); - const nativeWindow = gateMode === "native-h264"; - const gateCodec = gateMode === "vp8" ? "vp8" : "h264"; - const gateVideoSource = nativeWindow ? "native-window-h264" : "browser"; - const nativeHelperPath = nativeWindow - ? requiredEnvironmentPath("SCREENER_WINDOW_CAPTURE_HELPER") - : null; - const windowTargetTitle = process.env.SCREENER_NATIVE_GATE_WINDOW_TARGET_TITLE?.trim() || - (nativeWindow ? "Native Gate Source" : ""); - const accessKey = randomBytes(9).toString("base64url"); - const report: GateReport = { - schemaVersion: 1, - status: "failed", - failedStage: "preflight", - stages: {}, - limitations: [ - "This is an optional Native research probe and is not part of Web acceptance.", - nativeWindow - ? "The source is a real animated top-level Chrome window captured through WGC, not a game workload." - : "The source is a real animated Chrome tab captured through getDisplayMedia, not a game workload.", - nativeWindow - ? "Hardware status is the helper's hardware-only MF contract; physical VideoEncode use requires correlated OS telemetry." - : "This run proves one WebCodecs object, not one physical or hardware encoder.", - windowTargetTitle - ? "The loopback run does not prove public STUN, SFU, restrictive networks, packaging, or endurance." - : "The loopback run does not prove public STUN, SFU, restrictive networks, audio, packaging, or endurance.", - ], - }; - let server: ScreenerServer | null = null; - let native: ChildProcessWithoutNullStreams | null = null; - let chrome: ChildProcessWithoutNullStreams | null = null; - let cdp: CdpConnection | null = null; - let profile: string | null = null; - let ledgerPath: string | null = null; - let appPort: number | null = null; - let debugPort: number | null = null; - let nativePort: number | null = null; - let senderPage: PageHandle | null = null; - let gateSucceeded = false; - const senderLedger = new SenderStartLedger((record) => { - if (!ledgerPath) return; - appendLedgerRecord(ledgerPath, record); - }); - - try { - appPort = await reservePort(); - debugPort = await reservePort(); - profile = await mkdtemp(join(tmpdir(), "screener-native-one-viewer-")); - ledgerPath = join(profile, "sender-start-ledger.jsonl"); - writeFileSync(ledgerPath, "", { encoding: "utf8", flag: "wx" }); - const baseUrl = `http://127.0.0.1:${appPort}`; - const config = loadConfig({ - NODE_ENV: "development", - PORT: String(appPort), - LISTEN_HOST: "127.0.0.1", - PUBLIC_BASE_URL: baseUrl, - ALLOWED_ORIGINS: baseUrl, - SITE_ACCESS_PASSWORD: accessKey, - PEER_ASSISTED_MEDIA: "true", - STUN_URLS: "", - }); - const roomStore = new ObservedRoomStore({ - leaseMs: config.roomLeaseMs, - maxRooms: ROOM_CAPACITY, - maxViewersPerRoom: config.maxViewersPerRoom, - }, () => senderLedger.record({ hostWssAuthenticated: true })); - server = await createScreenerServer({ config, roomStore }); - observeNodeStages(server, senderLedger); - await server.listen(appPort, "127.0.0.1"); - - native = spawn(goPath, ["run", "./cmd/screener-one-viewer-gate"], { - cwd: join(root, "native", "sender"), - stdio: "pipe", - windowsHide: true, - }); - native.stderr.resume(); - const launchUrl = await readLaunchUrl(native); - nativePort = Number(new URL(launchUrl).port) || null; - chrome = spawn(chromePath, [ - `--remote-debugging-port=${debugPort}`, - `--user-data-dir=${profile}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-extensions", - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-renderer-backgrounding", - "--autoplay-policy=no-user-gesture-required", - "--auto-select-tab-capture-source-by-title=Native Gate Source", - "--window-size=1280,900", - "about:blank", - ], { stdio: "pipe", windowsHide: true }); - chrome.stdout.resume(); - chrome.stderr.resume(); - const version = await waitForVersion(debugPort, chrome); - cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000); - report.stages.preflight = { - serverInMemory: true, - peerAssistedEnabled: config.peerAssistedMedia, - oneViewerOnly: true, - videoSource: gateVideoSource, - nativeHelperConfigured: !nativeWindow || nativeHelperPath !== null, - chromeMajor: Number(version.Browser.match(/Chrome\/(\d+)/)?.[1] ?? 0), - }; - report.failedStage = "sender-start"; - - await createPage(cdp, animatedSourceUrl(), undefined, nativeWindow); - const activeSenderPage = await createPage(cdp, launchUrl, senderProbe(), nativeWindow); - senderPage = activeSenderPage; - let windowTargetId = ""; - if (windowTargetTitle) { - await waitForSample( - (deadline) => evaluate(cdp!, activeSenderPage, - `([...document.querySelector('#window-target').options].find((option) => option.text.includes(${JSON.stringify(windowTargetTitle)}))?.value || '')`, deadline), - Boolean, - 5_000, - ); - windowTargetId = await evaluate(cdp, activeSenderPage, - `([...document.querySelector('#window-target').options].find((option) => option.text.includes(${JSON.stringify(windowTargetTitle)}))?.value || '')`, Date.now() + 2_000); - } - await evaluate(cdp, activeSenderPage, `(() => { - document.querySelector('#server-url').value = ${JSON.stringify(baseUrl)}; - document.querySelector('#password').value = ${JSON.stringify(accessKey)}; - document.querySelector('#codec').value = ${JSON.stringify(gateMode)}; - document.querySelector('#window-target').value = ${JSON.stringify(windowTargetId)}; - document.querySelector('#start').click(); - })()`, Date.now() + 5_000); - if (nativeWindow) { - await waitForSample(async (deadline) => { - const snapshot = await senderSnapshot(cdp!, activeSenderPage, deadline); - senderLedger.record(senderStartObservation(snapshot, gateCodec, gateVideoSource)); - return { snapshot, ledger: senderLedger.snapshot() }; - }, ({ snapshot, ledger }) => nativeSenderStartComplete(snapshot, ledger), 20_000); - } else { - await waitForSenderStart( - async (deadline) => senderStartObservation( - await senderSnapshot(cdp!, activeSenderPage, deadline), gateCodec, gateVideoSource, - ), - senderLedger, - { timeoutMs: 20_000 }, - ); - } - const senderReady = await senderSnapshot(cdp, activeSenderPage, Date.now() + 3_000); - const senderStartChecks = { - correctVideoSource: senderReady.config?.videoSource === gateVideoSource, - currentBridge: retainsExpectedBridge(senderReady, nativeWindow), - noCriticalErrors: senderReady.encoderErrors === 0 && senderReady.fatalEvents === 0, - ...(nativeWindow ? { - noBrowserCapture: !senderReady.getDisplayMediaRequested && !senderReady.getDisplayMediaResolved, - nativeHardwareStarting: senderReady.nativeHardwareStarting, - nativeHardwareActive: senderReady.nativeHardwareActive, - noBrowserVideoEncoder: senderReady.encoderInstances === 0, - } : { oneEncoderObject: senderReady.encoderInstances === 1 }), - }; - report.stages["sender-start"] = { - ...senderLedger.snapshot(), ...senderStartChecks, - adapterLuid: nativeWindow ? senderReady.nativeAdapterLuid : null, - }; - requireChecks(senderStartChecks); - report.failedStage = "node-host"; - const inviteUrl = await evaluate(cdp, activeSenderPage, - `document.querySelector('#invite-link').href`, Date.now() + 3_000); - const invite = new URL(inviteUrl); - const roomId = invite.pathname.match(/^\/r\/([1-9][0-9]{0,11})$/)?.[1] ?? ""; - const viewerGrant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? ""; - const nodeHost = { - privateGrantBoundToRoom: new RegExp(`^g1\\.${roomId}\\.[1-9][0-9]{0,12}\\.[A-Za-z0-9_-]{43}$`).test(viewerGrant), - provisionalRandomRoom: roomId !== "1", - }; - report.stages["node-host"] = nodeHost; - requireChecks(nodeHost); - - report.failedStage = "viewer-signal"; - const viewerPage = await createPage(cdp, inviteUrl, viewerProbe()); - let identity: OneViewerIdentity | null = null; - await waitForSample(async (deadline) => ({ - viewer: await viewerSnapshot(cdp!, viewerPage, deadline), - sender: await senderSnapshot(cdp!, activeSenderPage, deadline), - }), ({ viewer, sender }) => { - identity = captureOneViewerIdentity(viewer.identity, senderIdentity(sender)); - const checks = { - viewerAuthenticated: viewer.signaling.authenticateSent === 1 && - viewer.signaling.authenticatedReceived === 1, - peerJoined: sender.activeViewers === 1 && roomStore.getConnectedViewers(roomId).length === 1, - offerAnswer: viewer.signaling.offersReceived >= 1 && viewer.signaling.answersSent >= 1, - iceExchange: viewer.signaling.candidatesReceived >= 1 && viewer.signaling.candidatesSent >= 1, - connected: viewer.connectionState === "connected" && - ["connected", "completed"].includes(viewer.iceConnectionState), - pionConnected: sender.peers.length === 1 && sender.peers[0]?.connectionState === "connected" && - ["connected", "completed"].includes(sender.peers[0]?.iceConnectionState ?? ""), - sameGeneration: identity !== null, - sameBridge: retainsExpectedBridge(sender, nativeWindow), - edgeBound: sender.maxPeers <= 1 && sender.peers.length === 1, - }; - report.stages["viewer-signal"] = { - ...checks, - offersReceived: viewer.signaling.offersReceived, - answersSent: viewer.signaling.answersSent, - candidatesReceived: viewer.signaling.candidatesReceived, - candidatesSent: viewer.signaling.candidatesSent, - socketOrdinal: identity?.socketOrdinal ?? 0, - authGeneration: identity?.authGeneration ?? 0, - connectionOrdinal: identity?.connectionOrdinal ?? 0, - pcOrdinal: identity?.pcOrdinal ?? 0, - trackOrdinal: identity?.trackOrdinal ?? 0, - pionSlot: identity?.pionSlot ?? -1, - pionEdgeGeneration: identity?.pionEdgeGeneration ?? 0, - }; - return Object.values(checks).every(Boolean); - }, 20_000); - - report.failedStage = "viewer-media"; - if (!identity) throw new Error("Viewer generation was not established"); - const mediaBaselineDeadline = Date.now() + 3_000; - const [beforeViewer, beforeSender] = await Promise.all([ - viewerSnapshot(cdp, viewerPage, mediaBaselineDeadline), - senderSnapshot(cdp, activeSenderPage, mediaBaselineDeadline), - ]); - if (!retainsOneViewerIdentity(identity, beforeViewer.identity, senderIdentity(beforeSender))) { - throw new Error("Viewer generation changed before media sampling"); - } - await waitForSample(async (deadline) => ({ - viewer: await viewerSnapshot(cdp!, viewerPage, deadline), - sender: await senderSnapshot(cdp!, activeSenderPage, deadline), - }), ({ viewer, sender }) => { - const sameGeneration = retainsOneViewerIdentity(identity!, viewer.identity, senderIdentity(sender)); - const pionOutbound = hasOneViewerPionProof(sameGeneration, { - viewerPackets: beforeViewer.packetsReceived, viewerDecoded: beforeViewer.framesDecoded, - viewerRendered: beforeViewer.renderedFrames, pionPackets: beforeSender.peers[0]?.packetsSent ?? 0, - pionBytes: beforeSender.peers[0]?.bytesSent ?? 0, - }, { - viewerPackets: viewer.packetsReceived, viewerDecoded: viewer.framesDecoded, - viewerRendered: viewer.renderedFrames, pionPackets: sender.peers[0]?.packetsSent ?? 0, - pionBytes: sender.peers[0]?.bytesSent ?? 0, - }); - const checks = { - inboundPackets: viewer.packetsReceived > beforeViewer.packetsReceived, - inboundBytes: viewer.bytesReceived > beforeViewer.bytesReceived, - decodedFrames: viewer.framesDecoded > beforeViewer.framesDecoded, - videoReady: viewer.videoReadyState >= 2 && viewer.videoWidth > 0 && viewer.videoHeight > 0, - playbackClock: viewer.videoCurrentTime > beforeViewer.videoCurrentTime, - renderedFrames: viewer.renderedFrames > beforeViewer.renderedFrames, - pionOutbound, - sameGeneration, - sameBridge: retainsExpectedBridge(sender, nativeWindow), - criticalErrors: sender.fatalEvents === 0 && sender.encoderErrors === 0, - ...(nativeWindow ? { - nativeHardwareActive: sender.nativeHardwareActive, - noBrowserCapture: !sender.getDisplayMediaRequested && !sender.getDisplayMediaResolved, - noBrowserVideoEncoder: sender.encoderInstances === 0, - } : { oneEncoderObject: sender.encoderInstances === 1 }), - ...(windowTargetTitle ? { - audioTrack: viewer.audioTrackCount === 1, - audioInbound: viewer.audioPacketsReceived > beforeViewer.audioPacketsReceived, - } : {}), - }; - report.stages["viewer-media"] = { - ...checks, - packetDelta: viewer.packetsReceived - beforeViewer.packetsReceived, - byteDelta: viewer.bytesReceived - beforeViewer.bytesReceived, - decodedDelta: viewer.framesDecoded - beforeViewer.framesDecoded, - renderedDelta: viewer.renderedFrames - beforeViewer.renderedFrames, - audioPacketDelta: viewer.audioPacketsReceived - beforeViewer.audioPacketsReceived, - pionPacketDelta: (sender.peers[0]?.packetsSent ?? 0) - (beforeSender.peers[0]?.packetsSent ?? 0), - pionByteDelta: (sender.peers[0]?.bytesSent ?? 0) - (beforeSender.peers[0]?.bytesSent ?? 0), - pionPacketsSent: sender.peers[0]?.packetsSent ?? 0, - pionBytesSent: sender.peers[0]?.bytesSent ?? 0, - pcOrdinal: viewer.identity.pcOrdinal, - pionSlot: sender.peers[0]?.slot ?? -1, - pionEdgeGeneration: sender.peers[0]?.edgeGeneration ?? 0, - route: safeRoute(sender.peers[0]?.route), - videoWidth: viewer.videoWidth, - videoHeight: viewer.videoHeight, - }; - return Object.values(checks).every(Boolean); - }, 10_000); - const finalSender = await senderSnapshot(cdp, activeSenderPage, Date.now() + 3_000); - const finalChecks = { - finalBridge: retainsExpectedBridge(finalSender, nativeWindow), - finalCriticalErrors: hasNoCriticalSenderErrors(finalSender), - finalVideoSource: finalSender.config?.videoSource === gateVideoSource, - ...(nativeWindow ? { - finalNativeHardwareActive: finalSender.nativeHardwareActive, - finalNoBrowserVideoEncoder: finalSender.encoderInstances === 0, - } : {}), - }; - report.stages["viewer-media"] = { ...report.stages["viewer-media"], ...finalChecks }; - requireChecks(finalChecks); - gateSucceeded = true; - } catch { - // The bounded report retains only the first stage and sanitized counters. - } finally { - let finalSenderEvidence = false; - const finalCdp = cdp; - const finalSenderPage = senderPage; - if (finalCdp && finalSenderPage) { - finalSenderEvidence = await verifyFinalSenderEvidence( - () => senderSnapshot(finalCdp, finalSenderPage, Date.now() + 1_000), - (snapshot) => { - senderLedger.record(senderStartObservation(snapshot, gateCodec, gateVideoSource)); - return senderLedger.snapshot(); - }, - ); - } - report.stages["sender-start"] = { - ...report.stages["sender-start"], - ...senderLedger.snapshot(), - }; - gateSucceeded = gateSucceeded && finalSenderEvidence; - await finalizeGate(report, gateSucceeded, () => cleanupRun({ - cdp, native, chrome, server, profile, - ports: [appPort, debugPort, nativePort].filter((port): port is number => port !== null), - })); - console.log(JSON.stringify(report, null, 2)); - if (report.status === "failed") process.exitCode = 1; - } -} - -export function senderProbe(): string { - return `(() => { - const state = { getDisplayMediaRequested: false, getDisplayMediaResolved: false, - nativeHardwareStarting: false, nativeHardwareActive: false, - nativeAdapterLuid: null, - ready: 0, configAccepted: 0, - encoderInstances: 0, encoderOutputs: 0, encoderErrors: 0, fatalEvents: 0, - bridgeGeneration: 0, binarySendAttempts: 0, binarySendSucceeded: 0, binarySendFailed: 0, - diagnosticsObserved: false, postSendDiagnosticsObserved: false, - postSendDiagnosticsSequence: 0, - activeViewers: 0, maxPeers: 0, edgeGeneration: 0, activeEdgeSlot: null, - config: null, media: null, peers: [] }; - const finite = (value) => Number.isFinite(value) ? value : 0; - const mark = (key) => { state[key] = 1; }; - let activeBridgeSocket = null; - const nativeGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); - Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { - configurable: true, - value: (...args) => { - state.getDisplayMediaRequested = true; - return nativeGetDisplayMedia(...args).then((stream) => { - state.getDisplayMediaResolved = true; - return stream; - }); - }, - }); - const NativeWebSocket = globalThis.WebSocket; - globalThis.WebSocket = class GateWebSocket extends NativeWebSocket { - constructor(...args) { - super(...args); - this.__gateMedia = new URL(String(args[0]), location.href).pathname === '/media'; - if (!this.__gateMedia) return; - state.bridgeGeneration = Math.min(2, state.bridgeGeneration + 1); - this.__gateBridgeGeneration = state.bridgeGeneration; - activeBridgeSocket = state.bridgeGeneration === 1 ? this : null; - this.addEventListener('message', (event) => { - if (typeof event.data !== 'string') return; - let message; try { message = JSON.parse(event.data); } catch { return; } - if (message.kind === 'ready') state.ready += 1; - if (message.kind === 'config-accepted') state.configAccepted += 1; - if (message.kind === 'fatal') mark('fatalEvents'); - if (message.kind === 'native-video-state' && message.status?.hardwareOnly === true) { - if (message.status.state === 'starting' && Number.isFinite(message.status.adapterIndex) && - typeof message.status.adapterName === 'string' && message.status.adapterName.length > 0 && - typeof message.status.adapterLuid === 'string' && message.status.adapterLuid.length > 0 && - Number.isFinite(message.status.mftIndex) && - typeof message.status.mftName === 'string' && message.status.mftName.length > 0 && - typeof message.status.mftClsid === 'string' && message.status.mftClsid.length > 0) { - state.nativeHardwareStarting = true; - state.nativeAdapterLuid = message.status.adapterLuid; - } - if (message.status.state === 'active' && state.nativeHardwareStarting && - message.status.profileLevelId === '42c01f' && message.status.width === 1280 && - message.status.height === 720 && message.status.fps === 30) { - state.nativeHardwareActive = true; - } - } - if (message.kind === 'ready' || message.kind === 'viewer-count') { - state.activeViewers = finite(message.activeViewers); - } - if (message.kind === 'diagnostics') { - state.diagnosticsObserved = true; - if (state.binarySendSucceeded > 0) { - state.postSendDiagnosticsObserved = true; - state.postSendDiagnosticsSequence = Math.min(2, state.postSendDiagnosticsSequence + 1); - } - state.media = message.media ? { - framesWritten: finite(message.media.framesWritten), - sourceRtpPacketsWritten: finite(message.media.sourceRtpPacketsWritten), - sourceRtpBytesWritten: finite(message.media.sourceRtpBytesWritten), - } : null; - const peers = Array.isArray(message.peers) ? message.peers.slice(0, 3) : []; - const nextSlot = peers.length === 1 ? finite(peers[0].slot) : null; - if (nextSlot !== null && nextSlot !== state.activeEdgeSlot) state.edgeGeneration += 1; - state.activeEdgeSlot = nextSlot; - state.peers = peers.map((peer) => ({ - slot: finite(peer.slot), edgeGeneration: state.edgeGeneration, - connectionState: String(peer.connectionState || 'unknown'), - iceConnectionState: String(peer.iceConnectionState || 'unknown'), - route: String(peer.route || 'unknown'), - packetsSent: finite(peer.packetsSent), bytesSent: finite(peer.bytesSent), - })); - state.maxPeers = Math.max(state.maxPeers, state.peers.length); - } - }); - } - send(value) { - if (this.__gateMedia && this === activeBridgeSocket && - this.__gateBridgeGeneration === 1 && value instanceof ArrayBuffer) { - mark('binarySendAttempts'); - try { - const result = super.send(value); - mark('binarySendSucceeded'); - return result; - } catch (error) { - mark('binarySendFailed'); - throw error; - } - } - if (this.__gateMedia) { - if (typeof value === 'string') { - let message; try { message = JSON.parse(value); } catch { message = null; } - if (message?.kind === 'config') state.config = { - codec: typeof message.codec === 'string' ? message.codec : null, - videoSource: typeof message.videoSource === 'string' ? message.videoSource : null, - width: finite(message.width), height: finite(message.height), fps: finite(message.fps), - bitrate: finite(message.bitrate), encoderInstances: finite(message.encoderInstances), - }; - } - } - return super.send(value); - } - }; - if (typeof globalThis.VideoEncoder === 'function') { - const NativeVideoEncoder = globalThis.VideoEncoder; - globalThis.VideoEncoder = new Proxy(NativeVideoEncoder, { - construct(Target, args) { - const init = args[0] || {}; - const wrapped = { ...init, - output: (...values) => { state.encoderOutputs += 1; return init.output(...values); }, - error: (...values) => { mark('encoderErrors'); return init.error(...values); }, - }; - const encoder = Reflect.construct(Target, [wrapped], Target); - state.encoderInstances += 1; - return encoder; - }, - get(Target, property, receiver) { - const value = Reflect.get(Target, property, receiver); - return typeof value === 'function' && property === 'isConfigSupported' ? value.bind(Target) : value; - }, - }); - } - Object.defineProperty(globalThis, '__NATIVE_ONE_VIEWER_GATE__', { - value: { snapshot: () => structuredClone(state) }, configurable: false, - }); - })();`; -} - -function viewerProbe(): string { - return `(() => { - const state = { signaling: { authenticateSent: 0, authenticatedReceived: 0, - offersReceived: 0, answersSent: 0, candidatesReceived: 0, candidatesSent: 0 }, - socketCount: 0, socketSequence: 0, logicalSocketCount: 0, - authenticateSocketOrdinal: 0, authSocketOrdinal: 0, logicalAuthSocketOrdinal: 0, - authGeneration: 0, - connectionIds: [], activeConnectionId: null, preOfferConnectionId: null, - consistent: true, pendingOffer: null, - pcs: [], boundPcOrdinal: 0, boundConnectionOrdinal: 0, boundSocketOrdinal: 0, - boundAuthGeneration: 0, trackCount: 0, trackOrdinal: 0, trackPcOrdinal: 0, - audioTrackCount: 0, - renderedFrames: 0, observedVideo: null }; - const connectionOrdinal = (value) => { - let index = state.connectionIds.indexOf(value); - if (index < 0) { state.connectionIds.push(value); index = state.connectionIds.length - 1; } - return index + 1; - }; - const inspect = (data, direction, socketOrdinal) => { - if (typeof data !== 'string') return; - let message; try { message = JSON.parse(data); } catch { return; } - if (direction === 'out' && message.type === 'authenticate') { - state.signaling.authenticateSent += 1; state.authenticateSocketOrdinal = socketOrdinal; return; - } - if (direction === 'in' && message.type === 'authenticated') { - state.signaling.authenticatedReceived += 1; - const activeMediaStarted = state.activeConnectionId !== null || state.boundPcOrdinal > 0; - if (state.authGeneration === 0) { - state.authGeneration = 1; - state.logicalSocketCount = 1; - state.logicalAuthSocketOrdinal = 1; - } else if (activeMediaStarted) { - state.authGeneration += 1; - state.logicalSocketCount += 1; - state.logicalAuthSocketOrdinal = state.logicalSocketCount; - state.consistent = false; - } - state.authSocketOrdinal = socketOrdinal; - if (state.authenticateSocketOrdinal !== socketOrdinal) state.consistent = false; - return; - } - if (message.type !== 'signal' || !message.payload) return; - const rawConnectionId = message.payload.connectionId; - if (typeof rawConnectionId !== 'string' || state.authSocketOrdinal !== socketOrdinal) { - state.consistent = false; return; - } - const ordinal = connectionOrdinal(rawConnectionId); - const inboundOffer = direction === 'in' && message.payload.kind === 'description' && - message.payload.description?.type === 'offer'; - if (state.activeConnectionId === null) { - if (inboundOffer) { - if (state.preOfferConnectionId !== null && state.preOfferConnectionId !== rawConnectionId) { - state.consistent = false; - } - state.activeConnectionId = rawConnectionId; - } else if (state.preOfferConnectionId === null) { - // Pion may trickle a candidate before the offer write reaches the Viewer. - state.preOfferConnectionId = rawConnectionId; - } else if (state.preOfferConnectionId !== rawConnectionId) { - state.consistent = false; - } - } - if (state.activeConnectionId !== rawConnectionId) state.consistent = false; - if (inboundOffer) { - state.signaling.offersReceived += 1; - state.pendingOffer = { rawConnectionId, ordinal, socketOrdinal, authGeneration: state.authGeneration }; - } - if (direction === 'out' && message.payload.kind === 'description' && message.payload.description?.type === 'answer') { - state.signaling.answersSent += 1; - if (state.boundConnectionOrdinal !== ordinal || state.boundSocketOrdinal !== socketOrdinal || - state.boundAuthGeneration !== state.authGeneration) state.consistent = false; - } - if (direction === 'in' && message.payload.kind === 'candidate') state.signaling.candidatesReceived += 1; - if (direction === 'out' && message.payload.kind === 'candidate') state.signaling.candidatesSent += 1; - }; - const NativeWebSocket = globalThis.WebSocket; - globalThis.WebSocket = class GateWebSocket extends NativeWebSocket { - constructor(...args) { - super(...args); - this.__gateSignal = new URL(String(args[0]), location.href).pathname === '/signal'; - if (this.__gateSignal) { - this.__gateSocketOrdinal = ++state.socketSequence; - this.addEventListener('message', (event) => inspect(event.data, 'in', this.__gateSocketOrdinal)); - } - } - send(value) { - if (this.__gateSignal) inspect(value, 'out', this.__gateSocketOrdinal); - return super.send(value); - } - }; - const NativePeerConnection = globalThis.RTCPeerConnection; - globalThis.RTCPeerConnection = class GatePeerConnection extends NativePeerConnection { - constructor(...args) { - super(...args); - state.pcs.push(this); - this.__gatePcOrdinal = state.pcs.length; - this.addEventListener('track', (event) => { - if (event.track?.kind === 'audio') { state.audioTrackCount += 1; return; } - if (event.track?.kind !== 'video') return; - state.trackCount += 1; state.trackOrdinal = state.trackCount; - state.trackPcOrdinal = this.__gatePcOrdinal; - }); - } - async setRemoteDescription(description) { - const result = await super.setRemoteDescription(description); - if (description?.type === 'offer') { - const offer = state.pendingOffer; - if (!offer || offer.rawConnectionId !== state.activeConnectionId) state.consistent = false; - else { - state.boundPcOrdinal = this.__gatePcOrdinal; - state.boundConnectionOrdinal = offer.ordinal; - state.boundSocketOrdinal = offer.socketOrdinal; - state.boundAuthGeneration = offer.authGeneration; - } - } - return result; - } - }; - async function snapshot() { - const video = document.querySelector('.remote-stage video'); - if (video && state.observedVideo !== video && typeof video.requestVideoFrameCallback === 'function') { - state.observedVideo = video; - const frame = () => { state.renderedFrames += 1; video.requestVideoFrameCallback(frame); }; - video.requestVideoFrameCallback(frame); - } - const pc = state.pcs[state.boundPcOrdinal - 1]; - let packetsReceived = 0, bytesReceived = 0, framesDecoded = 0; - let audioPacketsReceived = 0, audioBytesReceived = 0; - if (pc) { - const stats = await pc.getStats(); - stats.forEach((entry) => { - if (entry.type === 'inbound-rtp' && entry.kind === 'video' && entry.isRemote !== true) { - packetsReceived += finite(entry.packetsReceived); - bytesReceived += finite(entry.bytesReceived); - framesDecoded += finite(entry.framesDecoded); - } - if (entry.type === 'inbound-rtp' && entry.kind === 'audio' && entry.isRemote !== true) { - audioPacketsReceived += finite(entry.packetsReceived); - audioBytesReceived += finite(entry.bytesReceived); - } - }); - } - const activeOrdinal = state.activeConnectionId === null ? 0 : - state.connectionIds.indexOf(state.activeConnectionId) + 1; - const identity = { socketCount: state.logicalSocketCount, - socketOrdinal: state.logicalAuthSocketOrdinal, - authGeneration: state.authGeneration, connectionCount: state.connectionIds.length, - connectionOrdinal: activeOrdinal, pcCount: state.pcs.length, pcOrdinal: state.boundPcOrdinal, - trackCount: state.trackCount, trackOrdinal: state.trackOrdinal, - consistent: state.consistent && state.boundConnectionOrdinal === activeOrdinal && - state.boundSocketOrdinal === state.authSocketOrdinal && - state.boundAuthGeneration === state.authGeneration && state.trackPcOrdinal === state.boundPcOrdinal }; - return { signaling: structuredClone(state.signaling), identity, - connectionState: pc?.connectionState || 'none', - iceConnectionState: pc?.iceConnectionState || 'none', packetsReceived, bytesReceived, framesDecoded, - videoReadyState: finite(video?.readyState), videoWidth: finite(video?.videoWidth), - videoHeight: finite(video?.videoHeight), videoCurrentTime: finite(video?.currentTime), - renderedFrames: state.renderedFrames, audioTrackCount: state.audioTrackCount, - audioPacketsReceived, audioBytesReceived }; - } - const finite = (value) => Number.isFinite(value) ? value : 0; - Object.defineProperty(globalThis, '__NATIVE_VIEWER_GATE__', { - value: { snapshot }, configurable: false, - }); - })();`; -} - -function senderSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise { - return evaluate(cdp, page, "globalThis.__NATIVE_ONE_VIEWER_GATE__.snapshot()", deadline); -} - -function viewerSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise { - return evaluate(cdp, page, "globalThis.__NATIVE_VIEWER_GATE__.snapshot()", deadline); -} - -function senderIdentity(sender: SenderSnapshot): SenderIdentityEvidence { - const peer = sender.peers[0]; - return { peerCount: sender.peers.length, maxPeers: sender.maxPeers, - slot: peer?.slot ?? -1, edgeGeneration: peer?.edgeGeneration ?? 0 }; -} - -function requireChecks(checks: Record): void { - if (!Object.values(checks).every(Boolean)) throw new Error("Gate check failed"); -} - -function safeRoute(value: string | undefined): string { - return new Set(["direct-udp", "direct-tcp"]) - .has(value ?? "") ? value! : "unknown"; -} - -async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise { - let buffer = ""; - return new Promise((resolveUrl, rejectUrl) => { - const timeout = setTimeout(() => rejectUrl(new Error("Native runner startup timed out")), 20_000); - child.stdout.on("data", (chunk) => { - buffer += chunk.toString(); - const end = buffer.indexOf("\n"); - if (end < 0) return; - clearTimeout(timeout); - try { - const parsed = JSON.parse(buffer.slice(0, end)) as { launchUrl?: unknown }; - if (typeof parsed.launchUrl !== "string") throw new Error(); - resolveUrl(parsed.launchUrl); - } catch { - rejectUrl(new Error("Native runner handoff was invalid")); - } - }); - child.once("exit", () => { - clearTimeout(timeout); - rejectUrl(new Error("Native runner exited before handoff")); - }); - }); -} - -export function animatedSourceUrl(): string { - const html = `Native Gate Source -