From 9579a911687da1bec12ed09f4526115154d7dced Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:41:46 +0800 Subject: [PATCH] fix: finish native client lifecycle and capture aspect Accepted native Client lifecycle and Windows capture aspect phase. All Web/Server and three-platform Client checks passed. --- .agents/skills/ponytail/LICENSE | 21 + .agents/skills/ponytail/SKILL.md | 120 + .agents/skills/ponytail/UPSTREAM.md | 8 + .agents/skills/stop-that-shit/SKILL.md | 101 - .github/workflows/repository-hygiene.yml | 14 + .gitignore | 1 + AGENTS.md | 6 +- CONTRIBUTING.md | 6 +- LICENSE | 21 + README.md | 27 + deploy/release-app.sh | 6 +- docs/README.md | 2 + .../0005-automatic-hybrid-media-routing.md | 24 +- docs/adr/0009-optional-nat-prediction.md | 77 +- .../adr/0010-cross-platform-client-runtime.md | 150 +- .../0011-browser-assisted-native-fanout.md | 70 + docs/deployment.md | 6 + docs/maintenance.md | 12 +- docs/product/media-quality.md | 52 +- docs/product/presentation-lifecycle.md | 23 +- docs/product/rooms-access.md | 14 +- docs/product/routing-transport.md | 43 +- docs/project-memory.md | 36 +- docs/research/agent-context-governance.md | 2 +- .../research/cross-platform-client-runtime.md | 7 +- docs/research/nat-traversal.md | 50 +- docs/research/native-client-lifecycle.md | 120 + docs/research/native-client-media.md | 123 +- docs/research/peer-assisted-media.md | 3 +- docs/research/server-consolidation.md | 100 + docs/status.md | 97 +- docs/todo.md | 105 +- docs/verification-status.md | 70 +- licenses/README.md | 22 + .../bufbuild-protobuf-1.10.1.txt | 49 +- licenses/cloudflared-2026.8.3.txt | 2685 +++++++++++++++ licenses/libvpx-1.17.0.txt | 327 ++ licenses/node-24.19.0.txt | 2946 +++++++++++++++++ licenses/polka-url-1.0.0-next.29.txt | 21 + licenses/sirv-3.0.2.txt | 21 + licenses/upstream.json | 32 + native/client/README.md | 109 +- native/client/cmd/screener-client/main.go | 18 +- .../client/cmd/screener-client/screener.ico | Bin 0 -> 22899 bytes .../screener_windows_amd64.syso | Bin 0 -> 23522 bytes native/client/cmd/screener-peer-gate/main.go | 2 +- native/client/go.mod | 25 +- native/client/go.sum | 44 +- native/client/internal/clientapp/app.go | 224 +- native/client/internal/clientapp/app_test.go | 48 +- native/client/internal/clientapp/console.go | 444 +++ .../client/internal/clientapp/console_test.go | 110 + native/client/internal/clientconfig/config.go | 42 +- .../internal/clientconfig/config_test.go | 16 +- native/client/internal/launcher/server.go | 82 +- .../client/internal/launcher/server_test.go | 92 +- native/client/internal/loopback/protocol.go | 8 +- .../client/internal/loopback/protocol_test.go | 45 +- native/client/internal/loopback/server.go | 120 +- .../client/internal/loopback/server_test.go | 81 +- native/client/internal/mediaedge/audio.go | 62 +- .../internal/mediaedge/candidate_gathering.go | 158 + native/client/internal/mediaedge/edge.go | 114 +- native/client/internal/mediaedge/edge_test.go | 155 +- native/client/internal/mediaedge/engine.go | 163 +- native/client/internal/mediaedge/quality.go | 12 +- .../client/internal/mediaedge/quality_test.go | 4 +- native/client/internal/mediaedge/receiver.go | 316 ++ .../internal/mediaedge/receiver_test.go | 287 ++ native/client/internal/mediaedge/source.go | 73 +- native/client/internal/nativeaudio/encoder.go | 7 + .../internal/nativeaudio/encoder_test.go | 15 + native/client/internal/nativecapture/probe.go | 9 +- .../internal/nativecapture/probe_test.go | 10 +- .../client/internal/nativecapture/protocol.go | 7 +- .../internal/nativecapture/protocol_test.go | 34 +- .../client/internal/nativecapture/stream.go | 71 +- .../client/internal/nativecontrol/session.go | 361 +- .../internal/nativecontrol/session_test.go | 60 +- native/client/internal/nativecontrol/wire.go | 93 +- native/client/internal/nativehost/profile.go | 15 + native/client/internal/nativehost/session.go | 522 ++- .../internal/nativehost/session_test.go | 25 +- .../client/internal/nativeviewer/session.go | 357 ++ native/client/internal/portmapping/mapping.go | 19 +- .../internal/portmapping/mapping_test.go | 8 +- .../client/platform/darwin/capture/README.md | 13 +- .../client/platform/darwin/capture/build.sh | 1 + .../client/platform/darwin/capture/main.swift | 500 ++- .../client/platform/linux/capture/README.md | 32 + native/client/platform/linux/capture/build.sh | 24 + native/client/platform/linux/capture/main.c | 808 +++++ native/client/platform/linux/capture/portal.c | 146 + native/client/platform/linux/capture/portal.h | 22 + .../client/platform/windows/capture/README.md | 22 +- .../platform/windows/capture/build-libvpx.ps1 | 68 + .../client/platform/windows/capture/build.ps1 | 21 +- .../windows/capture/capture_geometry.h | 43 + .../windows/capture/capture_geometry.test.cpp | 53 + .../windows/capture/capture_target.cpp | 737 ++++- .../platform/windows/capture/capture_target.h | 16 + .../windows/capture/libvpx-dependencies.json | 17 + .../client/platform/windows/capture/main.cpp | 600 +++- .../platform/windows/capture/vp8_encoder.cpp | 98 + .../platform/windows/capture/vp8_encoder.h | 35 + package-lock.json | 1 + package.json | 6 +- scripts/assemble-client.mjs | 20 +- scripts/browser-gate-harness.ts | 5 +- scripts/check-client.mjs | 31 +- scripts/check-docs.mjs | 8 +- scripts/client-browser-fanout-gate.ts | 247 ++ scripts/client-icons.mjs | 177 + scripts/client-link-gate.ts | 12 +- scripts/client-local-gate.ts | 12 +- scripts/client-media-gate.ts | 105 +- scripts/client-native-host-gate.ts | 490 ++- scripts/client-native-sfu-gate.ts | 36 +- scripts/client-native-sfu-page.ts | 50 +- scripts/client-package-targets.mjs | 4 +- scripts/package-app-release.mjs | 5 +- scripts/package-client-candidate.mjs | 64 +- scripts/package-licenses.mjs | 118 + scripts/required-project-paths.txt | 8 +- src/client/App.tsx | 27 +- .../components/living/CaptureSourcePicker.tsx | 368 +- src/client/components/living/Comic.tsx | 165 +- src/client/components/living/RouteTree.tsx | 2 +- src/client/components/living/Stage.tsx | 11 +- src/client/components/living/hints/index.tsx | 16 +- src/client/components/living/hints/set1.tsx | 75 +- src/client/components/living/hints/set2.tsx | 14 + src/client/components/living/hints/set3.tsx | 14 +- src/client/components/living/hints/set4.tsx | 73 +- src/client/lib/display-name.ts | 2 +- src/client/lib/session.ts | 17 +- src/client/locales/en.ts | 17 +- src/client/locales/zh.ts | 17 +- src/client/media/quality.ts | 19 +- src/client/native/capture-selection.ts | 21 +- src/client/native/client.ts | 195 +- src/client/native/media-bridge.ts | 21 + src/client/native/media-ingress.ts | 188 ++ .../{host-edge.ts => native-sender-edge.ts} | 103 +- ...ive-host-peer.ts => native-sender-peer.ts} | 84 +- src/client/native/native-viewer-peer.ts | 745 +++++ src/client/native/wire.ts | 49 +- src/client/pages/ClientLauncherPage.tsx | 102 +- src/client/pages/HostPage.tsx | 457 ++- src/client/pages/ViewerPage.tsx | 177 +- src/client/styles.css | 139 +- src/client/webrtc/nat-prediction.ts | 67 +- src/client/webrtc/viewer-peer.ts | 43 +- src/client/webrtc/viewer-relay.ts | 143 +- src/server/app.ts | 1 + src/server/config.ts | 1 + src/server/hybrid-media-router.ts | 4 + src/server/ice.ts | 5 +- src/server/local-config.ts | 44 +- src/server/room-route-controller.ts | 211 +- src/shared/protocol.ts | 6 +- tests/client-state.test.ts | 36 +- tests/hybrid-media-router.test.ts | 47 + tests/ice.test.ts | 8 +- tests/living-components.test.ts | 376 --- tests/local-server-config.test.ts | 26 + tests/metric-cells.test.ts | 277 -- tests/nat-prediction.test.ts | 33 +- tests/native-capture-selection.test.ts | 55 + tests/native-client.test.ts | 213 +- tests/native-media-bridge.test.ts | 6 +- tests/native-media-ingress.test.ts | 85 + ...dge.test.ts => native-sender-edge.test.ts} | 50 +- ...eer.test.ts => native-sender-peer.test.ts} | 41 +- tests/native-viewer-peer.test.ts | 61 + tests/peer-assisted-benchmark.test.ts | 1714 ---------- tests/protocol.test.ts | 25 +- tests/quality.test.ts | 22 + tests/room-route-controller.test.ts | 140 + tests/route-tree.test.ts | 8 +- tsconfig.tools.json | 1 + 181 files changed, 19370 insertions(+), 4239 deletions(-) create mode 100644 .agents/skills/ponytail/LICENSE create mode 100644 .agents/skills/ponytail/SKILL.md create mode 100644 .agents/skills/ponytail/UPSTREAM.md delete mode 100644 .agents/skills/stop-that-shit/SKILL.md create mode 100644 LICENSE create mode 100644 docs/adr/0011-browser-assisted-native-fanout.md create mode 100644 docs/research/native-client-lifecycle.md create mode 100644 docs/research/server-consolidation.md create mode 100644 licenses/README.md rename public/third-party-licenses.txt => licenses/bufbuild-protobuf-1.10.1.txt (85%) create mode 100644 licenses/cloudflared-2026.8.3.txt create mode 100644 licenses/libvpx-1.17.0.txt create mode 100644 licenses/node-24.19.0.txt create mode 100644 licenses/polka-url-1.0.0-next.29.txt create mode 100644 licenses/sirv-3.0.2.txt create mode 100644 licenses/upstream.json create mode 100644 native/client/cmd/screener-client/screener.ico create mode 100644 native/client/cmd/screener-client/screener_windows_amd64.syso create mode 100644 native/client/internal/clientapp/console.go create mode 100644 native/client/internal/clientapp/console_test.go create mode 100644 native/client/internal/mediaedge/candidate_gathering.go create mode 100644 native/client/internal/mediaedge/receiver.go create mode 100644 native/client/internal/mediaedge/receiver_test.go create mode 100644 native/client/internal/nativehost/profile.go create mode 100644 native/client/internal/nativeviewer/session.go create mode 100644 native/client/platform/linux/capture/README.md create mode 100755 native/client/platform/linux/capture/build.sh create mode 100644 native/client/platform/linux/capture/main.c create mode 100644 native/client/platform/linux/capture/portal.c create mode 100644 native/client/platform/linux/capture/portal.h create mode 100644 native/client/platform/windows/capture/build-libvpx.ps1 create mode 100644 native/client/platform/windows/capture/capture_geometry.h create mode 100644 native/client/platform/windows/capture/capture_geometry.test.cpp create mode 100644 native/client/platform/windows/capture/libvpx-dependencies.json create mode 100644 native/client/platform/windows/capture/vp8_encoder.cpp create mode 100644 native/client/platform/windows/capture/vp8_encoder.h create mode 100644 scripts/client-browser-fanout-gate.ts create mode 100644 scripts/client-icons.mjs create mode 100644 scripts/package-licenses.mjs create mode 100644 src/client/native/media-ingress.ts rename src/client/native/{host-edge.ts => native-sender-edge.ts} (61%) rename src/client/native/{native-host-peer.ts => native-sender-peer.ts} (69%) create mode 100644 src/client/native/native-viewer-peer.ts delete mode 100644 tests/living-components.test.ts delete mode 100644 tests/metric-cells.test.ts create mode 100644 tests/native-media-ingress.test.ts rename tests/{native-host-edge.test.ts => native-sender-edge.test.ts} (63%) rename tests/{native-host-peer.test.ts => native-sender-peer.test.ts} (64%) create mode 100644 tests/native-viewer-peer.test.ts delete mode 100644 tests/peer-assisted-benchmark.test.ts diff --git a/.agents/skills/ponytail/LICENSE b/.agents/skills/ponytail/LICENSE new file mode 100644 index 0000000..715d483 --- /dev/null +++ b/.agents/skills/ponytail/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DietrichGebert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/ponytail/SKILL.md b/.agents/skills/ponytail/SKILL.md new file mode 100644 index 0000000..02c0712 --- /dev/null +++ b/.agents/skills/ponytail/SKILL.md @@ -0,0 +1,120 @@ +--- +name: ponytail +description: > + Forces the laziest solution that actually works, simplest, shortest, most + minimal. Channels a senior dev who has seen everything: question whether the + task needs to exist at all (YAGNI), reach for the standard library before + custom code, native platform features before dependencies, one line before + fifty. Supports intensity levels: lite, full (default), ultra. Use on ANY + coding task: writing, adding, refactoring, fixing, reviewing, or designing + code, and choosing libraries or dependencies. Also use whenever the user + says "ponytail", "be lazy", "lazy mode", "simplest solution", "minimal + solution", "yagni", "do less", or "shortest path", or complains about + over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT + use for non-coding requests (general knowledge, prose, translation, + summaries, recipes). +argument-hint: "[lite|full|ultra]" +license: MIT +--- + +# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: `/ponytail lite|full|ultra`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project — but it runs *after* you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. +- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. +- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path (`# ponytail: global lock, per-account locks if throughput matters`). + +## Output + +Code first. Then at most three short lines: what was skipped, when to add it. +No essays, no feature tours, no design notes. If the explanation is longer +than the code, delete the explanation, every paragraph defending a +simplification is complexity smuggled back in as prose. Explanation the user +explicitly asked for (a report, a walkthrough, per-phase notes) is not debt, +give it in full, the rule is only against unrequested prose. + +Pattern: `[code] → skipped: [X], add when [Y].` + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. | +| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. | +| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. | + +Example: "Add a cache for these API responses." +- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class." +- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short." +- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate." + +## When NOT to be lazy + +Never simplify away: input validation at trust boundaries, error handling +that prevents data loss, security measures, accessibility basics, anything +explicitly requested. User insists on the full version → build it, no +re-arguing. + +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + +Hardware is never the ideal on paper: a real clock drifts, a real sensor +reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not +just less code, the physical world needs tuning a minimal model can't see. + +Lazy code without its check is unfinished. Non-trivial logic (a branch, a +loop, a parser, a money/security path) leaves ONE runnable check behind, the +smallest thing that fails if the logic breaks: an `assert`-based +`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no +fixtures, no per-function suites unless asked. Trivial one-liners need no +test, YAGNI applies to tests too. + +## Boundaries + +Ponytail governs what you build, not how you talk (pair with Caveman for +terse prose). "stop ponytail" / "normal mode": revert. Level persists until +changed or session end. + +The shortest path to done is the right path. diff --git a/.agents/skills/ponytail/UPSTREAM.md b/.agents/skills/ponytail/UPSTREAM.md new file mode 100644 index 0000000..ef123a8 --- /dev/null +++ b/.agents/skills/ponytail/UPSTREAM.md @@ -0,0 +1,8 @@ +# Upstream + +[Ponytail](https://github.com/DietrichGebert/ponytail) by DietrichGebert, pinned to +[`974d940a1c5344210874150b98ff0d2c861fab6a`](https://github.com/DietrichGebert/ponytail/tree/974d940a1c5344210874150b98ff0d2c861fab6a). + +`SKILL.md` is the unmodified `skills/ponytail/SKILL.md` from that revision; +`LICENSE` is its unmodified root MIT license. The skill has no required local +references. Upstream plugins, hooks, examples, and other skills are not vendored. diff --git a/.agents/skills/stop-that-shit/SKILL.md b/.agents/skills/stop-that-shit/SKILL.md deleted file mode 100644 index 6dd0deb..0000000 --- a/.agents/skills/stop-that-shit/SKILL.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -name: stop-that-shit -description: Control task authority and scope, choose the smallest correct result, and deliver a standalone current artifact without process residue. ---- - -# Stop That Shit - -Do the requested work. Keep necessary consequences. Stop everything else. - -## 1. Authority - -Within higher-priority instructions, the user's latest explicit decision defines -the current task and deliverable. Earlier discussion, examples, experiments, -review suggestions, and agent ideas are inputs, not authority. Resolve the -request against the current product model and reachable evidence before acting. - -The requested task type limits authority. Review, research, explanation, or -diagnosis does not authorize implementation. Implementation does not authorize -deployment, publishing, destructive cleanup, or unrelated changes. Preserve -existing user work and unrelated state. - -## 2. Scope - -Before taking an action that the user did not name, ask: - -1. Did the user request it? -2. Is it necessary to complete the requested result? -3. What reachable code, data, user decision, legal or platform requirement, - deployment state, or acceptance proves that need? -4. Would omitting it fail the current task? - -If the answer remains no, do not take the action. Report it only when useful. - -Requests to "make it better," "optimize," "clean up," or "understand the -intent" do not by themselves add unnamed scope. When the requested boundary is -genuinely ambiguous, default to the smallest complete result. Raise an -unrequested, non-required addition before acting when it would materially change -user-visible behavior, product rules, information structure, dependencies, -implementation scope, or delivery cost. - -## 3. Smallest Correct Result - -Prefer standards, mature framework behavior, and one small general mechanism. -Fix the owning invariant before adding an example-specific branch, gate, probe, -timer, state container, test, compatibility layer, or speculative hook. Add one -only when reachable evidence proves that the general mechanism cannot satisfy -the current result. - -Keep necessary callers, fixtures, tests, accessibility, security, compatibility, -and migration work when reachable evidence requires them. Fewer files or lines -is not the goal. The smallest correct result is. - -## 4. Separate Work From The Deliverable - -The work process may contain questions, alternatives, diagnosis, experiments, -and validation. Do not automatically copy that process into the artifact. The -deliverable must read like a complete, independently authored result that can be -used without the conversation. - -For the deliverable, treat the confirmed current requirements as the sole -content basis, even when editing an existing artifact. Reconstruct the result -from those requirements instead of preserving an old draft's structure or -wording by default. A rejected, withdrawn, or user-requested deletion with no -remaining consumer should be absent as though it had never been introduced. -Current docs, UI, code, comments, configuration, and delivery copy state only -the accepted behavior and rationale that still constrains it. Do not name or -structure a result as a patch, replacement, removal, "no-X" variant, demo, or -intermediate version unless that distinction is itself requested and useful. - -Omit abandoned reasoning, superseded attempts, debug logs, instruction -restatements, agent process, self-referential or meta-explanatory copy, -unrequested suggestions or next steps, obsolete explanations, and modification -traces. Comments and documentation explain only non-obvious current constraints, -rules, risks, or compatibility behavior. - -Keep history, methodology, limitations, warnings, or disclosure only when the -user requested them or when a concrete decision, compatibility, migration, -legal, safety, audit, or rollback consumer requires them. Do not turn internal -risk controls into user-facing caveats. Narrow or attribute uncertain claims -instead of padding the result with process disclaimers. - -A requested research, review, diagnosis, decision record, or no-go conclusion is -itself a valid result. Keep the evidence and reasoning its reader needs to act; -omit only process with no current consumer. - -## 5. Finish - -As part of normal completion, inspect the pending changes and deliverable only: - -1. Every element is requested or a proven necessary consequence. -2. Names, structure, and copy describe the current result without modification - traces. -3. Explanations say what governs the result; historical "why not" material has a - current consumer. -4. The result stands alone and is directly usable for its requested purpose. -5. No explanation, comment, heading, warning, instruction repeat, or next step is - present without a current need. - -Fix failures silently rather than listing them in the deliverable. Report the -current requested result, its necessary consequences, and the evidence that -makes it complete. Do not add a separate audit loop merely to satisfy this skill. diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index f5abfa9..e818033 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -55,6 +55,8 @@ jobs: if [ -n "$base" ] && ! git diff --quiet "$base" HEAD -- \ .github/workflows/repository-hygiene.yml \ .node-version \ + LICENSE \ + licenses \ index.html \ native/client \ package.json \ @@ -66,7 +68,9 @@ jobs: tsconfig.server.json \ vite.config.ts \ scripts/assemble-client.mjs \ + scripts/client-icons.mjs \ scripts/package-app-release.mjs \ + scripts/package-licenses.mjs \ scripts/package-client-candidate.mjs \ scripts/client-package-targets.mjs \ scripts/check-client.mjs \ @@ -149,6 +153,16 @@ jobs: with: go-version: 1.26.6 cache-dependency-path: native/client/go.sum + - name: Install Linux native capture dependencies + if: matrix.target == 'linux-amd64' + run: >- + sudo apt-get update && sudo apt-get install -y + libportal-dev + libgstreamer1.0-dev + libgstreamer-plugins-base1.0-dev + gstreamer1.0-pipewire + gstreamer1.0-plugins-bad + gstreamer1.0-pulseaudio - name: Download application release uses: actions/download-artifact@v8 with: diff --git a/.gitignore b/.gitignore index eafccc3..d81af04 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # Dependencies and generated output node_modules/ +public/third-party-licenses.txt build/ coverage/ dist/ diff --git a/AGENTS.md b/AGENTS.md index 6876373..0de9621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,9 @@ ## Authority And Scope - Read and follow the complete repo-tracked - [`stop-that-shit` skill](./.agents/skills/stop-that-shit/SKILL.md) before work. - It is the generic authority for scope, smallest-correct-result, and clean - delivery; installed copies must remain synchronized with it. + [`ponytail` skill](./.agents/skills/ponytail/SKILL.md) before coding, review, + or design work. It is required guidance for the smallest correct result; + project authority and delivery constraints remain owned here. - Within higher-priority instructions, the latest explicit user decision defines the task. Review, research, implementation, deployment, and cleanup grant only the authority inherent in that task type. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aadcd9a..49a6108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ This is the default for an ordinary scoped implementation or bug fix. 1. Start from the canonical repository root on a clean, current `main`; preserve unrelated user work. 2. Inspect the affected code, current owner, and reachable evidence. Research primary sources only when the issue is non-trivial or component behavior is uncertain. -3. Make the smallest coherent change under the repo-tracked [`stop-that-shit` skill](./.agents/skills/stop-that-shit/SKILL.md). +3. Make the smallest coherent change under the repo-tracked [`ponytail` skill](./.agents/skills/ponytail/SKILL.md). 4. Run the focused tests or checks that exercise the changed behavior, inspect the pending diff, stage requested files, and report the result and evidence. Do not automatically create a truth checkpoint, edit status/memory, run `npm run check`, request independent reviews, open or merge a pull request, deploy, or clean branches/worktrees for every small change. Do not record ordinary UI details, self-evident implementation, one-off fixes, routine test output, or completed history in long-lived truth documents. @@ -55,7 +55,9 @@ Clean up worktrees and branches only after semantic review and integration are c - Use current community issues and operational reports to identify real compatibility failures, but label anecdotal evidence as such. - Include a date and direct URLs. Separate verified facts, measurements, assumptions, and recommendations. - 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. +- Screener-owned code is [MIT-licensed](./LICENSE); third-party components retain + their own licenses and notices. GPL/AGPL implementation sources remain + study-only unless a separate distribution decision accepts their obligations. ## Verification Entrypoints diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..321ba7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TNTcraft and Screener contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3ad1da9..c041125 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,28 @@ SFU/UDP fallback rather than an always-SFU conference topology. [Current status](./docs/status.md) owns the exact source and production state; this README intentionally does not mirror release details. +## Screener Client + +The Client opens the same interface in your system browser. The Windows package +includes its runtime; you do not need to install Node.js separately. + +- **Local:** share with devices on the same LAN. +- **Public link:** send an ordinary invitation link. Cloudflare Quick Tunnel + carries room access and signaling; media travels between peers without an SFU. +- **Site:** open a saved Screener server and use the running Client for native + capture or compatible media forwarding. The Site can provide its SFU fallback. + +Choose Browser, application/window, or screen in the source picker. Native +screen audio uses the default system output; window audio uses the selected +application when supported. Audio has a separate sharing switch. Native capture +on Windows offers VP8, Auto, and H264 in the same controls as Browser capture. +Other native platform adapters currently use hardware H.264. + +P2P is best effort, not a guarantee that every network pair connects. The +[current status](./docs/status.md) lists platform acceptance and open Client +issues. Client access is open by default; its launcher can set an optional +site-access password. This does not change access rules on a saved Site. + ## Local Development Node.js 24 and npm 11 are required. @@ -44,3 +66,8 @@ npm run check - [Presentation and lifecycle](./docs/product/presentation-lifecycle.md) - [Current work](./docs/todo.md) - [Contributing workflow](./CONTRIBUTING.md) + +## License + +Screener's own code is [MIT licensed](./LICENSE). Redistributed components retain +their own licenses and notices; see [third-party licensing](./licenses/README.md). diff --git a/deploy/release-app.sh b/deploy/release-app.sh index 573f088..2cf2c91 100644 --- a/deploy/release-app.sh +++ b/deploy/release-app.sh @@ -174,7 +174,7 @@ while IFS= read -r raw_entry; do esac case "$entry" in /*|*\\*) printf 'invalid archive entry: %s\n' "$raw_entry" >&2; exit 41 ;; - REVISION|package.json|package-lock.json|dist|dist/client|dist/server|dist/client/*|dist/server/*) ;; + LICENSE|REVISION|package.json|package-lock.json|dist|dist/client|dist/server|dist/client/*|dist/server/*) ;; *) printf 'unexpected archive entry: %s\n' "$raw_entry" >&2; exit 42 ;; esac done < <(tar -tzf "$artifact") @@ -198,7 +198,7 @@ for (const line of lines) { const fields = line.split('\t'); if (fields.length !== 3 || !/^[0-9a-f]{64}$/.test(fields[0]) || !/^\d+$/.test(fields[1])) process.exit(62); const path = fields[2]; - if (!/^(REVISION|package(-lock)?\.json|dist\/(client|server)\/[A-Za-z0-9._/-]+)$/.test(path)) process.exit(63); + if (!/^(LICENSE|REVISION|package(-lock)?\.json|dist\/(client|server)\/[A-Za-z0-9._/-]+)$/.test(path)) process.exit(63); if (path.split('/').some((part) => part === '' || part === '.' || part === '..') || expected.has(path)) process.exit(64); expected.set(path, { hash: fields[0], size: Number(fields[1]) }); } @@ -238,7 +238,7 @@ test -f "$stage/dist/client/$main_asset" test -f "$stage/dist/server/server/index.js" find "$stage/dist" -type d -exec chmod 0755 {} + find "$stage/dist" -type f -exec chmod 0644 {} + -chmod 0644 "$stage/REVISION" "$stage/package.json" "$stage/package-lock.json" +chmod 0644 "$stage/LICENSE" "$stage/REVISION" "$stage/package.json" "$stage/package-lock.json" systemd-run \ --unit="screener-deps-${release_id}" \ diff --git a/docs/README.md b/docs/README.md index 341254a..9026ea7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ ordinary implementation and UI detail. audio boundary. - [ADR-0009](./adr/0009-optional-nat-prediction.md): optional connection-local NAT prediction. +- [ADR-0011](./adr/0011-browser-assisted-native-fanout.md): Browser capture with + Client encoded fanout. - [ADR-0010](./adr/0010-cross-platform-client-runtime.md): cross-platform Client runtime and self-contained package boundary. diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 44394b1..74a9ea3 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -89,8 +89,16 @@ root. A fresh healthy candidate sender proof is required before make-before-brea commit. Success moves one branch toward even fanout; failure consumes that one-shot intent. No timer, continuous rebalance or general load score is introduced. -A failed Peer tuple is consumed for its exact parent and endpoint sessions plus -endpoint-transition strength. SFU create, reuse, and replace are resource +A Peer opportunity is fenced by its exact parent and endpoint sessions plus +endpoint-transition strength. With NAT traversal disabled, one failed attempt +consumes it. With traversal enabled, availability acquisition and direct +continuation may create at most three candidate connections for that opportunity. +Only an admitted candidate creation spends an attempt; duplicate failure and +timeout messages cannot spend it again. A preparation rejection that cannot +create a connection remains terminal. A new session or strictly stronger +transition reopens the opportunity; worse transitions use only its remaining +budget. Quality and root-convergence experiments retain their existing one-shot +consumption rules. SFU create, reuse, and replace are resource actions for one logical SFU opportunity keyed by the child and Host sessions; the publication generation remains a physical fence, not retry authority. Controller-owned publication creation, replacement, or teardown cannot make the @@ -140,6 +148,18 @@ Peer candidates converge behind working media, one at a time and round-robin across SFU Viewers. New join or repair work preempts this background convergence. Exhausting direct candidates simply keeps the working SFU route. +When NAT acquisition exhausts one operation but retains candidate budget, the +same serial scheduler starts the next operation after rollback and resource +release. Each operation retains its full original deadline; neither a failed +candidate nor a retry extends a running operation. Untried parents precede +repeat attempts, and waiting Viewers with fewer started Peer attempts go first. +The foreground SFU window is unchanged. SFU commitment carries the same +remaining Peer budget into the existing round-robin direct continuations, not +another retry ledger. No usable SFU route is interrupted by these attempts. +This permits longer total acquisition while keeping every operation bounded and +requiring no polling or independent retry timer. The prepare message reports +the actual candidate's connection attempt, not inferred UI progress. + The five-second no-transport-progress window advances a silent Peer only while another bounded candidate or SFU fallback remains. It is a route scheduling boundary, not evidence that Browser ICE has reached a terminal state. The final diff --git a/docs/adr/0009-optional-nat-prediction.md b/docs/adr/0009-optional-nat-prediction.md index 61d4915..0eea44b 100644 --- a/docs/adr/0009-optional-nat-prediction.md +++ b/docs/adr/0009-optional-nat-prediction.md @@ -1,20 +1,21 @@ # ADR-0009: Optional Connection-Local NAT Prediction -- Status: accepted as an optional deployment capability +- Status: accepted as an optional Site and Client capability - Date: 2026-09-02 ## Context The self-hosted deployment has two auxiliary STUN-only listeners for controlled -NAT measurements. A disposable namespace lab showed that a sequential, +NAT measurements, while a self-contained Client needs public discovery without +depending on that deployment. A disposable namespace lab showed that a sequential, endpoint-dependent mapping can sometimes be reached when the remote peer receives a small set of adjacent server-reflexive candidates. The result is network- and generation-specific; it is not a reliable NAT type or participant capability. -The existing deployment configuration, route policy, and ICE candidate owners -are sufficient. The mechanism needs no NAT label, route-controller state, new -media path, or third-party STUN dependency. +The existing ICE configuration, route policy, and candidate owners are +sufficient. Prediction needs no NAT classification or new media path; bounded +connection acquisition reuses the route controller's existing opportunity ledger. ## Decision @@ -23,35 +24,47 @@ media path, or third-party STUN dependency. policy. An enabled deployment exposes `NAT traversal`; the pre-share switch defaults on, remains Host-controllable, locks while sharing, and is not persisted beyond that share generation. -2. An enabled deployment derives ports 3479 and 3480 from the first ordinary - STUN authority on UDP 3478. When the share switch is on, every Browser P2P - connection adds those same-host endpoints. This covers Host direct, Viewer - upstream, and Viewer relay connections. SFU PeerConnections remain - unchanged. No independent or third-party STUN endpoint is added. +2. An enabled Site derives ports 3479 and 3480 from its first ordinary STUN + authority on UDP 3478. Public-link Client mode instead supplies one ordinary + public STUN destination and two fixed public survey destinations; pure LAN + mode supplies none. The same share switch and ICE configuration cover every + Browser or Native P2P edge. SFU PeerConnections remain unchanged. 3. For one ICE gathering generation, the adapter observes only UDP `srflx` - candidates whose Browser-reported STUN URL belongs to the self-hosted - 3478/3479/3480 survey set. If three or more distinct survey candidates for + candidates belonging to the exact three-destination survey set. Browser + candidates are matched by their reported STUN URL. Native Pion uses its + `UniversalUDPMux` to perform the survey on the media socket and emits standard + srflx-shaped observations marked with an `ns` foundation. Only those explicit + survey observations feed prediction; port-mapped candidates remain ordinary + ICE inputs. If three or more distinct survey candidates for one media section and public address form an arithmetic port sequence, the adapter appends at most eight bounded candidates outward from both sequence endpoints: four above the high endpoint and four below the low endpoint. - Every ordinary candidate keeps normal Trickle ICE timing; no ordinary + Every host and observed srflx candidate keeps normal Trickle ICE timing; no ordinary candidate is delayed, rejected, or replaced. A Browser that does not expose candidate source URLs keeps ordinary ICE without prediction. Rejection of a predicted remote candidate discards only that optional candidate; ordinary candidate errors retain their normal connection error semantics. -4. Candidate observations remain in memory for that connection only. No NAT - label, raw address, port, score, hard candidate skip, periodic probe, or - route-controller branch is introduced. Sanitized diagnostics record only +4. Candidate observations remain in memory for that connection only. They do + not create persistent NAT labels, endpoint addresses, routing scores, hard + candidate skips, or periodic probes. Sanitized diagnostics record only whether a signaled candidate was ordinary, predicted, or end-of-candidates, and whether the selected remote foundation was ordinary, predicted, or unknown. +5. The same per-share gate also enables the three-attempt Peer acquisition + budget in [ADR-0005](0005-automatic-hybrid-media-routing.md). Browser and + Native candidates use the ordinary server-owned prepare/rollback lifecycle; + no Viewer-owned ICE-restart loop is added. Local public-link and Hosted + peer-only rooms can exhaust that budget without SFU, while mixed rooms use + the remaining attempts behind a working SFU route. A new connection can + provide another mapping opportunity, not guaranteed independent randomness: + in particular, Native connections may retain the same shared UDP socket. ## Consequences Positive: -- deployments that operate the required STUN listeners can offer the bounded - mechanism while smaller deployments retain stock ICE; +- Sites with their own listeners and self-contained public-link Clients can use + the same bounded mechanism while pure Browser/LAN operation stays unchanged; - normal ICE remains immediately available, so an unavailable auxiliary STUN listener or unsuccessful prediction cannot delay direct or SFU fallback; - prediction is bounded to one connection generation and one small candidate @@ -63,24 +76,38 @@ Negative: - the Browser may still reject synthetic candidates or the NAT may not have a matching mapping; -- enabled P2P connections perform two extra self-hosted STUN transactions and - may add connectivity checks for up to eight adjacent UDP ports; and +- enabled P2P connections perform two extra STUN transactions and + may add connectivity checks for up to eight adjacent UDP ports; - two endpoint-dependent NATs remain sensitive to candidate scheduling and - intervening mappings, so room-wide coverage is not a success guarantee. + intervening mappings, so room-wide coverage is not a success guarantee; and +- enabled acquisition can occupy up to three ordinary operation windows when + one parent remains, yielding between operations to other waiting Viewers. -Predicted connectivity checks originate from participant Browsers and target -the other participant's bounded candidate window. The Screener server only -receives Binding requests on its fixed 3478/3479/3480 listeners; enabling this -capability does not make the cloud host scan external ports. +Predicted connectivity checks originate from participant ICE agents and target +the other participant's bounded candidate window. A Site server only receives +Binding requests on its fixed listeners; it does not scan external ports. +Public survey operators receive STUN metadata but never the media stream. The controlled lab result (14/15 sequential-to-restricted runs with a bounded prediction set) is mechanism evidence, not a production success guarantee. Target-network prevalence, candidate direction, and long-run resource impact remain acceptance work. +A Windows Native Host preflight first exposed three different temporary local +ports under Pion's ordinary srflx gatherer. Replacing that boundary with Pion's +`UniversalUDPMux` produced an srflx observation whose related port exactly +matched the one media listener. A subsequent public-link run delivered 35 H.264 +RTP packets to an independent Linux Pion Viewer over a selected direct +host-to-srflx pair. This proves the shared-socket candidate carries DTLS-SRTP; +the tested TUN network exposed only one distinct public mapping, so it does not +prove a predicted candidate win. +The current Native path maps selected Pion candidate foundations to the same +anonymous `ordinary | predicted | unknown` evidence as Browser ICE. + ## Primary Sources - [RFC 8445: Interactive Connectivity Establishment](https://www.rfc-editor.org/rfc/rfc8445) - [RFC 4787: UDP NAT behavioral requirements](https://www.rfc-editor.org/rfc/rfc4787) - [RFC 5780: NAT Behavior Discovery Using STUN](https://www.rfc-editor.org/rfc/rfc5780) - [W3C WebRTC candidate model](https://www.w3.org/TR/webrtc/) +- [Pion Universal UDP mux](https://github.com/pion/ice/blob/main/udp_mux_universal.go) diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md index a43bd89..0823d1f 100644 --- a/docs/adr/0010-cross-platform-client-runtime.md +++ b/docs/adr/0010-cross-platform-client-runtime.md @@ -1,7 +1,7 @@ # ADR-0010: Cross-Platform Client Runtime -- Status: accepted Client/Local foundation and Windows native Host media boundary -- Date: 2026-09-04 +- Status: accepted capability-provider architecture and native endpoint media boundary +- Date: 2026-09-05 ## Context @@ -14,27 +14,37 @@ 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. +Room authority and local media capability are independent choices. A room may +come from Local, a temporary public link, or a configured Site while each +participant independently uses Browser media or available Client capability. +Binding these choices into exclusive Client modes prevents mixed Browser/Native +topologies and makes a saved Site unavailable while another room source runs. + ## 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`. +2. The packaged product is Screener Client. Its Go process is the cross-platform + entry and native capability provider. It starts `/health` and one `/control` + WebSocket on IPv4 loopback within ports `39721` through `39730` before room- + source selection and retains them until the process exits. 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 + public process identity, not authentication. The listener accepts the current + Local Host origin and the one user-saved Site origin. Origin and Host + validation plus the Browser's local-network permission own this boundary. +4. Loopback v8 starts with a strict `hello` handshake. Health discovery reports + only separately probed native capture booleans. Native Viewer receive/NAT + remains available when capture or hardware encode is absent. 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. + policy. An activated participant tab opens this connection lazily on its first + native action, reuses it across successive media generations, and closes it + with the page; a picker, share, or room source does not own the socket. The + Browser forwards current 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 @@ -42,7 +52,11 @@ Hosted and Local deployments diverge without improving the media path. 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. + smaller boundary. A Client-opened Site stores a non-secret opt-in at that + exact Browser origin, so later manually opened pages may discover the running + Client. Pages without that opt-in do not probe localhost or request local- + network permission. Clearing Site data simply requires opening it from the + Client again. 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 @@ -52,13 +66,24 @@ Hosted and Local deployments diverge without improving the media path. 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 +8. Browser and Native are local media adapters beneath the same authenticated + participant, connection identity, copy capacity, and committed route graph. + Browser-to-Browser, Native-to-Browser, and Native-to-Native edges use the same + WebRTC signaling contract. Native media currently covers the Host adapter; + Viewer receive/relay follows the same boundary rather than adding a second + participant or route protocol. A Native Viewer accepts only a negotiated + H.264/VP8 and Opus offer, forwards encoded RTP into bounded local sources, and uses + the existing Browser bridge for playback; compatible child edges reuse those + sources. Unsupported media or a failed native bridge falls back to the + existing Browser peer. 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 + gathers ICE; pure LAN Local mode does not. Once ordinary STUN observes a + public address, Native advertises the mapped port as one lower-priority, + srflx-shaped candidate on that address. 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, @@ -69,24 +94,44 @@ Hosted and Local deployments diverge without improving the media path. 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 + and the Client never guesses a target. The same room quality settings select + native capture size, frame rate, video/audio bitrate, and the platform + encoder's quality-versus-speed hint. A live quality or source change prepares + a replacement capture/encoder generation and swaps it behind the existing + Pion source; room, route, and PeerConnections do not change. Windows uses + Graphics Capture, Media Foundation, and WASAPI; macOS uses ScreenCaptureKit, + VideoToolbox, and AudioToolbox; Linux uses the ScreenCast Portal, PipeWire, + and an installed GStreamer hardware-H.264 element. These adapters end at the + same bounded encoded-frame protocol and do not own WebRTC or product state. + The shared Native encode is the normal path. For a persistently degraded + Native sender edge, the existing quality operation may prepare an overlapping + stock Browser sender from the stable local bridge; existing evidence alone + decides commit or rollback, and a later operation may return to Native. No + extra threshold, timer, score, route operation, or representation ladder is + added. 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. On every launch, the current + lightweight control center offers Local, public link, and Site. The Site value + is stored in Client configuration and remains one click on later launches; + selecting Local or public link does not disable background RPC access for the + saved Site. 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. + memory-only rooms, peer-assisted media, no SQLite or SFU, and localhost plus + current LAN IPv4 origins. It uses no public discovery by default. Public-link + mode adds its temporary HTTPS origin, one ordinary public STUN destination, + and two bounded public survey destinations. Site mode consumes that Site's + configured STUN survey. Both feed the same connection-local prediction + adapter; Native additionally owns its UDP socket and best-effort port mapping. +11. One Client configuration owns the optional Site origin and an optional, + user-chosen Local access password. A blank value leaves the Local site open; + a value gates that site through the existing SiteAccess authority. The + Client bootstraps a selected page through a fragment consumed before + authentication; the page retains only the non-secret Client opt-in at its + origin. Friends use the existing room invitation grant. No Client-specific + room 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 @@ -105,8 +150,9 @@ Hosted and Local deployments diverge without improving the media path. ## 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 +Go runtime acts as an optional capability provider when an activated Site or +Local page discovers it, but it is not a second room product. A mixed room does +not expose endpoint implementation to routing policy. The runtime 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. @@ -117,33 +163,41 @@ 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 +received 30+ H.264 RTP packets over a selected direct +`srflx`-to-`srflx` pair; this proves packet delivery, not decoded Browser video. +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 existing SFU publisher, including a live 1080p-to-480p profile change and +complete cleanup. +A Browser-Host-to-Native-Viewer gate additionally proves that the Viewer claims +the v8 Client control session and Chrome decodes the 1280x720 source; the encoded +downstream edge has a separate H.264/Opus RTP integration gate. +One-link Browser media and physical non-Windows capture remain separate gates. +GitHub runners compile all three platform adapters. The macOS arm64 sidecar also +creates a hardware-only VideoToolbox encoder and produces a constrained-baseline +SPS/PPS/IDR; its ScreenCaptureKit video/audio permission, source lifecycle, +static-screen recovery, and endurance still require a physical Mac. The Linux +candidate probes and packages its Portal/PipeWire/GStreamer adapter, while a +real desktop, hardware encoder, system audio, and lifecycle still require a +physical Linux gate. Source previews remain best-effort on both platforms. -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 +The v8 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. +The local trust boundary is intentionally per-user: the instance token identifies +the running Client but is not authentication against another local process. + 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. +delivery, source restart, and cleanup with mapping enabled. The mapped port is +now advertised on the observed public address; a selected mapped path across a +pair that fails with STUN alone remains field evidence rather than an +architectural claim. ## Primary Sources diff --git a/docs/adr/0011-browser-assisted-native-fanout.md b/docs/adr/0011-browser-assisted-native-fanout.md new file mode 100644 index 0000000..d7dcb67 --- /dev/null +++ b/docs/adr/0011-browser-assisted-native-fanout.md @@ -0,0 +1,70 @@ +# ADR-0011: Browser-Assisted Native Encoded Fanout + +- Status: accepted +- Date: 2026-09-05 + +## Context + +The product media goal is one encoded source per capable distribution layer. +Pure Browser WebRTC does not expose a way to inject a received encoded frame +into another `RTCPeerConnection` sender. `RTCRtpScriptTransform` can observe or +modify an existing sender or receiver pipeline, but it does not provide an RTP +transport fanout API. Browser simulcast and Dynacast remain appropriate for a +single Browser-to-SFU publication; they do not make independent P2P senders +share one encoder. + +The repository already has the required Native boundary: a Pion receiver can +accept H.264/Opus RTP and its encoded source can feed bounded downstream Pion +edges without decoding or re-encoding. The system Browser remains the product +UI and capture owner. + +## Decision + +1. A Client-launched Browser Host whose current codec is H.264 and whose + topology optimization is enabled uses one local Browser-to-Native ingress. + The existing `HostPeer` owns its sender, clone, quality settings and pause; + the existing Native receiver owns encoded fanout. VP8 and disabled quality + convergence retain ordinary Browser senders. +2. The ingress is generation-fenced beneath the existing room and route + controller. It adds no participant, route operation, quality score, or + representation policy. Steady P2P output uses one Browser encoding; a LiveKit + publication still owns its separate framework-managed encodings. +3. Native downstream edges reuse the existing `NativeSenderPeer` and + `sourceConnectionId` path. A quality operation may still use the existing + Browser sender candidate for one degraded edge; that candidate is an + exception when sharing an encoding cannot sustain that path. Savings never + justify worse delivery or disabling native Browser quality adaptation. +4. If the Client is absent or the local ingress fails, the Host keeps the + Browser capture and recreates affected senders on their current assigned + routes. Capture and room authority survive loss of the optional fanout. + Ordinary Browser pages retain their existing media path. +5. Native capture, Browser capture through the local ingress, Native Viewer + relay, and LiveKit SFU publication remain separate media adapters. No + application RTP protocol, WebCodecs object protocol, SVC ladder, codec + parser, or custom congestion controller is added. + +## Consequences + +- A Client-launched Browser Host can use one Browser encode plus Native encoded + fanout while preserving the existing UI and room behavior. +- A pure Browser relay remains framework-owned and may encode per child because + the standard Browser API has no cross-connection encoded-frame injection + surface. +- A Browser-to-Native loopback edge adds local WebRTC overhead, but it replaces + repeated remote Browser encoders. Pion owns downstream ICE, DTLS and RTCP; + its existing GCC observations feed route convergence, not a new layer selector. +- A Browser source switch with unchanged media kinds reuses the local sender. + A change in audio presence prepares a new ingress before replacing its Native + children; failure retains Browser capture through ordinary sender recovery. +- A Windows/Chrome two-child gate proves one local encode, 1080p at about 30 fps + on both Viewers, live quality changes, pause/resume, source audio changes, and + Client-exit recovery. Weak-path automatic adaptation and its cost remain + physical acceptance, not a consequence of the clean-path result. + +## References + +- [Advanced peer distribution research](../research/advanced-peer-distribution.md) +- [WebRTC Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/) +- [WebRTC SVC](https://www.w3.org/TR/webrtc-svc/) +- [Pion encoded broadcast](https://github.com/pion/webrtc/blob/main/examples/broadcast/main.go) +- [Pion RTP forwarding and padding](https://github.com/pion/webrtc/blob/main/track_local_static.go) diff --git a/docs/deployment.md b/docs/deployment.md index b362daa..72eec72 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -63,6 +63,12 @@ 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. +The platform package also carries its native presentation metadata: Windows +embeds the icon in the Go executable, Linux emits a freedesktop desktop entry +under `share/`, and macOS emits a thin `.app` launcher with an ICNS resource. +The latter two are packaging metadata only and do not duplicate the Client or +Node runtime. + CI and local release-candidate builds use the same wrapper on the target's native operating system: diff --git a/docs/maintenance.md b/docs/maintenance.md index 16e8ebf..3a7f429 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -4,7 +4,7 @@ 仓库是跨 session、设备和 agent 的持久事实源。会影响后续工作的结论必须先进入其权威文档;聊天、agent 摘要、旧分支和临时计划都不能替代仓库真相。 -常驻上下文只保留每次工作都需要的边界和导航。细节按需读取,完成历史交给 Git。任务范围、最小正确结果和 current-result 交付遵循 `AGENTS.md` 摘要并完整读取仓库内的 [`stop-that-shit` skill](../.agents/skills/stop-that-shit/SKILL.md);本文件只维护仓库特有的事实治理规则。 +常驻上下文只保留每次工作都需要的边界和导航。细节按需读取,完成历史交给 Git。任务范围和交付遵循 `AGENTS.md`,最小正确结果遵循仓库内的 [`ponytail` skill](../.agents/skills/ponytail/SKILL.md);本文件只维护仓库特有的事实治理规则。 ## 事实分层 @@ -25,13 +25,13 @@ ## 顶级指令卫生 -[官方调研](./research/agent-context-governance.md)的共同结论是:顶层指令应是短而稳定的地图,流程和专业细节按需加载,并定期删除冲突或陈旧规则。检查器把官方边界分成推荐线和硬顶:Codex 项目指令链在默认 32 KiB 上限的 80% 提示、到上限失败;Claude 的有效启动指令在官方建议的 200 行提示,并容许到 250 行的项目缓冲;Hermes 在动态下限 20,000 字符的 80% 提示、到下限失败。`CLAUDE.md` 导入的 `AGENTS.md` 计入有效行数;每次任务必读的 repo STS skill 使用同一保守包络。 +[官方调研](./research/agent-context-governance.md)的共同结论是:顶层指令应是短而稳定的地图,流程和专业细节按需加载,并定期删除冲突或陈旧规则。检查器把官方边界分成推荐线和硬顶:Codex 项目指令链在默认 32 KiB 上限的 80% 提示、到上限失败;Claude 的有效启动指令在官方建议的 200 行提示,并容许到 250 行的项目缓冲;Hermes 在动态下限 20,000 字符的 80% 提示、到下限失败。`CLAUDE.md` 导入的 `AGENTS.md` 计入有效行数;必读的 repo Ponytail skill 使用同一保守包络。 推荐线只触发园艺提醒,不是压缩目标。硬顶失败时应把目录专属规则迁到嵌套或 path-scoped context,把流程和参考资料迁到按需 skill/文档,简化重复措辞,并删除已完成历史或陈旧结论;不要为了过闸损失仍有消费者的约束。模块特有规则只有在对应代码存在且每次读取确有必要时,才放到最近目录的 `AGENTS.md`;流程说明进入 `CONTRIBUTING.md`,当前工作进入 `docs/todo.md`,证据与理由进入其 owner。 ## 真相更新顺序 -1. 按 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 authority 和 scope 规则,对照完整产品模型、当前代码、测试和证据确定目标;语义未定时只冻结依赖项。 +1. 按 [`AGENTS.md`](../AGENTS.md) 的 authority 和 scope 规则,对照完整产品模型、当前代码、测试和证据确定目标;语义未定时只冻结依赖项。 2. 就地更新受影响的 product、ADR 或 research owner;仅在快照确实变化时同步 memory/status,未决执行项进入 TODO。 3. 检查各 current owner 一致并形成 Git-tracked truth checkpoint,再开始依赖该结论的实现或派发。 @@ -39,7 +39,7 @@ ## 机制减负审查 -机制减负遵循 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 scope 和 smallest-correct-result 规则。本仓库的审查先只读清点机制服务的用户行为,量化移除后的真实退让,并与代码、配置、迁移、测试、运行时状态、部署和故障面的整体成本比较。文档也是需要同步和审查的机制:长期真相只保留产品合同、非显然不变量、算法/模型、关键取舍和外部证据,普通 UI 与实现细节由代码、测试和 PR 持有。若 owner 接受有界退让,先更新产品合同再删除失去必要性的完整表面;否则保留合同并寻找更小实现。审查不以行数为目标,也不授权无边界重构。 +机制减负遵循 [`ponytail`](../.agents/skills/ponytail/SKILL.md) 的最小正确结果原则,范围由 `AGENTS.md` 约束。本仓库的审查先只读清点机制服务的用户行为,量化移除后的真实退让,并与代码、配置、迁移、测试、运行时状态、部署和故障面的整体成本比较。文档也是需要同步和审查的机制:长期真相只保留产品合同、非显然不变量、算法/模型、关键取舍和外部证据,普通 UI 与实现细节由代码、测试和 PR 持有。若 owner 接受有界退让,先更新产品合同再删除失去必要性的完整表面;否则保留合同并寻找更小实现。审查不以行数为目标,也不授权无边界重构。 TURN 的减负证据由 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) 和相关 transport research 持有;轻量/SQLite 房间取舍由 [ADR-0002](./adr/0002-memory-resident-protected-rooms.md) 持有。这些案例只证明应按当前消费者重审完整成本,不能被推广为永远删除或永远保留某类机制的规则。 @@ -47,7 +47,7 @@ TURN 的减负证据由 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) 在需求或优先级被接受、设计或研究结论改变、实质阶段切换、分支/PR/agent 交接、可能 compaction 或长暂停前更新事实 owner。不要为每个函数、工具调用或未形成结论的探索建立检查点。 -检查点按 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 current-result 规则,只保存以后仍需要的结论、当前状态、下一步和真实阻塞;不保存原始对话或工具输出、完整日志、临时路径、可廉价重查的事实和过程性 TODO 流水账。 +检查点按 [`AGENTS.md`](../AGENTS.md) 的 durable-truth 规则,只保存以后仍需要的结论、当前状态、下一步和真实阻塞;不保存原始对话或工具输出、完整日志、临时路径、可廉价重查的事实和过程性 TODO 流水账。 恢复上下文时依次读取 `AGENTS.md`、project memory、status、TODO、相关 product/ADR/research,然后检查当前分支、`git status`、近期 commit 和 PR。摘要与仓库冲突时,以当前代码、测试和权威文档为准。 @@ -63,6 +63,6 @@ TURN 的减负证据由 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) - 迭代中运行覆盖当前改动的最小定向检查;完整套件、真实浏览器矩阵、网络整形、跨设备、长时稳定性和部署检查在相关候选的验收边界批量运行。 - 文档改动默认运行本地 Markdown 链接、whitespace、diff 和 repository-hygiene 检查;不因无关文档变化重跑媒体基准。 -- 断链、缺失必需 owner 和格式损坏是 hard failure。每次注入的有效 `AGENTS.md`/`CLAUDE.md` 上下文与每次必读的 repo STS skill 使用“推荐线 warning、硬顶 failure”,避免把建议值误当死线,也避免超过工具加载边界。Product、ADR、research、operations 等按需文档的行数/字节阈值始终只是 warning:它提示检查是否应拆分二级 owner、删除历史或移出过程信息,不要求为通过门禁压缩合理正文,也不是交付质量指标。 +- 断链、缺失必需 owner 和格式损坏是 hard failure。每次注入的有效 `AGENTS.md`/`CLAUDE.md` 上下文与必读的 repo Ponytail skill 使用“推荐线 warning、硬顶 failure”,避免把建议值误当死线,也避免超过工具加载边界。Product、ADR、research、operations 等按需文档的行数/字节阈值始终只是 warning:它提示检查是否应拆分二级 owner、删除历史或移出过程信息,不要求为通过门禁压缩合理正文,也不是交付质量指标。 - 昂贵证据记录 commit、环境、结果和适用边界。只有相关路径、协议、配置、依赖、环境或门槛改变,或旧证据失败/含糊时才重跑。 - `.githooks/pre-commit`、`scripts/check-project-state.sh` 和 PowerShell 等价入口共享 `scripts/required-project-paths.txt`。Hook 只做快速、确定、可复现的检查;工程判断留给评审和测试。 diff --git a/docs/product/media-quality.md b/docs/product/media-quality.md index 3f49409..0fd8244 100644 --- a/docs/product/media-quality.md +++ b/docs/product/media-quality.md @@ -1,6 +1,6 @@ # Capture, Audio, And Media Quality -This file owns the current Browser media contract. Detailed measurements and +This file owns the current Browser and Client media contract. Detailed measurements and platform limits live in [realtime quality research](../research/realtime-quality-adaptation.md) and [screen-audio research](../research/browser-screen-audio-quality.md). [ADR-0007](../adr/0007-path-isolated-representation-quality.md) owns the SFU @@ -12,21 +12,41 @@ adaptation decision. 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 + latter uses one supported native codec path and never infers a target from a title. An ordinary Web Host does not probe localhost. +- Windows native capture follows an explicitly stretched active display path + for an entire display or a window covering that display, when the captured + frame matches its desktop source dimensions. Other frames retain their own + aspect ratio. This changes only the encoded presentation, never the game or + display settings; vendor-private scaling is not inferred. - 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. 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. +- Authoritative Browser pause disables the current source and sender-owned tracks + while retaining the room and established routes. Native pause keeps capture + alive but stops session output through the same owner. Black frames, track + mute, or network failure are not interpreted as a user pause. - The Host preview displays the capture stream directly and creates no Viewer or media route. Hiding the page may pause only that local video element; it must not intentionally stop capture, encoding, or upload. - Live quality changes update the current capture and Host sender-track constraints plus sender ceilings without reopening source selection or replacing a healthy route. +- Native quality changes use the same room settings. The Client prepares a new + platform capture/encoder generation, then replaces the old generation behind + the same encoded source and PeerConnections; audio-only changes update the + current Opus encoder directly. Platform quality preference uses the hardware + encoder's standard quality-versus-speed hint, while Pion/WebRTC still own + transport estimation and route evidence. + Windows VP8 uses libvpx's realtime mode; it does not claim the same hardware + quality-versus-speed control or per-edge Browser adaptation. +- With Client available, Browser H.264 capture can use one local sender and the + existing Native encoded fanout while topology optimization is enabled. The + Browser still owns preview, pause, capture settings, and source selection. + Loss of that optional ingress retains capture and rebuilds the assigned + Browser edges. [ADR-0011](../adr/0011-browser-assisted-native-fanout.md) owns + this composition; pure Browser and VP8 sharing retain their normal senders. ## Video Profiles @@ -70,6 +90,15 @@ The Browser chooses the concrete encoder implementation. A reported H.264 codec does not by itself prove hardware acceleration, and Web content cannot select a specific MFT, NVENC, AMF, or QSV implementation. +Windows native capture uses the same VP8/Auto/H264 controls. H264 selects the +hardware path and VP8 the bundled libvpx encoder. Auto measures encoding work +for synthetic NV12 frames at the selected dimensions and frame rate, with a +bounded warmup and sample. If H264 sustains the target it is selected; otherwise +VP8 is measured within the remaining four-second budget. This is a throughput +check, not a perceptual-quality score or a promise under future GPU load. +The returned actual codec owns the shared source, preview, and relay; live +quality/source changes retain it. Other native platform encoders remain H264. + ## Framework-Owned Adaptation Each direct, relay, or SFU video sender owns one clone of its capture or received @@ -95,9 +124,16 @@ 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. +Native sender edges normally reuse one encoded source. A Native Viewer forwards +compatible H.264/VP8 and Opus payload without decoding or re-encoding it; each outbound +PeerConnection owns its RTP identity and transport feedback. When an edge is +persistently degraded, the existing quality operation may prepare a Browser +WebRTC sender from the stable local bridge as that edge's candidate. The old +edge stays live until the Viewer proves the candidate is better; a failed +candidate rolls back, and a later operation may return the edge to the shared +native source. This gives one difficult path stock per-sender adaptation without +lowering the shared representation for healthy paths. It adds no quality score, +timer, or room-wide media setting. Screener does not maintain an application bitrate/resolution ladder, scene detector, periodic quality controller, manual SFU layer selector, or diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md index 6006e76..a9f293a 100644 --- a/docs/product/presentation-lifecycle.md +++ b/docs/product/presentation-lifecycle.md @@ -19,7 +19,8 @@ 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 +Site before entering the Host workspace. Viewer links opened at that activated +origin may use the same running Client without changing the Viewer UI. Starting a share 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. @@ -40,9 +41,15 @@ and sequences, and literal scene objects for people, rooms, and media. Glyphs do not form sentences. Universal digits, transport symbols, URLs, and measured values remain literal when users need the data. -Motion explains entry, transition, progress, and control feedback. Stable state -stays still; ambient animation and motion added only for ornament are outside -the language. All meaning remains available with reduced motion. Functional +Control hints and state comics use the same television for shared or watched +media. Browser chrome identifies the application UI; a window title bar or a +display stand identifies the capture target. A server in a media path means +actual SFU relay, not merely opening a configured Site. + +Motion explains entry, transition, progress, and control feedback. Operational +status stays still; ambient motion is limited to the Client brand mark's +occasional idle wink. +All meaning remains available with reduced motion. Functional controls remain native buttons or inputs with localized accessible names, and hover, keyboard focus, and touch receive equivalent guidance. @@ -123,10 +130,10 @@ that owns that PeerConnection and are never uploaded, persisted, or used for identity or route selection. The product does not offer a diagnostic-download 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 Client audio/SFU, and non-Windows capture remain later platform -work. +Current scope includes Web Host, Web Viewer, Browser relay, and optional Native +Host/Viewer media beneath those same pages. Desktop and mobile Browsers are +Viewer targets; mobile Web capture, reliable background relay, Client-owned SFU, +and physical non-Windows native acceptance remain later platform work. ## Primary References diff --git a/docs/product/rooms-access.md b/docs/product/rooms-access.md index f937b30..38db458 100644 --- a/docs/product/rooms-access.md +++ b/docs/product/rooms-access.md @@ -74,13 +74,15 @@ code-only attempts and does not silently revoke invitations. 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. + choice and optional Local site-access password, not rooms or media state. A + blank password leaves that local site open. -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. +The Client opens its own localhost Host page with the configured password, when +present, 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. diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index f2cb40b..40e96d9 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -32,11 +32,16 @@ share generation: - native-edge topology convergence is enabled by default. The Host may disable it before sharing; when disabled, quality evidence stays diagnostic and availability routing is unchanged; -- NAT traversal is absent unless the deployment enables its self-hosted - auxiliary STUN capability. When available, its Host switch defaults on and - augments every Browser P2P edge in that share; the Host may disable it before - sharing. It is not a participant capability, route score, or SFU preference. - Candidate generation and selected-pair diagnostics retain only +- NAT traversal requires an exact three-destination STUN survey: a Site may use + self-hosted 3478/3479/3480, Public Link uses its bounded public survey, and + pure LAN supplies none. Its default-on Host switch augments Browser and Native + P2P edges; Native also uses its media socket and best-effort gateway mapping. + Availability and background direct acquisition share a bounded budget of + three actual connection attempts per eligible parent/session opportunity. + Retries use the same serial controller and normal operation deadlines; + waiting Viewers and untried parents receive their opportunities first. + It is not a participant capability, route score, or SFU preference. Candidate + generation and selected-pair diagnostics retain only `ordinary | predicted | unknown` provenance. [ADR-0009](../adr/0009-optional-nat-prediction.md) owns the bounded behavior and evidence boundary. @@ -90,6 +95,9 @@ not abandon its only possible route at the foreground boundary. The pending Viewer transport reports an actual Browser failure but installs no shorter initial or disconnected-state deadline. Viewer-owned reconnect timing begins only after that exact candidate commits as the active route. +When NAT traversal is enabled, another operation may use the remaining +connection-attempt budget after rollback. The waiting display uses the actual +server-issued attempt ordinal; it never counts time as an attempted connection. When a newly committed Host-root Viewer exposes unused downstream capacity while another Host root has at least two direct children, the same background operation may @@ -163,17 +171,16 @@ 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. +Client Local mode runs this graph without SFU, NAT prediction, or default STUN. +One-link mode tunnels only HTTP/WebSocket control and adds public STUN to the +same Browser/Native P2P edges. Reachable peers form one mixed relay tree; an +unreachable path adds no route type or score. Site mode uses Site transport. -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. +A native Host reserves one loopback edge for Browser preview and the existing +SFU publisher; it consumes no route-copy capacity or gateway mapping. Direct +children share the native encode, while assigned SFU keeps the Browser LiveKit +representation and recovery policy. + +A native Viewer terminates only its P2P upstream, reserves one Browser playback +edge, and reuses compatible H.264/Opus for bounded children. Browser keeps SFU, +route revisions, frame proof, and fallback. diff --git a/docs/project-memory.md b/docs/project-memory.md index 41105d2..0c16cd3 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,6 +1,6 @@ # Project Memory -Last updated: 2026-09-04 +Last updated: 2026-09-05 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 @@ -8,18 +8,30 @@ Browser relay; desktop and mobile Browsers are Viewer targets. It is not a 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 +temporary public-link, and saved-Site choices on every launch, starts one process- +level loopback capability service, supervises Local Node when selected, and owns +the explicit native media path chosen in the same Web UI. Room source and media +implementation are independent: a topology may mix Browser and Native peers +without changing participant, signaling, capacity, or route identity. Native +Host and H.264/VP8 Viewer receive/relay adapters share the same Pion media edge; +unsupported Viewer media falls back to Browser. A Client-assisted Browser Host +can feed that fanout through one local H.264 sender while quality convergence is +enabled; losing the Client preserves Browser capture. 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. +P2P. Minimal Windows, macOS, and Linux adapters terminate at one encoded-frame +boundary; only Windows has completed physical native-media acceptance, while +the other two compile and package. Native capture consumes the same room quality +settings and applies live quality or source changes behind its stable encoded +source and connections. Native P2P edges feed Pion GCC's categorical payload- +capacity result into the existing route evidence windows. Healthy Native sender +edges share one hardware encode; the existing quality operation may test one +stock Browser sender for a persistently degraded edge without lowering the +shared source. 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; broader package acceptance remains a later gate. +The Local launcher keeps an optional user-chosen site-access password; leaving it +blank keeps the self-contained site open. ## Product Map @@ -72,7 +84,7 @@ implementation and routine UI detail. ## Current Snapshot -Canonical source uses the strict `screener-v19` Browser/server contract with +Canonical source uses the strict `screener-v20` Browser/server contract with committed media readiness, bounded candidate-relative progress and one-shot Host-root convergence. It retains optional SQLite room authority and the H.264/VP8 sender gate. [Status](./status.md) owns the compact current execution diff --git a/docs/research/agent-context-governance.md b/docs/research/agent-context-governance.md index 302f769..b2dcc22 100644 --- a/docs/research/agent-context-governance.md +++ b/docs/research/agent-context-governance.md @@ -38,7 +38,7 @@ Screener therefore warns at the published recommendation or 80% of a loader limit, then fails only at a real loading boundary or a small explicit buffer: 32 KiB for the Codex chain, 20,000 characters for Hermes, and 250 effective Claude lines above the 200-line recommendation. The same envelope applies to the -repo STS skill because `AGENTS.md` requires it on every task. A failure tells the +repo Ponytail skill because `AGENTS.md` requires it for coding work. A failure tells the maintainer to use nested/path-scoped context, on-demand skills or linked docs, remove duplication and completed history, and delete stale conclusions; raising the ceiling is not the default remedy. diff --git a/docs/research/cross-platform-client-runtime.md b/docs/research/cross-platform-client-runtime.md index 68c9963..cdcb763 100644 --- a/docs/research/cross-platform-client-runtime.md +++ b/docs/research/cross-platform-client-runtime.md @@ -77,9 +77,10 @@ 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 +Viewer URL backed by the same local TypeScript process. The Client persists an +optional user-chosen access password and passes it only to its Host page in a +consumed fragment; a blank value leaves the Local site open. 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 diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md index 9d049b5..b3cfd3e 100644 --- a/docs/research/nat-traversal.md +++ b/docs/research/nat-traversal.md @@ -1,8 +1,8 @@ -# Browser NAT Traversal +# Browser And Native NAT Traversal Last reviewed: 2026-09-02 -This document owns evidence for improving direct Browser ICE without adding a +This document owns evidence for improving direct ICE without adding a new relay or a custom transport. The current product contract remains standard WebRTC ICE with STUN discovery and bounded SFU fallback. @@ -56,21 +56,50 @@ supports an opt-in mechanism, not a default or a participant-wide classifier. ## Current Decision -The ordinary self-hosted endpoint remains the discovery service. NAT prediction -is absent by default; a deployment may enable same-host STUN-only listeners on -3479 and 3480. Enabled deployments expose a per-share Host switch that defaults -on and remains locked while sharing. +An ordinary STUN endpoint remains the discovery service. A Site may enable +same-host STUN-only listeners on 3479 and 3480. Public-link Client mode supplies +one ordinary public endpoint plus two public survey destinations; pure LAN mode +supplies none. Available authorities expose the same per-share Host switch, +which defaults on and remains locked while sharing. -The accepted adapter is deliberately connection-local and additive. It -derives the two auxiliary ports from the existing STUN authority, waits for a +The accepted adapter is deliberately connection-local and additive. It waits for a clear three-point arithmetic `srflx` shape in one ICE generation, and appends a small two-sided candidate window from the observed port-sequence endpoint. Ordinary candidates trickle immediately, so unavailable auxiliary listeners cannot hold back stock ICE. There is no NAT label, hard candidate skip, -route-controller input, or SFU preference. The switch applies to Host, Viewer -upstream, and Viewer relay P2P connections. Its exact scope is recorded in +route-controller input, or SFU preference. Browser candidates are filtered by +their reported STUN URL. Native Pion performs the same survey through its +`UniversalUDPMux`, so every observation and subsequent media packet uses one +socket. Only explicitly marked Native survey observations feed prediction; +the independently mapped-port candidate does not. The switch applies to Host, +Viewer upstream, and Viewer relay P2P connections. Its exact scope is recorded in [ADR-0009](../adr/0009-optional-nat-prediction.md). +### Native Shared-Socket Preflight + +On 2026-09-05, the unmodified Pion srflx gatherer contacted three public STUN +destinations from three different temporary local ports, despite the media +engine's ordinary UDP mux. This invalidated the prior assumption that Native +STUN and media already shared one socket. Pion ICE's `UniversalUDPMux` was then +used as the media mux and direct STUN observation owner. Its emitted srflx +candidate reported the exact Engine listener as its related port. + +The same build carried a public-link session to an independent Linux Pion +Viewer: 35 H.264 RTP packets arrived over a selected direct host-to-srflx pair, +while the Quick Tunnel carried signaling only. The active TUN network yielded +one distinct mapped endpoint across the public survey, so no port sequence or +prediction was claimed. The result proves shared-socket discovery and transport, +not public-survey availability or a predicted-path success rate. + +Native Site and public-link shares also request one PCP, UPnP, or NAT-PMP +mapping for that same socket. The returned port is advertised as a +lower-priority candidate using a public address already observed by ordinary +STUN. This is additive and bounded; a VPN, double NAT, or absent mapping service +can make it unusable without delaying or replacing ordinary ICE. +A local UDP-forwarding gate then withheld every ordinary Host candidate and +connected Pion ICE/DTLS in 1.26 seconds through the advertised `mp1` endpoint. +That proves the same-socket ICE mechanism, not rescue through a physical NAT. + ### Independent Observation And Attribution Preflight On 2026-09-01, Chrome 151 on the flagship Windows network gathered one `srflx` @@ -180,4 +209,5 @@ a new Peer route. - [libp2p Browser and DCUtR hole-punching boundary](https://github.com/libp2p/specs/blob/master/connections/hole-punching.md) - [coturn listener and auxiliary endpoint reference](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf) - [Cloudflare Realtime STUN service](https://developers.cloudflare.com/realtime/turn/) +- [Pion Universal UDP mux](https://github.com/pion/ice/blob/main/udp_mux_universal.go) - [WebRTC selected candidate stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-foundation) diff --git a/docs/research/native-client-lifecycle.md b/docs/research/native-client-lifecycle.md new file mode 100644 index 0000000..ea3a465 --- /dev/null +++ b/docs/research/native-client-lifecycle.md @@ -0,0 +1,120 @@ +# Native Client Lifecycle + +- Reviewed: 2026-09-05 +- Scope: Windows capture idle semantics, native loopback input isolation, and + Browser visibility of an unexpected Client disconnect. +- Status: static-source, preview-queue and Client-crash checks pass; Windows 10 + monitor startup and game-specific window replacement remain unresolved. + +## Current Evidence + +An idle WGC source is not a capture-end signal. Source/target closure, control +session loss and individual edge failures have separate owners. A Viewer may +need a keyframe from the retained image while a source remains unchanged. + +Source previews use the same serial control connection as share startup. A +virtual-time reproduction with three three-second previews made the real +`NativeClient.startShare()` time out at eight seconds, before its handler ran +at nine seconds. The picker now sends one preview at a time, cancels unsent +work when its source view is retired, and waits for the remaining preview before +starting native media. Refresh and Client replacement fence old results. + +## Scope Decisions + +- Keep one strict loopback wire and one-share/one-session fence. +- Recovery does not choose a different capture target, codec or media route. +- Do not add a tunnel watchdog, local-process authentication scheme, custom + quality score, or compatibility alias in this phase. + +The loopback identifier boundary is now aligned at 8-256 bytes, and the link +gate is included in TypeScript checking. Evidence wording distinguishes scripted +package gates from physical-device gates. The possible cloudflared orphan and +the intentionally per-user local trust boundary remain separate decisions; no +runtime change for either is claimed here. + +## Implemented Boundary + +- Windows capture treats a quiet frame pool as idle. It retains one latest + converted frame and emits it only when an explicit recovery keyframe request + arrives; source-close and target-process signals remain the end conditions. +- Native Pion candidate input is syntax-checked with the existing ICE parser, + bounded before queuing, and discarded when malformed, stale, or over capacity. + Repeated answers for an edge are idempotent. Profile updates remain fenced to + that same share session in the current loopback v8 wire. +- An unexpected Browser control-socket close now notifies the active Host owner; + intentional user cleanup remains silent and uses the existing share fence. + +## Physical Evidence + +- On 2026-09-04, a blank Windows Notepad window was selected as a native source. + The capture process stayed alive beyond seven seconds without a new source + change, grew from 7,664 to 16,095 output bytes after a `K` request, and then + exited cleanly. This validates the quiet-source and retained-frame path on the + current Windows host; it does not claim a cross-version Windows matrix. +- The same day, the local Native Host gate started a real Host/Viewer share, + terminated the Client process, and observed the Host return to its start-share + control within the bounded gate. Browser, capture, server, ports, and profile + cleanup all passed. +- On 2026-09-05, a Windows 11 build 26200 display-source check produced 91 + hardware-H.264 frames at the 720p30 profile and exited normally. A separate + source-window check stayed alive through 7.02 seconds without frames while + minimized, then produced 17 frames within 540 ms of restoration. All owned + processes, ports and profiles were cleaned up. Neither check establishes the + reported Windows 10 monitor-start failure or game HWND recreation as fixed. + +## Open Capture Reports + +The Windows 10 report concerns selecting one entire monitor; application/window +capture, including fullscreen games, works. `CreateForMonitor` supports Windows +10 1903 and later; the current single-HMONITOR call, identity checks and +`CreateFreeThreaded` setup match the documented API. No missing general permission +request has been established. Community reports of disabled capture services or +invalid display contexts are leads, not a diagnosis of this user's machine. +Projected WinRT exceptions now reach the existing capture-error boundary instead +of escaping `std::exception`; that correction alone does not prove startup fixed. + +The original converter always fitted the captured pixel aspect into the output: +1280x960 into 1920x1080 yielded 1440x1080 content and 240-pixel side bars, even +when Windows explicitly stretched that desktop source onto a 1920x1080 target. +The Windows adapter now reads the active `QueryDisplayConfig` path and applies +its target aspect only for explicit `STRETCHED`, a unique display path, exact +source/frame dimensions, and display or physical full-client-area capture. +Rotation changes the target aspect, not the orientation of already-oriented WGC +pixels. Output profile dimensions and the one-encode media path are unchanged. + +Display metadata refreshes on source geometry changes and existing periodic +keyframes, without another timer or display-setting writes. Unknown/custom +scaling, ambiguous cloned outputs and normal windows keep captured aspect. +This does not prove fidelity to monitor-side scaling or vendor-private game +panel-fit/MPO transforms: those may not appear in desktop display-path metadata. +The CPU geometry check covers stretch, ordinary aspect, rotation and already- +scaled input; the reported game's physical comparison remains acceptance work. + +Windows explicitly permits a capture item to close when its application silently +replaces the underlying window. Reattaching to another window is not equivalent +to resuming the same selected target; this remains separate from the verified +quiet/minimized-source behavior. + +Primary references: [CreateForMonitor](https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createformonitor), +[CreateFreeThreaded](https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.direct3d11captureframepool.createfreethreaded), +[capture item closure](https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscaptureitem.closed), +and [WinRT exceptions](https://learn.microsoft.com/en-us/uwp/cpp-ref-for-winrt/error-handling/hresult-error). +Aspect references: [active display paths](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-querydisplayconfig), +[scaling modes](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ne-wingdi-displayconfig_scaling), +[active signal size](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-displayconfig_video_signal_info), +and [independent panel fitting/MPO](https://learn.microsoft.com/en-us/windows/win32/direct3ddxgi/for-best-performance--use-dxgi-flip-model). + +## Acceptance + +- Static Windows source remains a live share for a bounded physical run, and a + new Viewer can obtain a keyframe without a source change. +- A malformed/stale ICE candidate or repeated per-edge answer does not close the + control session or terminate sibling edges; a real edge lifecycle failure + remains observable. +- Killing the Client causes the active Host owner to leave the native share + path promptly, while ordinary user-initiated cleanup does not recurse. +- `npm run check` and the relevant Client checks pass; no credentials or raw + media identifiers enter logs or documentation. + +Cross-version capture and browser matrices remain separate acceptance work. They +are not claimed by the current Windows gate. diff --git a/docs/research/native-client-media.md b/docs/research/native-client-media.md index 4f4feb7..95392c1 100644 --- a/docs/research/native-client-media.md +++ b/docs/research/native-client-media.md @@ -1,11 +1,10 @@ # Native Client Media Evidence -- Reviewed: 2026-09-04 -- Scope: Windows capture, one shared H.264/Opus source, Pion transport, Browser +- Reviewed: 2026-09-05 +- Scope: platform 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 +- Status: Windows physical native Host and Viewer gates passed; macOS and Linux adapters + compile and package but still require physical media gates ## Result @@ -20,6 +19,15 @@ 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. +On 2026-09-05, a separate Windows Local gate used a Browser Host with synthetic +1280x720 motion and a Client-activated Viewer. The Viewer exclusively claimed +the v8 loopback control session and real Chrome decoded 621 frames at 1280x720. +This proves the Browser-to-Native receive/local-bridge path rather than a silent +Browser fallback. A Pion integration gate separately forwards the same H.264 +and Opus payload from one inbound receiver source to a bounded downstream edge, +while dropping upstream connection-local extensions so each outbound Pion +interceptor writes its own negotiated TWCC header. + 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 @@ -27,8 +35,9 @@ 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. +LiveKit Viewer received the default 1080p source, then 15 consecutive 854x480 +frames after one live Native/SFU profile change. 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: @@ -65,9 +74,24 @@ 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. +Windows now supports hardware H264 and software VP8 behind the same capture, +source and Pion transport boundary. libvpx 1.17.0 accepts the existing NV12 data +with row pitch preserved, without another color-conversion library. In a local +CPU-only 720p30 trial its mean encoding work was 6.112 ms per frame; this excludes +GPU readback and is not game-quality evidence. Separate real Browser gates +proved manual VP8 and Auto-selected H264, including live presets, pause/resume, +source replacement and exact RTP codec preservation. Auto uses a bounded +synthetic target-profile throughput check, not a score or a runtime codec switch. +GPU-heavy selection and cross-device startup remain open acceptance work. + +The Windows Browser gate now also keeps two native PeerConnections alive while +the source changes from 720p30 to 1440p60, then changes to 480p15 while paused +and resumes both Viewers. It proves the same Pion source survives two hardware +capture/encoder generations; direct capture probes also produced every current +resolution/FPS extreme, and the same route survives an explicit native source +switch. The result does not yet prove macOS/Linux physical capture or endurance. +Native Host media is exposed only through an explicit Client-launched Host +selection; an ordinary Web Host retains Browser capture. 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 @@ -77,6 +101,16 @@ 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. + +A shared encode cannot independently adapt one bitstream for unequal paths. +Rather than lower every Native child, a quality operation originating from a + persistently degraded Native sender edge may prepare one Browser WebRTC sender +from the stable local bridge. The existing overlapping candidate comparison +alone commits or rolls it back, and a later quality operation can return that +edge to Native. Healthy Native edges keep sharing the hardware encode. This is +implemented without a new route reason, timer, score, or representation ladder; +controlled weak-path physical acceptance remains open. + 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 @@ -103,8 +137,11 @@ 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 returned port is advertised once per observed public address as a +lower-priority candidate. Only the three explicit same-socket survey candidates +feed NAT prediction; the mapped candidate does not. This proves lifecycle and +signaling, not that the 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 @@ -120,34 +157,63 @@ 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. +A controlled 144 fps window on Windows build 26200 exposed two capture-cadence +failures: WGC's zero `MinUpdateInterval` delivered 48 fps, and sampling from the +last emitted timestamp reduced 48 fps input to 24 fps for a 30 fps profile. +Using WGC's supported non-zero interval plus a phase-continuous output cadence +delivered 29.99 fps and 60.00 fps from the same source. Older Windows versions +without the optional session interface retain their existing WGC behavior. + +A Browser-capture/Pion fanout gate exposed a separate RTP input defect: +payload-empty padding was rejected and ended the video or audio reader. Chrome +then fell to a 30 kbps video target even at zero media loss and about 1 ms RTT. +Discarding padding kept the reader alive but introduced downstream sequence +gaps, NACKs and reduced decoded cadence. Forwarding valid padding through Pion's +existing `WriteRTP`, while excluding it from media-frame counts, restored two +1080p Viewers to about 30 fps with zero packet loss. The same gate passed live +quality, pause/resume, audio-presence source changes and Client-exit fallback. +Native payload-capacity evidence does not require decoded dimensions; encoded +receivers can supply real transport evidence without opening a decoder. + +DevTools accepted weak-network emulation commands in this loopback run, but RTP +traffic exceeded the requested limit without the requested loss. That run does +not establish weak-network quality or automatic fallback performance. + Non-Windows capture keeps the existing process/frame boundary and replaces only -the platform sidecar. The macOS candidate enumerates `SCShareableContent`, fences +the platform sidecar. The macOS adapter 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. +`42c01f` SPS/PPS/IDR. The current adapter also emits ScreenCaptureKit application +or display audio through the common PCM boundary. ScreenCaptureKit permissions, +real video/audio 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. +The Linux adapter lets the XDG ScreenCast Portal own source selection, consumes +its restricted PipeWire stream through one bounded GStreamer pipeline, and +requires an installed element classified as a hardware H.264 encoder. It emits +the same Annex-B protocol and can use the PulseAudio-compatible default monitor +for system audio; it does not claim per-process audio. GitHub Linux CI compiles, +probes, packages, starts, and stops the candidate. A real Portal desktop, +hardware encoder, audio source, Browser decode, and recovery still require a +physical Linux gate. GStreamer stays a system dependency: bundling another RTC +or an 80+ MB media runtime would defeat the thin-adapter boundary, while an +unavailable dependency cleanly leaves Browser capture available. ## 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. +- `nativehost` owns the current capture generation and its bounded stable edges. +- `nativeviewer` owns one native inbound H.264/Opus source and its encoded child + edges; it does not own room or route state. +- `nativecontrol` maps local source/share/receive/edge commands and exact native + sender quality windows, live profile updates, and source replacement to the + loopback v8 wire. The deleted sender application, UI, room client, and old wire are not compatibility inputs. Historical measurements remain in the separately marked @@ -156,6 +222,8 @@ compatibility inputs. Historical measurements remain in the separately marked ## Primary Sources - [Windows Graphics Capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture) +- [WGC `MinUpdateInterval`](https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscapturesession.minupdateinterval) +- [Sunshine WGC update interval](https://github.com/LizardByte/Sunshine/blob/master/src/platform/windows/display_wgc.cpp) - [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/) @@ -167,14 +235,17 @@ compatibility inputs. Historical measurements remain in the separately marked - [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) +- [Pion broadcast relay](https://github.com/pion/webrtc/tree/master/examples/broadcast) - [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 audio output](https://developer.apple.com/documentation/screencapturekit/scstreamoutputtype/audio) - [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) +- [libportal](https://libportal.org/libportal.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/peer-assisted-media.md b/docs/research/peer-assisted-media.md index dbc4691..8ada137 100644 --- a/docs/research/peer-assisted-media.md +++ b/docs/research/peer-assisted-media.md @@ -160,7 +160,8 @@ configuration checks and media evidence would still be required. This research uses W3C/IETF specifications, BSD-licensed libwebrtc source, public browser-compatibility data, and an official TeamSpeak technical statement. No GPL/AGPL implementation code was copied. GPL/AGPL projects remain -study-only until the repository license and distribution model are decided. +study-only unless a separate distribution decision accepts their obligations; +the repository's own [MIT license](../../LICENSE) does not replace theirs. ## Primary Sources diff --git a/docs/research/server-consolidation.md b/docs/research/server-consolidation.md new file mode 100644 index 0000000..0dc234c --- /dev/null +++ b/docs/research/server-consolidation.md @@ -0,0 +1,100 @@ +# Server Consolidation + +- Reviewed: 2026-09-05 +- Status: design candidate for the phase after Browser/Client acceptance +- Scope: one shared Go backend for Hosted and self-contained Client operation + +## Current Ownership + +Hosted [`index.ts`](../../src/server/index.ts) and Local +[`local-index.ts`](../../src/server/local-index.ts) already compose the same +[`createScreenerServer`](../../src/server/app.ts). The Go +[`clientapp`](../../native/client/internal/clientapp/app.go) supervises that Node +process and supplies native media; it does not duplicate room or route authority. +Consolidation replaces this sole backend rather than merging two implementations. + +[`RoomStore`](../../src/server/room-store.ts) owns room authority and participants; +[`RoomDatabase`](../../src/server/room-database.ts) persists only access and lease +material. [`RoomRouteController`](../../src/server/room-route-controller.ts) +decides graph transitions without network I/O; +[`HybridMediaRouter`](../../src/server/hybrid-media-router.ts) executes signaling +and SFU effects. These are useful boundaries, not duplication to remove. + +## Preparatory Simplification + +1. Unify room termination cleanup. `SignalingServer.closeRoom` and `expireRooms` + in [`signaling.ts`](../../src/server/signaling.ts) delete the same timers, + connection evidence, share state and router resources before closing sockets. + Use one cleanup owner with the existing termination reason as input. +2. Remove the Hybrid connection-ID mirror. `connectionIdsByViewer` is populated + through router callbacks, then `handleViewerQualityEvidence` checks that map + and `resolveActiveViewerMediaEdge` again. Derive the Hybrid identity from the + committed edge; retain the evidence gate's own last-observation identity. + Cover reauthentication and in-place connection adoption before removing the + callbacks. The ordinary routing mode remains a separate consumer until its + product decision is made. + +These are structural simplifications, not reproduced functional failures. + +## Product Choice + +`PEER_ASSISTED_MEDIA=false` remains a documented, default configuration in +[`.env.example`](../../.env.example). It owns a separate Host-star path in +`SignalingServer`, including child admission and SDP-answer readiness; Local and +production use the graph controller. Decide whether this mode still belongs to +the product before translating it. If automatic Peer topology is universal, +remove the switch and ordinary path together; otherwise retain its explicit +contract; this reachable mode is not dead code. + +## Target Composition + +```text +one Go module +cmd/screener-server -> shared server application +cmd/screener-client -> shared server application + launcher + native media +internal/server -> rooms, routing, HTTP/signaling, persistence, SFU adapters +internal/client -> current native and platform boundaries +one React/Vite static artifact +``` + +Use packages for cohesive owners and files for local concerns. Do not introduce +a framework layer per file, a second product API, or a multi-module build. +Move the existing `native/client` Go module into the common scope during the +migration. Hosted and Local construct the same validated application options; +entry points own their defaults, reachability and lifecycle policy. + +Each room keeps one serialized state owner. Go HTTP/WebSocket handlers must not +mutate room, share and route maps independently. Run password derivation and +LiveKit I/O outside that owner, then apply results only while the exact session, +share and operation identities still match. Preserve the existing distinction +between decisions and effects rather than replacing Node serialization with +unrelated locks around each map. + +## Reusable Components + +- Use standard [`net/http`](https://pkg.go.dev/net/http) and + [`embed`](https://pkg.go.dev/embed) for HTTP and built assets. +- Reuse the Client's [`coder/websocket`](https://pkg.go.dev/github.com/coder/websocket) + for signaling and the official [LiveKit Go SDK](https://github.com/livekit/server-sdk-go) + for room control and tokens. +- Keep the current SQLite schema and transactions. Compare a small + [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) build/runtime gate + against package and locking needs before selecting a driver; no ORM is needed. +- Follow [Go's shared `internal` and multiple `cmd` guidance](https://go.dev/doc/modules/layout). + Retain current Pion, capture sidecars and control-tunnel ownership. + +## Minimum Acceptance + +Preserve exact Host/session replacement, grant revocation, atomic room replacement, +SQLite restart continuity and Local shutdown semantics. Exercise one graph and +one operation through Peer, SFU, same-edge regeneration, bounded overlap and +rollback, including stale asynchronous completions. Preserve short-lived SFU +authority and release resources only after physical drain confirmation. + +Use the same Browser contract scenarios against Hosted and Local, plus one +physical mixed Browser/Client media flow. Measure package size, startup and idle +memory before and after. Keep one strict wire contract and one schema owner; +avoid hand-maintained parallel TypeScript/Go validation rules. Retire the Node +server, bundled runtime, supervisor and old packaging paths in the same accepted +integration boundary, preserving [ADR-0005](../adr/0005-automatic-hybrid-media-routing.md) +and [ADR-0010](../adr/0010-cross-platform-client-runtime.md). diff --git a/docs/status.md b/docs/status.md index cf09872..8f8e3b3 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Current Status -Last updated: 2026-09-04 +Last updated: 2026-09-05 This is the current execution index. Git history owns completed timelines; [verification status](./verification-status.md) owns evidence boundaries. @@ -50,7 +50,7 @@ This is the current execution index. Git history owns completed timelines; ## Current Source -- Canonical source uses the strict `screener-v19` Browser/server contract. +- Canonical source uses the strict `screener-v20` Browser/server contract. Optional SQLite stable authority, non-expiring local preferred code, atomic room replacement and the Host codec selector remain implemented; production selects stable storage. Graceful restart, crash, timeout and network loss use @@ -76,10 +76,12 @@ This is the current execution index. Git history owns completed timelines; sender windows retain the bounded Peer operation; failed exact candidates stay consumed until the sender or candidate opportunity changes. The pre-share gate defaults on and locks while sharing. -- Source keeps NAT prediction deployment-gated and disabled by default. When a - deployment enables it, Host Advanced settings shows a per-share switch that - defaults on. Every Browser P2P role uses the same bounded, connection-local - adapter with only the deployment's self-hosted 3478/3479/3480 STUN endpoints; +- NAT prediction stays authority-gated and defaults on only when offered. Browser + and Native P2P use one connection-local rule with Site or Public-Link survey + endpoints; pure LAN, route scoring, and SFU preference remain unchanged. + Enabled acquisition now shares a three-attempt budget in the route controller, + across foreground P2P and background direct continuation. Viewer progress + reflects actual candidate creation; quality trials retain their prior budget. candidate and selected-path logs retain anonymous `ordinary | predicted | unknown` provenance. Ordinary candidates and SFU fallback remain unchanged. @@ -94,7 +96,10 @@ This is the current execution index. Git history owns completed timelines; visible/resume/pageshow rebaseline and rearm current-frame proof. Host and Viewer SFU clients disable LiveKit page-leave auto-disconnect, so SFU recovery cannot stop Host-owned capture. Native Viewer controls still own playback, - and manual reconnect stays on the current route. + and manual reconnect stays on the current route. A Client-launched Viewer can + receive H.264/VP8 and Opus through a native encoded source, bridge one local Browser + preview, and reuse that source for compatible P2P children; unsupported codec + or failed bridge returns to the Browser peer. - Viewer presentation derives access, Host, route, playback, and runtime state from separate owners. An exact media generation plus monotonic proof epoch owns visible-frame truth; every invalidation rearms proof, while stale epochs @@ -121,35 +126,73 @@ This is the current execution index. Git history owns completed timelines; including bounded same-edge connection regeneration. Both default it on with a pre-share Host opt-out. Weighted/global optimization remains parked. - 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 + One Go entry starts a process-level loopback capability service, then opens the + current system-Browser launcher for Local, temporary public-link, or saved-Site + operation. The saved Site remains allowed while another room source runs; the + same Host UI explicitly selects + Browser capture or an exact native screen/window. Windows Native now shares + the VP8/Auto/H264 selector, with libvpx software VP8 and hardware H264; Auto + uses a bounded target-profile throughput check. Both VP8 and Auto-selected + H264 passed real Browser delivery and codec-stable live/source changes. + 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 + verified macOS arm64 package. Its capture sidecar passes an arm64 compile, + hardware-H.264 IDR self-test, and audio-adapter build, but the package and + capture path remain unrun on a physical Mac. The Linux package now includes a + thin Portal/PipeWire/GStreamer hardware-H.264 capture adapter and passes its + compile, probe, package, startup, and shutdown checks; real desktop capture, + system audio, and recovery remain unproved. Client-scoped pull requests build + and run the scripted Local smoke on 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. + best-effort PCP/UPnP/NAT-PMP mapping and Pion Universal-UDP-mux STUN discovery + on their media socket. A public-link run delivered 35 H.264 packets to an + independent Linux Viewer; no predicted-path win is yet claimed. 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. + pacing or changing the shared encode. The existing quality operation may test + a Browser sender for one persistently degraded Native edge without lowering + healthy shared edges. A local bridge supplies native Host media to the existing + Browser LiveKit publisher; an isolated LiveKit gate + proves default 1080p delivery, a live change to 480p, and complete cleanup + without a second SFU client. + A Windows gate proves Browser Host to Client-native Viewer delivery and Chrome + decode; an RTP gate covers bounded H.264/Opus child relay and cleanup. + Client-assisted Browser H.264 capture now reuses one local sender for Native + fanout while quality convergence is enabled. Its two-Viewer gate covers + cadence, live controls and Client-exit recovery; weak-network acceptance + remains open. Native RTP forwarding preserves padding and its sequence + continuity without requiring decoded dimensions for capacity evidence. + Native control treats malformed/stale/repeated per-edge signaling as disposable; + unexpected Client close reaches the current Browser participant through the + existing fence. Windows + retains one converted frame for quiet-source keyframe recovery. Its live + profile gate keeps two PeerConnections while moving the hardware source from + 720p30 to 1440p60, then to 480p15 while paused, and resumes both Viewers; + current source-switch, static, and crash gates pass, while cross-version + capture remains open. + Local Client access is open by default and can be protected with a user-chosen + password from the Browser launcher; Hosted Site access is unchanged. + Non-Windows native media remains outside physical acceptance. The default + packaged Client launcher performs a non-blocking official GitHub Release check; + 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 -No source or deployment P0/P1 is open; sanitized route logging remains enabled. +Client acceptance remains open for the reported Windows 10 display startup and +game-specific source behavior. Windows 11 display, minimized-source recovery, +live presets and source-switch checks pass, but do not establish those reports +as resolved. NAT acquisition has controller and signaling coverage, not a new +public-network success-rate claim. Its added prepare progress field requires a +coordinated private-wire release: old strict pages cannot consume it safely. +Native codec and capability fields likewise require the matching bundled UI. +Production is unchanged; [TODO](./todo.md) owns acceptance and release work. diff --git a/docs/todo.md b/docs/todo.md index 94dccff..6f09563 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,44 +1,39 @@ # Current TODO Ledger -Last reviewed: 2026-09-04 +Last reviewed: 2026-09-05 Only **Now** is executable. Observations, old branches, experiments, and parked topics are not implementation authority. ## Now -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. +1. **Client physical acceptance.** Physically validate macOS and Linux + capture/audio/recovery, a decoded public-link Browser Viewer, and a NAT case + rescued beyond ordinary STUN, plus one mixed Browser/Native Viewer relay. +2. **Client owner acceptance.** Review the localized terminal, Browser capture + through Native fanout, live controls and Client-exit recovery before merging. + Confirm weak-path convergence with effective media throttling; a DevTools + command succeeding without limiting RTP is not a weak-path pass. +3. **Capture startup and controls.** Diagnose Windows 10 selecting an entire + display failing to start while window capture succeeds; do not conflate it + with exclusive-fullscreen game capture. Current Windows 11 checks cover + display startup, minimized-source recovery and live presets. Verify remaining + game HWND/device-loss behavior and physically compare the implemented Windows + display-aspect correction with the reported stretched game, including GPU + scaling not exposed by the active display path. Compare bitrate changes against + same-content delivery before changing encoder settings. +4. **NAT acquisition and release acceptance.** Validate the implemented bounded + three-attempt owner on restricted Browser/Native pairs; a new connection does + not guarantee a different NAT mapping. The added prepare progress field is + incompatible with old strict pages. Obtain owner approval for the coordinated + Web/Server/Client update and its active-share impact before merging/deploying; + do not mix the new field into an ongoing old-version share. +5. **Native codec acceptance.** Windows Native VP8 and Auto-selected H264 now + pass real Browser playback, live presets, paused changes and source-switch + checks while preserving the selected codec. Validate GPU-heavy Auto selection + and the tight four-second probe/five-second startup budget on other hardware; + do not claim throughput screening guarantees perceptual quality or later + load. Other native platform capture encoders remain H264. ## Parked Product Work @@ -58,22 +53,10 @@ topics are not implementation authority. Auto or move an unproved result into the availability path. Validate whether H.264 qualification can choose a slower VP8 fallback under mixed GPU/CPU load before adding comparative or dual-codec probing. -3. **Browser encoded-frame reuse.** After the current Browser experience release, - gate one narrow video-edge adapter that preserves signaling, routing, - ICE/TURN, DTLS, DataChannel congestion control, upstream WebRTC playback and - SFU fallback while reusing one encoded representation across every compatible - P2P descendant. First determine whether current Browser APIs or a mature - module can preserve per-edge adaptation without re-encoding; do not accept a - standard-WebRTC weak-edge fallback as the default merely because it is easy. - Compare one layered SVC encode with a small room-wide simulcast set before any - per-edge encoder; select layers from current edge capacity rather than - classifying Viewers into permanent strong/weak groups. - A strong/weak two-child gate must keep the strong path at full quality, - recover the weak path without globally lowering the shared representation, - bound queues and temporary copies, and measure CPU, bytes, latency, keyframe - recovery, A/V timing and supported mobile Browsers. Reuse existing quality - evidence and route operations; add no custom score, global worst-viewer rule, - periodic ladder or unproved codec representation. +3. **Browser encoded reuse.** Pure Browser operation keeps normal WebRTC + senders; users can use Client to reduce repeated encoding. Revisit Browser + reuse only for a small mature adapter that preserves adaptation and media + quality without an application-owned transport or layer controller. 4. **Control-plane resource fairness.** Calibrate one deployment-wide LiveKit control queue and simple HTTP/WebSocket ingress budgets before implementation; prove a self-hosted stale-token revocation boundary before independently @@ -116,10 +99,10 @@ topics are not implementation authority. Keep injected candidates additive and diagnostic; do not make them a participant-wide NAT label, hard candidate skip, route score, or SFU preference. Validate selected predicted paths and bounded resource impact - before recommending it as a default for other deployments. Direct - 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. + plus one selected Native mapped-port path that fails with STUN alone before + recommending either as a default for other deployments. Measure benefit of + the implemented three-attempt budget before increasing it or claiming that + independent connection attempts are independent NAT mappings. 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 @@ -141,10 +124,12 @@ topics are not implementation authority. 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 - -- Choose the repository and distribution license before public release or - package distribution. GPL/AGPL implementations remain research-only until - then. + persistence, or route-controller implementations. The + [consolidation draft](./research/server-consolidation.md) identifies the two + preparatory simplifications, the routing-mode product choice, and the shared + module and concurrency model; implementation follows Client acceptance. +17. **Opt-in diagnostics.** Replace ad hoc console logging with an explicit + Client/Server debug option and bounded diagnostic export. Include revision, + runtime state and sanitized capture/connection events; exclude credentials, + media and raw process memory by default. A compressed feedback bundle needs + deliberate collection and retention, not automatic uploads. diff --git a/docs/verification-status.md b/docs/verification-status.md index 41d2d83..c04bd9f 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -1,6 +1,6 @@ # Verification Status -Last updated: 2026-09-04 +Last updated: 2026-09-05 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 @@ -77,21 +77,26 @@ 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 +On Windows, Chrome 151.0.7922.175 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 +completed v8 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 current clean-revision Windows package assembled with Node 24.19.0 passed the +package checks recorded for that revision; changing `app/REVISION` made it fail +before opening a listener. Its fresh Site loopback gate closed the Client, +Browser, port, and temporary profile. 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. +matching Mach-O arm64 Client and Node binaries. Its prior fixed-profile capture +baseline and current variable-profile/audio sidecar compile on the macOS runner; +the latter passes the synthetic hardware-H.264 IDR self-test. The full package +and ScreenCaptureKit video/audio path have not run on a physical Mac. Linux CI +also compiles, probes, packages, starts, and stops its Portal/PipeWire/system- +GStreamer hardware-H.264 adapter; it has not captured on a physical desktop. Still required are physical second-device LAN playback, first-run LNA prompt, no-STUN mDNS behavior, macOS package execution, and equivalent non-Windows @@ -105,16 +110,24 @@ 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. +frames. The same product gate changes a live Native Host from default 1080p30 +to 1440p30, then changes resolution while paused and resumes the same Viewer +media object through the Host UI. A separate two-Viewer media gate changes +720p30 to 1440p60, then changes to 480p15 while paused and resumes both +connections. 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. +path now have bounded gates. A focused unit gate proves that a quality candidate +from a current Native sender edge selects the stock Browser sender, while ordinary +candidates remain Native. Controlled weak-path commit/rollback, production +package integration, SFU recovery and endurance, and physical non-Windows +capture remain unproved. A five-second headless topology run with 20 Viewers passed the existing route capacity, no-orphan-publication, quality-propagation, and decoded-frame checks; it is topology evidence, not a long-running or public-network quality claim. -An isolated LiveKit Server 1.13.6 run also delivered 30 decoded 1280x720 native -frames through the existing Browser SFU publisher and completed cleanup. +An isolated LiveKit Server 1.13.6 run also delivered default 1080p native media, +then 15 decoded 854x480 frames after a live source/publisher profile change, +through the existing Browser SFU publisher and completed cleanup. 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 @@ -124,6 +137,27 @@ 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. +On 2026-09-05, a local Windows gate proved a Browser Host feeding a Client- +activated Native Viewer: the Viewer held the v8 control session while Chrome +decoded 621 frames at 1280x720. A separate Pion integration gate proves that the +same inbound source forwards H.264 and Opus RTP to one downstream edge without +re-encoding. A physical mixed-device relay and public-link Browser Viewer remain +open. + +On 2026-09-05, a Native preflight showed that Pion's ordinary srflx gatherer +used three temporary local ports for three public STUN destinations. The current +Universal UDP mux adapter instead emitted an srflx observation related to the +Engine's sole media port. A public-link run then delivered 35 H.264 RTP packets +from that Windows Native Host to the independent Linux Pion Viewer over a +selected direct host-to-srflx pair. The active TUN path exposed only one distinct +mapping, so this proves shared-socket discovery and transport, not prediction. + +On 2026-09-04, a bounded Windows physical check held a static Notepad source +open beyond seven seconds, produced a requested recovery keyframe from its +retained image, and exited cleanly. The Native Host crash gate also terminated the +Client process and observed the Host return to its start-share control within +the bounded check. These results cover the current Windows build only. + 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; @@ -131,8 +165,18 @@ revision mismatch remains fail-closed before a listener starts. ## Interpretation Rules +The current Windows Native candidate additionally passed manual VP8 and Auto- +selected H264 playback, with actual Host/Viewer RTP codec checks across live +presets, paused changes and source replacement. A three-attempt NAT acquisition +has deterministic controller/signaling coverage, not a field success-rate +claim. The completed checks do not resolve the reported Windows 10 monitor +startup or game-specific aspect/window-replacement issues. The matching bundle +must be accepted before any coordinated private-wire deployment. + - Configuration, unit tests, loopback, and synthetic signaling prove invariants, not target-network or device behavior. +- An automated/package gate is evidence from its scripted runner; a physical + gate requires the named target device, network, or desktop environment. - Missing RTCStats fields are unknown, not zero. - Requested quality values are ceilings; actual sender/receiver stats are truth. - A page-lifecycle correction is recovery logic, not a keepalive guarantee. diff --git a/licenses/README.md b/licenses/README.md new file mode 100644 index 0000000..f540b2e --- /dev/null +++ b/licenses/README.md @@ -0,0 +1,22 @@ +# Third-Party Licenses + +Screener-owned code uses the root [MIT license](../LICENSE). Dependencies retain +their own licenses; MIT does not replace their copyright notices or terms. + +The [pinned source index](./upstream.json) records exact upstream commits and +SHA-256 values for Node, Cloudflared, and npm packages that omit license files. +The retained text normalizes line endings and incidental whitespace only. +The Cloudflared text includes its source distribution's root and all vendored +LICENSE, COPYING, and NOTICE files, including platform variants. The protobuf +text also preserves Google's BSD-licensed varint attribution. +The Windows capture adapter statically links libvpx; its copyright, patent grant +and author list are included in the Client's native notices. + +`npm run dev` and `npm run build:client` generate `/third-party-licenses.txt` +offline from installed, lockfile-matched Web/Server runtime dependencies. +Client assembly adds licenses for the Go toolchain and actually compiled module +packages, Node's full same-version license, and the pinned Cloudflared notices. +Missing license texts or changed pinned contents stop packaging. + +Update the corresponding source text and index when a pinned runtime changes. +No dependency source tree, plugin, or license-scanning dependency is bundled. diff --git a/public/third-party-licenses.txt b/licenses/bufbuild-protobuf-1.10.1.txt similarity index 85% rename from public/third-party-licenses.txt rename to licenses/bufbuild-protobuf-1.10.1.txt index ffba06d..ba201ba 100644 --- a/public/third-party-licenses.txt +++ b/licenses/bufbuild-protobuf-1.10.1.txt @@ -1,14 +1,3 @@ -Third-party software notices -============================ - -This distribution includes: - -- livekit-client 2.22.0 (https://github.com/livekit/client-sdk-js) -- livekit-server-sdk 2.17.0 (https://github.com/livekit/node-sdks) - -Both packages are licensed under the Apache License, Version 2.0. Neither -package includes a separate NOTICE file in the installed distribution. - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -197,7 +186,7 @@ package includes a separate NOTICE file in the installed distribution. same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2021-2023 Buf Technologies, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -210,3 +199,39 @@ package includes a separate NOTICE file in the installed distribution. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +===== packages/protobuf/src/google/varint.ts license header ===== +Source: https://github.com/bufbuild/protobuf-es/blob/e31c1b139cf1e7fea2fb914f650af3c28c42ec68/packages/protobuf/src/google/varint.ts + +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. +* Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. diff --git a/licenses/cloudflared-2026.8.3.txt b/licenses/cloudflared-2026.8.3.txt new file mode 100644 index 0000000..319ca30 --- /dev/null +++ b/licenses/cloudflared-2026.8.3.txt @@ -0,0 +1,2685 @@ +Cloudflared 2026.8.3 +Source: https://github.com/cloudflare/cloudflared/tree/fe70e951a3c52d92abf9f6c4248e32937b2f42fc + +Root and vendored license/notice texts from the pinned source distribution, +including platform variants. All 89 upstream files are represented below; +identical texts share one section without dropping any source attribution. + +===== License text 1 ===== + +Applies to: +- LICENSE +- vendor/github.com/go-jose/go-jose/v4/LICENSE +- vendor/github.com/go-logr/stdr/LICENSE +- vendor/github.com/modern-go/concurrent/LICENSE +- vendor/github.com/modern-go/reflect2/LICENSE +- vendor/github.com/prometheus/client_golang/LICENSE +- vendor/github.com/prometheus/client_model/LICENSE +- vendor/github.com/prometheus/common/LICENSE +- vendor/github.com/prometheus/procfs/LICENSE +- vendor/github.com/tklauser/numcpus/LICENSE +- vendor/go.opentelemetry.io/auto/sdk/LICENSE +- vendor/go.opentelemetry.io/contrib/propagators/LICENSE +- vendor/go.opentelemetry.io/proto/otlp/LICENSE +- vendor/go.uber.org/mock/LICENSE +- vendor/google.golang.org/genproto/googleapis/api/LICENSE +- vendor/google.golang.org/genproto/googleapis/rpc/LICENSE +- vendor/google.golang.org/grpc/LICENSE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +===== License text 2 ===== + +Applies to: +- vendor/github.com/beorn7/perks/LICENSE + +Copyright (C) 2013 Blake Mizerany + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 3 ===== + +Applies to: +- vendor/github.com/BurntSushi/toml/COPYING + +The MIT License (MIT) + +Copyright (c) 2013 TOML authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +===== License text 4 ===== + +Applies to: +- vendor/github.com/cespare/xxhash/v2/LICENSE.txt + +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 5 ===== + +Applies to: +- vendor/github.com/cloudflare/backoff/LICENSE + +Copyright (c) 2016 CloudFlare Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 6 ===== + +Applies to: +- vendor/github.com/coreos/go-oidc/v3/LICENSE +- vendor/github.com/ebitengine/purego/LICENSE +- vendor/github.com/go-logr/logr/LICENSE +- vendor/gopkg.in/yaml.v2/LICENSE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +===== License text 7 ===== + +Applies to: +- vendor/github.com/coreos/go-oidc/v3/NOTICE + +CoreOS Project +Copyright 2014 CoreOS, Inc + +This product includes software developed at CoreOS, Inc. +(http://www.coreos.com/). + +===== License text 8 ===== + +Applies to: +- vendor/github.com/coreos/go-systemd/v22/LICENSE + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +===== License text 9 ===== + +Applies to: +- vendor/github.com/coreos/go-systemd/v22/NOTICE + +CoreOS Project +Copyright 2018 CoreOS, Inc + +This product includes software developed at CoreOS, Inc. +(http://www.coreos.com/). + +===== License text 10 ===== + +Applies to: +- vendor/github.com/cpuguy83/go-md2man/v2/LICENSE.md + +The MIT License (MIT) + +Copyright (c) 2014 Brian Goff + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 11 ===== + +Applies to: +- vendor/github.com/davecgh/go-spew/LICENSE + +ISC License + +Copyright (c) 2012-2016 Dave Collins + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +===== License text 12 ===== + +Applies to: +- vendor/github.com/fortytw2/leaktest/LICENSE +- vendor/github.com/go-jose/go-jose/v4/json/LICENSE + +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 13 ===== + +Applies to: +- vendor/github.com/fsnotify/fsnotify/LICENSE + +Copyright (c) 2012 The Go Authors. All rights reserved. +Copyright (c) 2012-2019 fsnotify Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 14 ===== + +Applies to: +- vendor/github.com/getsentry/sentry-go/LICENSE + +MIT License + +Copyright (c) 2019 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 15 ===== + +Applies to: +- vendor/github.com/go-chi/chi/v5/LICENSE + +Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 16 ===== + +Applies to: +- vendor/github.com/go-chi/cors/LICENSE + +Copyright (c) 2014 Olivier Poitrey +Copyright (c) 2016-Present https://github.com/go-chi authors + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 17 ===== + +Applies to: +- vendor/github.com/go-ole/go-ole/LICENSE + +The MIT License (MIT) + +Copyright © 2013-2017 Yasuhiro Matsumoto, + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 18 ===== + +Applies to: +- vendor/github.com/gobwas/httphead/LICENSE + +The MIT License (MIT) + +Copyright (c) 2017 Sergey Kamardin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 19 ===== + +Applies to: +- vendor/github.com/gobwas/pool/LICENSE + +The MIT License (MIT) + +Copyright (c) 2017-2019 Sergey Kamardin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 20 ===== + +Applies to: +- vendor/github.com/gobwas/ws/LICENSE + +The MIT License (MIT) + +Copyright (c) 2017-2021 Sergey Kamardin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 21 ===== + +Applies to: +- vendor/github.com/google/gopacket/LICENSE + +Copyright (c) 2012 Google, Inc. All rights reserved. +Copyright (c) 2009-2011 Andreas Krennmair. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Andreas Krennmair, Google, nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 22 ===== + +Applies to: +- vendor/github.com/google/uuid/LICENSE + +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 23 ===== + +Applies to: +- vendor/github.com/gorilla/websocket/LICENSE + +Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 24 ===== + +Applies to: +- vendor/github.com/grpc-ecosystem/grpc-gateway/v2/LICENSE + +Copyright (c) 2015, Gengo, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of Gengo, Inc. nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 25 ===== + +Applies to: +- vendor/github.com/json-iterator/go/LICENSE + +MIT License + +Copyright (c) 2016 json-iterator + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 26 ===== + +Applies to: +- vendor/github.com/klauspost/compress/LICENSE + +Copyright (c) 2012 The Go Authors. All rights reserved. +Copyright (c) 2019 Klaus Post. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------ + +Files: gzhttp/* + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017 The New York Times Company + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +------------------ + +Files: s2/cmd/internal/readahead/* + +The MIT License (MIT) + +Copyright (c) 2015 Klaus Post + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--------------------- +Files: snappy/* +Files: internal/snapref/* + +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----------------- + +Files: s2/cmd/internal/filepathx/* + +Copyright 2016 The filepathx Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 27 ===== + +Applies to: +- vendor/github.com/lufia/plan9stats/LICENSE + +BSD 3-Clause License + +Copyright (c) 2019, KADOTA, Kyohei +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 28 ===== + +Applies to: +- vendor/github.com/mattn/go-colorable/LICENSE + +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 29 ===== + +Applies to: +- vendor/github.com/mattn/go-isatty/LICENSE + +Copyright (c) Yasuhiro MATSUMOTO + +MIT License (Expat) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 30 ===== + +Applies to: +- vendor/github.com/mitchellh/go-homedir/LICENSE + +The MIT License (MIT) + +Copyright (c) 2013 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +===== License text 31 ===== + +Applies to: +- vendor/github.com/munnerz/goautoneg/LICENSE + +Copyright (c) 2011, Open Knowledge Foundation Ltd. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + Neither the name of the Open Knowledge Foundation Ltd. nor the + names of its contributors may be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 32 ===== + +Applies to: +- vendor/github.com/pkg/errors/LICENSE + +Copyright (c) 2015, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 33 ===== + +Applies to: +- vendor/github.com/pmezard/go-difflib/LICENSE + +Copyright (c) 2013, Patrick Mezard +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + The names of its contributors may not be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 34 ===== + +Applies to: +- vendor/github.com/power-devops/perfstat/LICENSE + +MIT License + +Copyright (c) 2020 Power DevOps + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 35 ===== + +Applies to: +- vendor/github.com/prometheus/client_golang/internal/github.com/golang/gddo/LICENSE + +Copyright (c) 2013 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 36 ===== + +Applies to: +- vendor/github.com/prometheus/client_golang/NOTICE + +Prometheus instrumentation library for Go applications +Copyright 2012-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + + +The following components are included in this product: + +perks - a fork of https://github.com/bmizerany/perks +https://github.com/beorn7/perks +Copyright 2013-2015 Blake Mizerany, Björn Rabenstein +See https://github.com/beorn7/perks/blob/master/README.md for license details. + +Go support for Protocol Buffers - Google's data interchange format +http://github.com/golang/protobuf/ +Copyright 2010 The Go Authors +See source code for license details. + +===== License text 37 ===== + +Applies to: +- vendor/github.com/prometheus/client_model/NOTICE + +Data model artifacts for Prometheus. +Copyright 2012-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + +===== License text 38 ===== + +Applies to: +- vendor/github.com/prometheus/common/NOTICE + +Common libraries shared by Prometheus Go components. +Copyright 2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + +===== License text 39 ===== + +Applies to: +- vendor/github.com/prometheus/procfs/NOTICE + +procfs provides functions to retrieve system, kernel and process +metrics from the pseudo-filesystem proc. + +Copyright 2014-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + +===== License text 40 ===== + +Applies to: +- vendor/github.com/quic-go/quic-go/LICENSE + +MIT License + +Copyright (c) 2016 the quic-go authors & Google, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 41 ===== + +Applies to: +- vendor/github.com/rs/zerolog/LICENSE + +MIT License + +Copyright (c) 2017 Olivier Poitrey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 42 ===== + +Applies to: +- vendor/github.com/russross/blackfriday/v2/LICENSE.txt + +Blackfriday is distributed under the Simplified BSD License: + +> Copyright © 2011 Russ Ross +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions +> are met: +> +> 1. Redistributions of source code must retain the above copyright +> notice, this list of conditions and the following disclaimer. +> +> 2. Redistributions in binary form must reproduce the above +> copyright notice, this list of conditions and the following +> disclaimer in the documentation and/or other materials provided with +> the distribution. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +> "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +> LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +> FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +> COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +> INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +> LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +> ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +> POSSIBILITY OF SUCH DAMAGE. + +===== License text 43 ===== + +Applies to: +- vendor/github.com/shirou/gopsutil/v4/LICENSE + +gopsutil is distributed under BSD license reproduced below. + +Copyright (c) 2014, WAKAYAMA Shirou +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the gopsutil authors nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------- +internal/common/binary.go in the gopsutil is copied and modified from golang/encoding/binary.go. + + + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 44 ===== + +Applies to: +- vendor/github.com/stretchr/testify/LICENSE + +MIT License + +Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 45 ===== + +Applies to: +- vendor/github.com/tklauser/go-sysconf/LICENSE + +BSD 3-Clause License + +Copyright (c) 2018-2022, Tobias Klauser +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 46 ===== + +Applies to: +- vendor/github.com/urfave/cli/v2/LICENSE + +MIT License + +Copyright (c) 2016 Jeremy Saenz & Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 47 ===== + +Applies to: +- vendor/github.com/yusufpapurcu/wmi/LICENSE + +The MIT License (MIT) + +Copyright (c) 2013 Stack Exchange + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== License text 48 ===== + +Applies to: +- vendor/go.opentelemetry.io/otel/exporters/otlp/otlptrace/LICENSE +- vendor/go.opentelemetry.io/otel/LICENSE +- vendor/go.opentelemetry.io/otel/metric/LICENSE +- vendor/go.opentelemetry.io/otel/sdk/LICENSE +- vendor/go.opentelemetry.io/otel/trace/LICENSE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 49 ===== + +Applies to: +- vendor/go.uber.org/automaxprocs/LICENSE + +Copyright (c) 2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +===== License text 50 ===== + +Applies to: +- vendor/golang.org/x/crypto/LICENSE +- vendor/golang.org/x/net/LICENSE +- vendor/golang.org/x/oauth2/LICENSE +- vendor/golang.org/x/sync/LICENSE +- vendor/golang.org/x/sys/LICENSE +- vendor/golang.org/x/term/LICENSE +- vendor/golang.org/x/text/LICENSE + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 51 ===== + +Applies to: +- vendor/google.golang.org/grpc/NOTICE.txt + +Copyright 2014 gRPC authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +===== License text 52 ===== + +Applies to: +- vendor/google.golang.org/protobuf/LICENSE + +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== License text 53 ===== + +Applies to: +- vendor/gopkg.in/natefinch/lumberjack.v2/LICENSE + +The MIT License (MIT) + +Copyright (c) 2014 Nate Finch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 54 ===== + +Applies to: +- vendor/gopkg.in/yaml.v2/LICENSE.libyaml + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original copyright and license: + + apic.go + emitterc.go + parserc.go + readerc.go + scannerc.go + writerc.go + yamlh.go + yamlprivateh.go + +Copyright (c) 2006 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 55 ===== + +Applies to: +- vendor/gopkg.in/yaml.v2/NOTICE +- vendor/gopkg.in/yaml.v3/NOTICE + +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +===== License text 56 ===== + +Applies to: +- vendor/gopkg.in/yaml.v3/LICENSE + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +===== License text 57 ===== + +Applies to: +- vendor/nhooyr.io/websocket/LICENSE.txt + +MIT License + +Copyright (c) 2018 Anmol Sethi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +===== License text 58 ===== + +Applies to: +- vendor/zombiezen.com/go/capnproto2/LICENSE + +go-capnproto is licensed under the terms of the MIT license reproduced below. + +=============================================================================== + +Copyright (C) 2014 the go-capnproto authors and contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +=============================================================================== diff --git a/licenses/libvpx-1.17.0.txt b/licenses/libvpx-1.17.0.txt new file mode 100644 index 0000000..3e98776 --- /dev/null +++ b/licenses/libvpx-1.17.0.txt @@ -0,0 +1,327 @@ +libvpx 1.17.0 +Source: https://github.com/webmproject/libvpx/tree/6df3ec34557879fff673706f4a1d9fbd0f3a6f0e + +===== LICENSE ===== + +Copyright (c) 2010, The WebM Project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google, nor the WebM Project, nor the names + of its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +===== PATENTS ===== + +Additional IP Rights Grant (Patents) +------------------------------------ + +"These implementations" means the copyrightable works that implement the WebM +codecs distributed by Google as part of the WebM Project. + +Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this section) patent license to +make, have made, use, offer to sell, sell, import, transfer, and otherwise +run, modify and propagate the contents of these implementations of WebM, where +such license applies only to those patent claims, both currently owned by +Google and acquired in the future, licensable by Google that are necessarily +infringed by these implementations of WebM. This grant does not include claims +that would be infringed only as a consequence of further modification of these +implementations. If you or your agent or exclusive licensee institute or order +or agree to the institution of patent litigation or any other patent +enforcement activity against any entity (including a cross-claim or +counterclaim in a lawsuit) alleging that any of these implementations of WebM +or any code incorporated within any of these implementations of WebM +constitute direct or contributory patent infringement, or inducement of +patent infringement, then any patent rights granted to you under this License +for these implementations of WebM shall terminate as of the date such +litigation is filed. + +===== AUTHORS ===== + +# This file is automatically generated from the git commit history +# by tools/gen_authors.sh. + +Aaron Watry +Abo Talib Mahfoodh +Adam B. Goode +Adrian Grange +Ahmad Sharif +Aidan Welch +Aleksey Vasenev +Alexander Potapenko +Alexander Voronov +Alexandra Hájková +Aℓex Converse +Alex Davicenko +Alexis Ballier +Alok Ahuja +Alpha Lam +A.Mahfoodh +Ami Fischman +Andoni Morales Alastruey +Andres Calderon Jaramillo +Andres Mejia +Andrew Lewis +Andrew Russell +Andrew Salkeld +Andrey Filipenkov +Angie Chen +Angie Chiang +Anton Venema +Anupam Pandey +Aron Rosenberg +Attila Nagy +Birk Magnussen +Bohan Li +Brian Foley +Brion Vibber +Casey Smalley +changjun.yang +Charles 'Buck' Krasic +Cheng Chen +Chen Wang +Cherma Rajan A +Chi Yo Tsai +chm +Chris Cunningham +Christian Duvivier +Chunbo Hua +Chun-Min Chang +Clement Courbet +CodeByMoriarty +Daniel Cheng +Daniele Castagna +Daniel Kang +Daniel Sommermann +Dan Zhu +Darryl Pogue +David Benjamin +Deb Mukherjee +Deepa K G +Diksha Singh +Dim Temp +Dmitry Kovalev +Dragan Mrdjan +Ed Baker +Ehsan Akhgari +Elliott Karpilovsky +Erik Niemeyer +Fabio Pedretti +Feng, Feifei +Florian Mayer +Frank Galligan +Fredrik Söderquist +Fritz Koenig +Fyodor Kyslov +Gabriel Marin +Gaute Strokkenes +George Steed +Gerda Zsejke More +Geza Lore +Ghislain MARY +Giuseppe Scrivano +Gordana Cmiljanovic +Gregor Jasny +Guillaume Martres +Guillermo Ballester Valor +Hang Nguyen +Hangyu Kuang +Hanno Böck +Han Shen +Hao Chen +Hari Limaye +Harish Mahendrakar +Henrik Lundin +Hien Ho +Hirokazu Honda +Hui Su +Ilya Kurdyukov +Ivan Krasin +Ivan Maltz +Jacek Caban +Jacky Chen +James Berry +James Touton +James Yu +James Zern +Jan Gerber +Jan-Ivar Bruaroey +Jan Kratochvil +Janne Salonen +Jean-Yves Avenard +Jeff Faust +Jeff Muizelaar +Jeff Petkau +Jeremy Dorfman +Jeremy Leconte +Jerome Jiang +Jia Jia +Jianhui Dai +Jian Zhou +Jim Bankoski +jinbo +Jin Bo +Jingning Han +Joel Fernandes +Joey Parrish +Johann +Johann Koenig +John Koleszar +Johnny Klonaris +John Stark +Jonathan Wright +Jon Kunkee +Jorge E. Moreira +Joshua Bleecher Snyder +Joshua Litt +Julia Robson +Justin Clift +Justin Lebar +Kaustubh Raste +Kexy Biscuit +KO Myung-Hun +Konstantinos Margaritis +Kyle Siefring +Lawrence Velázquez +L. E. Segovia +Linfeng Zhang +Lin Zheng +Liu Peng +Li Zhang +Lou Quillio +Luca Barbato +Luc Trudeau +Lu Wang +Makoto Kato +Mans Rullgard +Marco Paniconi +Mark Mentovai +Martin Ettl +Martin Storsjö +Matthew Heaney +Matthias Räncker +Michael Horowitz +Michael Kohler +Michał Janiszewski +Mike Frysinger +Mike Hommey +Mikhal Shemer +Mikko Koivisto +Min Chen +Minghai Shang +Min Ye +Mirko Bonadei +Moriyoshi Koizumi +Morton Jonuschat +Nathan E. Egge +Neeraj Gadgil +Neil Birkbeck +Nico Weber +Niveditha Rau +Parag Salasakar +Pascal Massimino +Patrik Westin +Paul Wilkins +Pavol Rusnak +Paweł Hajdan +Pengchong Jin +Peter Boström +Peter Collingbourne +Peter de Rivaz +Peter Kasting +Philip Jägenstedt +Philippe Antoine +Philipp Hancke +Priit Laes +Rafael Ávila de Espíndola +Rafaël Carré +Rafael de Lucena Valle +Rahul Chaudhry +Ralph Giles +Ram Mohan M +Ranjit Kumar Tulabandu +Raphael Kubo da Costa +Ravi Chaudhary +Ritu Baldwa +Rob Bradford +Ronald S. Bultje +Rui Ueyama +Sai Deng +Salome Thirot +Sami Pietilä +Sam James +Sarah Parker +Sasi Inguva +Scott Graham +Scott LaVarnway +Sean McGovern +Sergey Kolomenkin +Sergey Silkin +Sergey Ulanov +Shimon Doodkin +Shiyou Yin +Shubham Tandle +Shunyao Li +Sreerenj Balachandran +Stefan Holmer +Suman Sunkara +Supradeep T R +Sylvestre Ledru +Taekhyun Kim +Takanori MATSUURA +Tamar Levy +Tao Bai +Tero Rintaluoma +Thijs Vermeir +Tim Kopp +Timothy B. Terriberry +Tom Finegan +Tristan Matthews +Urvang Joshi +Venkatarama NG. Avadhani +Vignesh Venkatasubramanian +Vitaly Buka +Vlad Tsyrklevich +Wan-Teh Chang +Wonkap Jang +Xiahong Bao +Xiwei Gu +Yaowu Xu +Yi Luo +Yongseok Jeon +Yongzhe Wang +yuanhecai +Yue Chen +Yun Liu +Yunqing Wang +Yury Gitman +Zoe Liu +Zoltan Kuscsik +zuxy +Google Inc. +The Mozilla Foundation +The Xiph.Org Foundation diff --git a/licenses/node-24.19.0.txt b/licenses/node-24.19.0.txt new file mode 100644 index 0000000..e286d43 --- /dev/null +++ b/licenses/node-24.19.0.txt @@ -0,0 +1,2946 @@ +Node.js is licensed for use as follows: + +""" +Copyright Node.js contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +""" + +This license applies to parts of Node.js originating from the +https://github.com/joyent/node repository: + +""" +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +""" + +The Node.js license applies to all parts of Node.js that are not externally +maintained libraries. + +The externally maintained libraries used by Node.js are: + +- Acorn, located at deps/acorn, is licensed as follows: + """ + MIT License + + Copyright (C) 2012-2022 by various contributors (see AUTHORS) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + """ + +- c-ares, located at deps/cares, is licensed as follows: + """ + MIT License + + Copyright (c) 1998 Massachusetts Institute of Technology + Copyright (c) 2007 - 2023 Daniel Stenberg with many contributors, see AUTHORS + file. + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- merve, located at deps/merve, is licensed as follows: + """ + Copyright 2026 Yagiz Nizipli + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- ittapi, located at deps/v8/third_party/ittapi, is licensed as follows: + """ + Copyright (c) 2019 Intel Corporation. All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- amaro, located at deps/amaro, is licensed as follows: + """ + MIT License + + Copyright (c) Marco Ippolito and Amaro contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- swc, located at deps/amaro/dist, is licensed as follows: + """ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 SWC contributors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + +- ICU, located at deps/icu-small, is licensed as follows: + """ + UNICODE LICENSE V3 + + COPYRIGHT AND PERMISSION NOTICE + + Copyright © 2016-2025 Unicode, Inc. + + NOTICE TO USER: Carefully read the following legal agreement. BY + DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR + SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE + TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT + DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of data files and any associated documentation (the "Data Files") or + software and any associated documentation (the "Software") to deal in the + Data Files or Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, and/or sell + copies of the Data Files or Software, and to permit persons to whom the + Data Files or Software are furnished to do so, provided that either (a) + this copyright and permission notice appear with all copies of the Data + Files or Software, or (b) this copyright and permission notice appear in + associated Documentation. + + THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF + THIRD PARTY RIGHTS. + + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE + BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, + OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA + FILES OR SOFTWARE. + + Except as contained in this notice, the name of a copyright holder shall + not be used in advertising or otherwise to promote the sale, use or other + dealings in these Data Files or Software without prior written + authorization of the copyright holder. + + SPDX-License-Identifier: Unicode-3.0 + + ---------------------------------------------------------------------- + + Third-Party Software Licenses + + This section contains third-party software notices and/or additional + terms for licensed third-party software components included within ICU + libraries. + + ---------------------------------------------------------------------- + + ICU License - ICU 1.8.1 to ICU 57.1 + + COPYRIGHT AND PERMISSION NOTICE + + Copyright (c) 1995-2016 International Business Machines Corporation and others + All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, and/or sell copies of the Software, and to permit persons + to whom the Software is furnished to do so, provided that the above + copyright notice(s) and this permission notice appear in all copies of + the Software and that both the above copyright notice(s) and this + permission notice appear in supporting documentation. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY + SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER + RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF + CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + Except as contained in this notice, the name of a copyright holder + shall not be used in advertising or otherwise to promote the sale, use + or other dealings in this Software without prior written authorization + of the copyright holder. + + All trademarks and registered trademarks mentioned herein are the + property of their respective owners. + + ---------------------------------------------------------------------- + + Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + + ---------------------------------------------------------------------- + + Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (C) 2016 and later: Unicode, Inc. and others. + # License & terms of use: http://www.unicode.org/copyright.html + # Copyright (c) 2015 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://github.com/rober42539/lao-dictionary + # Dictionary: https://github.com/rober42539/lao-dictionary/laodict.txt + # License: https://github.com/rober42539/lao-dictionary/LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary version of Nov 22, 2020 + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in binary + # form must reproduce the above copyright notice, this list of conditions and + # the following disclaimer in the documentation and/or other materials + # provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + + ---------------------------------------------------------------------- + + Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + + ---------------------------------------------------------------------- + + Time Zone Database + + ICU uses the public domain data and code derived from Time Zone + Database for its time zone support. The ownership of the TZ database + is explained in BCP 175: Procedure for Maintaining the Time Zone + Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + + ---------------------------------------------------------------------- + + Google double-conversion + + Copyright 2006-2011, the V8 project authors. All rights reserved. + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ---------------------------------------------------------------------- + + JSON parsing library (nlohmann/json) + + File: vendor/json/upstream/single_include/nlohmann/json.hpp (only for ICU4C) + + MIT License + + Copyright (c) 2013-2022 Niels Lohmann + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + ---------------------------------------------------------------------- + + File: aclocal.m4 (only for ICU4C) + Section: pkg.m4 - Macros to locate and utilise pkg-config. + + Copyright © 2004 Scott James Remnant . + Copyright © 2012-2015 Dan Nicholson + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA + 02111-1307, USA. + + As a special exception to the GNU General Public License, if you + distribute this file as part of a program that contains a + configuration script generated by Autoconf, you may include it under + the same distribution terms that you use for the rest of that + program. + + (The condition for the exception is fulfilled because + ICU4C includes a configuration script generated by Autoconf, + namely the `configure` script.) + + ---------------------------------------------------------------------- + + File: config.guess (only for ICU4C) + + This file is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, see . + + As a special exception to the GNU General Public License, if you + distribute this file as part of a program that contains a + configuration script generated by Autoconf, you may include it under + the same distribution terms that you use for the rest of that + program. This Exception is an additional permission under section 7 + of the GNU General Public License, version 3 ("GPLv3"). + + (The condition for the exception is fulfilled because + ICU4C includes a configuration script generated by Autoconf, + namely the `configure` script.) + + ---------------------------------------------------------------------- + + File: install-sh (only for ICU4C) + + Copyright 1991 by the Massachusetts Institute of Technology + + Permission to use, copy, modify, distribute, and sell this software and its + documentation for any purpose is hereby granted without fee, provided that + the above copyright notice appear in all copies and that both that + copyright notice and this permission notice appear in supporting + documentation, and that the name of M.I.T. not be used in advertising or + publicity pertaining to distribution of the software without specific, + written prior permission. M.I.T. makes no representations about the + suitability of this software for any purpose. It is provided "as is" + without express or implied warranty. + + ---------------------------------------------------------------------- + + File: sorttable.js (only for ICU4J) + + The MIT Licence, for code from kryogenix.org + + Code downloaded from the Browser Experiments section of kryogenix.org is + licenced under the so-called MIT licence. The licence is below. + + Copyright (c) 1997-date Stuart Langridge + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- libuv, located at deps/uv, is licensed as follows: + """ + Copyright (c) 2015-present libuv project contributors. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + This license applies to parts of libuv originating from the + https://github.com/joyent/libuv repository: + + ==== + + Copyright Joyent, Inc. and other Node contributors. All rights reserved. + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + + ==== + + This license applies to all parts of libuv that are not externally + maintained libraries. + + The externally maintained libraries used by libuv are: + + - tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license. + + - inet_pton and inet_ntop implementations, contained in src/inet.c, are + copyright the Internet Systems Consortium, Inc., and licensed under the ISC + license. + """ + +- LIEF, located at deps/LIEF, is licensed as follows: + """ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 - 2025 R. Thomas + Copyright 2017 - 2025 Quarkslab + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + +- llhttp, located at deps/llhttp, is licensed as follows: + """ + This software is licensed under the MIT License. + + Copyright Fedor Indutny, 2018. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit + persons to whom the Software is furnished to do so, subject to the + following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- corepack, located at deps/corepack, is licensed as follows: + """ + **Copyright © Corepack contributors** + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- undici, located at deps/undici, is licensed as follows: + """ + MIT License + + Copyright (c) Matteo Collina and Undici contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- postject, located at test/fixtures/postject-copy, is licensed as follows: + """ + Postject is licensed for use as follows: + + """ + MIT License + + Copyright (c) 2022 Postman, Inc + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + + The Postject license applies to all parts of Postject that are not externally + maintained libraries. + + The externally maintained libraries used by Postject are: + + - LIEF, located at vendor/LIEF, is licensed as follows: + """ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 - 2022 R. Thomas + Copyright 2017 - 2022 Quarkslab + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + """ + +- OpenSSL, located at deps/openssl, is licensed as follows: + """ + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + """ + +- Punycode.js, located at lib/punycode.js, is licensed as follows: + """ + Copyright Mathias Bynens + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- V8, located at deps/v8, is licensed as follows: + """ + This license applies to all parts of V8 that are not externally + maintained libraries. The externally maintained libraries used by V8 + are: + + - PCRE test suite, located in + test/mjsunit/third_party/regexp-pcre/regexp-pcre.js. This is based on the + test suite from PCRE-7.3, which is copyrighted by the University + of Cambridge and Google, Inc. The copyright notice and license + are embedded in regexp-pcre.js. + + - Layout tests, located in test/mjsunit/third_party/object-keys. These are + based on layout tests from webkit.org which are copyrighted by + Apple Computer, Inc. and released under a 3-clause BSD license. + + - Strongtalk assembler, the basis of the files assembler-arm-inl.h, + assembler-arm.cc, assembler-arm.h, assembler-ia32-inl.h, + assembler-ia32.cc, assembler-ia32.h, assembler-x64-inl.h, + assembler-x64.cc, assembler-x64.h, assembler.cc and assembler.h. + This code is copyrighted by Sun Microsystems Inc. and released + under a 3-clause BSD license. + + - Valgrind client API header, located at third_party/valgrind/valgrind.h + This is released under the BSD license. + + - The Wasm C/C++ API headers, located at third_party/wasm-api/wasm.{h,hh} + This is released under the Apache license. The API's upstream prototype + implementation also formed the basis of V8's implementation in + src/wasm/c-api.cc. + + These libraries have their own licenses; we recommend you read them, + as their terms may differ from the terms below. + + Further license information can be found in LICENSE files located in + sub-directories. + + Copyright 2014, the V8 project authors. All rights reserved. + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- SipHash, located at deps/v8/src/third_party/siphash, is licensed as follows: + """ + SipHash reference C implementation + + Copyright (c) 2016 Jean-Philippe Aumasson + + To the extent possible under law, the author(s) have dedicated all + copyright and related and neighboring rights to this software to the public + domain worldwide. This software is distributed without any warranty. + """ + +- zlib, located at deps/zlib, is licensed as follows: + """ + zlib.h -- interface of the 'zlib' general purpose compression library + version 1.3.1, January 22nd, 2024 + + Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu + """ + +- simdjson, located at deps/simdjson, is licensed as follows: + """ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2025 The simdjson authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + +- simdutf, located at deps/v8/third_party/simdutf, is licensed as follows: + """ + Copyright 2021 The simdutf authors + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- ada, located at deps/ada, is licensed as follows: + """ + Copyright 2023 Yagiz Nizipli and Daniel Lemire + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- minimatch, located at deps/minimatch, is licensed as follows: + """ + # Blue Oak Model License + + Version 1.0.0 + + ## Purpose + + This license gives everyone as much permission to work with + this software as possible, while protecting contributors + from liability. + + ## Acceptance + + In order to receive this license, you must agree to its + rules. The rules of this license are both obligations + under that agreement and conditions to your license. + You must not do anything with this software that triggers + a rule that you cannot or will not follow. + + ## Copyright + + Each contributor licenses you to do everything with this + software that would otherwise infringe that contributor's + copyright in it. + + ## Notices + + You must ensure that everyone who gets a copy of + any part of this software from you, with or without + changes, also gets the text of this license or a link to + . + + ## Excuse + + If anyone notifies you in writing that you have not + complied with [Notices](#notices), you can keep your + license by taking all practical steps to comply within 30 + days after the notice. If you do not do so, your license + ends immediately. + + ## Patent + + Each contributor licenses you to do everything with this + software that would otherwise infringe any patent claims + they can license or become able to license. + + ## Reliability + + No contributor can revoke this license. + + ## No Liability + + **_As far as the law allows, this software comes as is, + without any warranty or condition, and no contributor + will be liable to anyone for any damages related to this + software or this license, under any kind of legal claim._** + """ + +- npm, located at deps/npm, is licensed as follows: + """ + The npm application + Copyright (c) npm, Inc. and Contributors + Licensed on the terms of The Artistic License 2.0 + + Node package dependencies of the npm application + Copyright (c) their respective copyright owners + Licensed on their respective license terms + + The npm public registry at https://registry.npmjs.org + and the npm website at https://www.npmjs.com + Operated by npm, Inc. + Use governed by terms published on https://www.npmjs.com + + "Node.js" + Trademark Joyent, Inc., https://joyent.com + Neither npm nor npm, Inc. are affiliated with Joyent, Inc. + + The Node.js application + Project of Node Foundation, https://nodejs.org + + The npm Logo + Copyright (c) Mathias Pettersson and Brian Hammond + + "Gubblebum Blocky" typeface + Copyright (c) Tjarda Koster, https://jelloween.deviantart.com + Used with permission + + -------- + + The Artistic License 2.0 + + Copyright (c) 2000-2006, The Perl Foundation. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + This license establishes the terms under which a given free software + Package may be copied, modified, distributed, and/or redistributed. + The intent is that the Copyright Holder maintains some artistic + control over the development of that Package while still keeping the + Package available as open source and free software. + + You are always permitted to make arrangements wholly outside of this + license directly with the Copyright Holder of a given Package. If the + terms of this license do not permit the full use that you propose to + make of the Package, you should contact the Copyright Holder and seek + a different licensing arrangement. + + Definitions + + "Copyright Holder" means the individual(s) or organization(s) + named in the copyright notice for the entire Package. + + "Contributor" means any party that has contributed code or other + material to the Package, in accordance with the Copyright Holder's + procedures. + + "You" and "your" means any person who would like to copy, + distribute, or modify the Package. + + "Package" means the collection of files distributed by the + Copyright Holder, and derivatives of that collection and/or of + those files. A given Package may consist of either the Standard + Version, or a Modified Version. + + "Distribute" means providing a copy of the Package or making it + accessible to anyone else, or in the case of a company or + organization, to others outside of your company or organization. + + "Distributor Fee" means any fee that you charge for Distributing + this Package or providing support for this Package to another + party. It does not mean licensing fees. + + "Standard Version" refers to the Package if it has not been + modified, or has been modified only in ways explicitly requested + by the Copyright Holder. + + "Modified Version" means the Package, if it has been changed, and + such changes were not explicitly requested by the Copyright + Holder. + + "Original License" means this Artistic License as Distributed with + the Standard Version of the Package, in its current version or as + it may be modified by The Perl Foundation in the future. + + "Source" form means the source code, documentation source, and + configuration files for the Package. + + "Compiled" form means the compiled bytecode, object code, binary, + or any other form resulting from mechanical transformation or + translation of the Source form. + + Permission for Use and Modification Without Distribution + + (1) You are permitted to use the Standard Version and create and use + Modified Versions for any purpose without restriction, provided that + you do not Distribute the Modified Version. + + Permissions for Redistribution of the Standard Version + + (2) You may Distribute verbatim copies of the Source form of the + Standard Version of this Package in any medium without restriction, + either gratis or for a Distributor Fee, provided that you duplicate + all of the original copyright notices and associated disclaimers. At + your discretion, such verbatim copies may or may not include a + Compiled form of the Package. + + (3) You may apply any bug fixes, portability changes, and other + modifications made available from the Copyright Holder. The resulting + Package will still be considered the Standard Version, and as such + will be subject to the Original License. + + Distribution of Modified Versions of the Package as Source + + (4) You may Distribute your Modified Version as Source (either gratis + or for a Distributor Fee, and with or without a Compiled form of the + Modified Version) provided that you clearly document how it differs + from the Standard Version, including, but not limited to, documenting + any non-standard features, executables, or modules, and provided that + you do at least ONE of the following: + + (a) make the Modified Version available to the Copyright Holder + of the Standard Version, under the Original License, so that the + Copyright Holder may include your modifications in the Standard + Version. + + (b) ensure that installation of your Modified Version does not + prevent the user installing or running the Standard Version. In + addition, the Modified Version must bear a name that is different + from the name of the Standard Version. + + (c) allow anyone who receives a copy of the Modified Version to + make the Source form of the Modified Version available to others + under + + (i) the Original License or + + (ii) a license that permits the licensee to freely copy, + modify and redistribute the Modified Version using the same + licensing terms that apply to the copy that the licensee + received, and requires that the Source form of the Modified + Version, and of any works derived from it, be made freely + available in that license fees are prohibited but Distributor + Fees are allowed. + + Distribution of Compiled Forms of the Standard Version + or Modified Versions without the Source + + (5) You may Distribute Compiled forms of the Standard Version without + the Source, provided that you include complete instructions on how to + get the Source of the Standard Version. Such instructions must be + valid at the time of your distribution. If these instructions, at any + time while you are carrying out such distribution, become invalid, you + must provide new instructions on demand or cease further distribution. + If you provide valid instructions or cease distribution within thirty + days after you become aware that the instructions are invalid, then + you do not forfeit any of your rights under this license. + + (6) You may Distribute a Modified Version in Compiled form without + the Source, provided that you comply with Section 4 with respect to + the Source of the Modified Version. + + Aggregating or Linking the Package + + (7) You may aggregate the Package (either the Standard Version or + Modified Version) with other packages and Distribute the resulting + aggregation provided that you do not charge a licensing fee for the + Package. Distributor Fees are permitted, and licensing fees for other + components in the aggregation are permitted. The terms of this license + apply to the use and Distribution of the Standard or Modified Versions + as included in the aggregation. + + (8) You are permitted to link Modified and Standard Versions with + other works, to embed the Package in a larger work of your own, or to + build stand-alone binary or bytecode versions of applications that + include the Package, and Distribute the result without restriction, + provided the result does not expose a direct interface to the Package. + + Items That are Not Considered Part of a Modified Version + + (9) Works (including, but not limited to, modules and scripts) that + merely extend or make use of the Package, do not, by themselves, cause + the Package to be a Modified Version. In addition, such works are not + considered parts of the Package itself, and are not subject to the + terms of this license. + + General Provisions + + (10) Any use, modification, and distribution of the Standard or + Modified Versions is governed by this Artistic License. By using, + modifying or distributing the Package, you accept this license. Do not + use, modify, or distribute the Package, if you do not accept this + license. + + (11) If your Modified Version has been derived from a Modified + Version made by someone other than you, you are nevertheless required + to ensure that your Modified Version complies with the requirements of + this license. + + (12) This license does not grant you the right to use any trademark, + service mark, tradename, or logo of the Copyright Holder. + + (13) This license includes the non-exclusive, worldwide, + free-of-charge patent license to make, have made, use, offer to sell, + sell, import and otherwise transfer the Package with respect to any + patent claims licensable by the Copyright Holder that are necessarily + infringed by the Package. If you institute patent litigation + (including a cross-claim or counterclaim) against any party alleging + that the Package constitutes direct or contributory patent + infringement, then this Artistic License to you shall terminate on the + date that such litigation is filed. + + (14) Disclaimer of Warranty: + THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS + IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR + NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL + LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL + BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + -------- + """ + +- GYP, located at tools/gyp, is licensed as follows: + """ + Copyright (c) 2020 Node.js contributors. All rights reserved. + Copyright (c) 2009 Google Inc. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- inspector_protocol, located at deps/inspector_protocol, is licensed as follows: + """ + // Copyright 2016 The Chromium Authors. + // + // Redistribution and use in source and binary forms, with or without + // modification, are permitted provided that the following conditions are + // met: + // + // * Redistributions of source code must retain the above copyright + // notice, this list of conditions and the following disclaimer. + // * Redistributions in binary form must reproduce the above + // copyright notice, this list of conditions and the following disclaimer + // in the documentation and/or other materials provided with the + // distribution. + // * Neither the name of Google Inc. nor the names of its + // contributors may be used to endorse or promote products derived from + // this software without specific prior written permission. + // + // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- jinja2, located at tools/inspector_protocol/jinja2, is licensed as follows: + """ + Copyright (c) 2009 by the Jinja Team, see AUTHORS for more details. + + Some rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- markupsafe, located at tools/inspector_protocol/markupsafe, is licensed as follows: + """ + Copyright (c) 2010 by Armin Ronacher and contributors. See AUTHORS + for more details. + + Some rights reserved. + + Redistribution and use in source and binary forms of the software as well + as documentation, with or without modification, are permitted provided + that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + + THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT + NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH + DAMAGE. + """ + +- cpplint.py, located at tools/cpplint.py, is licensed as follows: + """ + Copyright (c) 2009 Google Inc. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- gypi_to_gn.py, located at tools/gypi_to_gn.py, is licensed as follows: + """ + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- gtest, located at deps/googletest, is licensed as follows: + """ + Copyright 2008, Google Inc. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- nghttp2, located at deps/nghttp2, is licensed as follows: + """ + The MIT License + + Copyright (c) 2012, 2014, 2015, 2016 Tatsuhiro Tsujikawa + Copyright (c) 2012, 2014, 2015, 2016 nghttp2 contributors + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- large_pages, located at src/large_pages, is licensed as follows: + """ + Copyright (C) 2018 Intel Corporation + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom + the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES + OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- caja, located at lib/internal/freeze_intrinsics.js, is licensed as follows: + """ + Adapted from SES/Caja - Copyright (C) 2011 Google Inc. + Copyright (C) 2018 Agoric + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + """ + +- brotli, located at deps/brotli, is licensed as follows: + """ + Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + """ + +- zstd, located at deps/zstd, is licensed as follows: + """ + BSD License + + For Zstandard software + + Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + +- HdrHistogram, located at deps/histogram, is licensed as follows: + """ + The code in this repository code was Written by Gil Tene, Michael Barker, + and Matt Warren, and released to the public domain, as explained at + http://creativecommons.org/publicdomain/zero/1.0/ + + For users of this code who wish to consume it under the "BSD" license + rather than under the public domain or CC0 contribution text mentioned + above, the code found under this directory is *also* provided under the + following license (commonly referred to as the BSD 2-Clause License). This + license does not detract from the above stated release of the code into + the public domain, and simply represents an additional license granted by + the Author. + + ----------------------------------------------------------------------------- + ** Beginning of "BSD 2-Clause License" text. ** + + Copyright (c) 2012, 2013, 2014 Gil Tene + Copyright (c) 2014 Michael Barker + Copyright (c) 2014 Matt Warren + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. + """ + +- node-heapdump, located at src/heap_utils.cc, is licensed as follows: + """ + ISC License + + Copyright (c) 2012, Ben Noordhuis + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + === src/compat.h src/compat-inl.h === + + ISC License + + Copyright (c) 2014, StrongLoop Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + """ + +- rimraf, located at lib/internal/fs/rimraf.js, is licensed as follows: + """ + The ISC License + + Copyright (c) Isaac Z. Schlueter and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + """ + +- uvwasi, located at deps/uvwasi, is licensed as follows: + """ + MIT License + + Copyright (c) 2019 Colin Ihrig and Contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- ngtcp2, located at deps/ngtcp2/ngtcp2/, is licensed as follows: + """ + The MIT License + + Copyright (c) 2016 ngtcp2 contributors + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- nghttp3, located at deps/ngtcp2/nghttp3/, is licensed as follows: + """ + The MIT License + + Copyright (c) 2019 nghttp3 contributors + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- node-fs-extra, located at lib/internal/fs/cp, is licensed as follows: + """ + (The MIT License) + + Copyright (c) 2011-2017 JP Richardson + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files + (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + +- on-exit-leak-free, located at lib/internal/process/finalization, is licensed as follows: + """ + MIT License + + Copyright (c) 2021 Matteo Collina + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + +- sonic-boom, located at lib/internal/streams/fast-utf8-stream.js, is licensed as follows: + """ + MIT License + + Copyright (c) 2017 Matteo Collina + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ diff --git a/licenses/polka-url-1.0.0-next.29.txt b/licenses/polka-url-1.0.0-next.29.txt new file mode 100644 index 0000000..d46889a --- /dev/null +++ b/licenses/polka-url-1.0.0-next.29.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Luke Edwards (https://lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/licenses/sirv-3.0.2.txt b/licenses/sirv-3.0.2.txt new file mode 100644 index 0000000..d46889a --- /dev/null +++ b/licenses/sirv-3.0.2.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Luke Edwards (https://lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/licenses/upstream.json b/licenses/upstream.json new file mode 100644 index 0000000..c82cb28 --- /dev/null +++ b/licenses/upstream.json @@ -0,0 +1,32 @@ +{ + "libvpx@1.17.0": { + "file": "libvpx-1.17.0.txt", + "source": "https://github.com/webmproject/libvpx/tree/6df3ec34557879fff673706f4a1d9fbd0f3a6f0e", + "sha256": "3042109b4fb2a8d44e52c6241eaa8a1a9b4bfdbd632e67b7d91b4d1db36d653b" + }, + "node@24.19.0": { + "file": "node-24.19.0.txt", + "source": "https://raw.githubusercontent.com/nodejs/node/cdc1b38d40cb567b7ad0b39c86addf830a0af0ae/LICENSE", + "sha256": "d5dfe2ee1355c7c315e2b150c397c791d5fd17c154916ac2ac4b1c4eed04fd36" + }, + "sirv@3.0.2": { + "file": "sirv-3.0.2.txt", + "source": "https://raw.githubusercontent.com/lukeed/sirv/1135207e92c40354543cbd15c763c7a61d79d432/license", + "sha256": "ba573393f24555ac0528612ad39665fab5bdcc80330a61096024bbf5f736526d" + }, + "@polka/url@1.0.0-next.29": { + "file": "polka-url-1.0.0-next.29.txt", + "source": "https://raw.githubusercontent.com/lukeed/polka/02cbdb529ddca0a9f3d225e2abb2931924219cc3/license", + "sha256": "ba573393f24555ac0528612ad39665fab5bdcc80330a61096024bbf5f736526d" + }, + "@bufbuild/protobuf@1.10.1": { + "file": "bufbuild-protobuf-1.10.1.txt", + "source": "https://raw.githubusercontent.com/bufbuild/protobuf-es/e31c1b139cf1e7fea2fb914f650af3c28c42ec68/LICENSE", + "sha256": "c114d9db3f8aa485b7365a01aa6094c71de47262e89f6090c5fe7950e1dac95a" + }, + "cloudflared@2026.8.3": { + "file": "cloudflared-2026.8.3.txt", + "source": "https://github.com/cloudflare/cloudflared/tree/fe70e951a3c52d92abf9f6c4248e32937b2f42fc", + "sha256": "ef733450199fd982350bdadb89f5d0904232ba38412013776c72553cee7924b0" + } +} diff --git a/native/client/README.md b/native/client/README.md index d147b47..b864013 100644 --- a/native/client/README.md +++ b/native/client/README.md @@ -9,53 +9,76 @@ room store, signaling protocol, or route controller. - 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. +- The launcher remembers the Site address but keeps the per-run room source + separate. The Client RPC starts before this choice and accepts the saved Site + alongside the Local Host origin, so Local or Public invite does not disable + Site enhancement. A Client-opened Site remembers the opt-in in that Browser + origin; later manually opened Host pages may reuse the running Client. - `--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 +Local mode uses memory-only rooms, Browser P2P relay, no LiveKit, and no public +discovery. 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 +uses one ordinary public STUN destination plus two bounded public survey +destinations. Browser and Native edges share the same prediction rule; Native +STUN, ICE checks, and media use one Pion UDP mux. 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 +the user selects one explicitly. Windows offers the same VP8/Auto/H264 selector: +VP8 uses libvpx, H264 uses hardware Media Foundation, and Auto measures target- +profile encoding work before choosing one codec for the share. Windows uses +Graphics Capture and WASAPI; macOS +uses ScreenCaptureKit, VideoToolbox, and AudioToolbox; Linux delegates selection +to the ScreenCast Portal and uses the system PipeWire/GStreamer hardware path. +Video and audio share the same room route and PeerConnection. Native capture +starts with the Host's +current resolution, frame-rate, video/audio bitrate, and quality preference; +live changes replace only the capture/encoder generation behind those stable +connections. A Native edge with public STUN also attempts one bounded PCP, +UPnP, or NAT-PMP mapping for its Pion UDP socket; pure LAN 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 +Native P2P edges normally reuse that one encoded source and 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 video 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 Client does not pace, score, or globally lower the shared encoder. If one +Native sender edge remains persistently degraded, the existing quality operation +may test a stock Browser WebRTC sender for that edge through the local bridge. +Existing Viewer evidence commits or rolls back the candidate; healthy Native +edges continue sharing the selected encode. Native Viewers can receive and +forward either H.264 or VP8 without encoding it again. -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. +The Client configuration keeps an optional Local site-access password. Leave it +blank for an open Local site, or set a visible-ASCII password (8 to 128 bytes) +in the launcher before starting. When present, 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. +The terminal starts in visual mode and shows the current mode, working entry +links and startup state. Its language follows the launcher and Client-enabled +pages; a block television uses the terminal foreground with golden sparkles, +falling back to ASCII without color support or in very narrow windows. +Visual mode depicts the selected mode and startup state with small scenes; +the television rests with one eye closed and occasionally winks while idle. +Press `o` to reopen the Browser, or `q` / Ctrl+C to end Local +rooms and stop the bundled server and temporary public link. Plain-text output +uses Ctrl+C. 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 @@ -86,19 +109,20 @@ 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. +Each target compiles its isolated capture process and validates its bounded +capability response. macOS additionally encodes one in-memory hardware H.264 +IDR; Linux probes the Portal/PipeWire/GStreamer adapter. 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. +accepts one strict v8 session. After `hello`, an available Client may list local +capture choices, own one generation-fenced Host share, or receive one native +Viewer source and its bounded encoded child edges. Its public `instanceToken` +distinguishes the discovered process but is not authentication; room authority +and remote signaling remain in the Browser. Viewer receive/relay remains +available even when this machine has no accepted native capture encoder. ## Packaging @@ -120,9 +144,8 @@ SCREENER_GO=/path/to/go \ 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. +capture, and tunnel inputs must already match that target. Each target accepts +its matching native-capture input. The result contains: @@ -140,6 +163,13 @@ app/node_modules The Client executable and application must contain the same full Git revision. No compatibility reader accepts a mismatched private build. +The Windows Client embeds the shared Screener mark through the +`cmd/screener-client/screener_windows_amd64.syso` resource; the platform +suffix keeps that Windows resource out of Linux and macOS builds. +Linux packages include the standard `share/applications` desktop entry and +hicolor icon. macOS packages include a thin `Screener Client.app` launcher +with an ICNS resource; the raw Go executable remains available beside it. + 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 @@ -217,5 +247,6 @@ SSH path for signaling only and requires a selected `srflx` or `prflx` media pai 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. +Browser-mediated SFU path have dedicated gates. macOS and Linux capture still +require physical desktop/media gates; CI compilation and package smoke do not +substitute for them. diff --git a/native/client/cmd/screener-client/main.go b/native/client/cmd/screener-client/main.go index bc25e99..c176980 100644 --- a/native/client/cmd/screener-client/main.go +++ b/native/client/cmd/screener-client/main.go @@ -32,14 +32,18 @@ func main() { 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 options.DisableBrowser { + 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) + if options.DisableBrowser { + fmt.Fprintln(os.Stderr, err) + } os.Exit(1) } } diff --git a/native/client/cmd/screener-client/screener.ico b/native/client/cmd/screener-client/screener.ico new file mode 100644 index 0000000000000000000000000000000000000000..4cba1d7712922321e9bb07931e1621a93998e802 GIT binary patch literal 22899 zcmcF~Wl)?;x9$w??k+)s26wmM8Z>xtcZb0O1P$))1b4UK?hb>yyI=PH&d*!t);U#o zs-}9L?s`{uuj!@zE&u=sfCZqT0X|wXfD|ME;0gc$@bLbFNudFN_K!X?vj5-`7yw`u z4gmP_@^ZRm%wgKW1!MalT3az$B}Du3CTENL z*6Yv7Zc>uBeXq`apU(RWpY)0Sq?J#0(cQ|mMVR?P4NVl}3)+~Lwe%uZ5Dqm{B<0fd zGV6L*yC-Kq-yozIokgkK!wIR)h%465ggXxWrw@W^CgIx;3^{9SQMAL;EQSWCJz%-> z+IjmBF&uAPiIh`w*b|LTXK)!hR>Y?cwXqV@`+AOG<{LYk`)!AbND z+t|Px#Xm*jM_I5OsC&o*Nq$VMuwK-ulnA$S&Hs26x>(L+ke;{KQg3awe;XILgA<=^ zXN@B^gZK_RI1e|-mOzfD)n9PzrX?x*=wa>m?4SAji>vFsW^L9T3@WN{z3Q+ttBEMT z{+|GO%OAHhnunk_gPU`J-yJCZK=cwP>@pB^qmib|eP@Tdh-HeiSiZB~4CZZa1 z$bu7P{0VDBPGmg%bE?H22}2$|T0o~+M1iyu4I0hLh;Zm_NS%i5T1YzXOnUd3bjFwX z+~)eo0vxZ-V10n;Ht8g81gp5w@hFbYbA*2w5Br8S>?7VuCi{Nz70P7q;1pk%$))?n z1%B=bVT^@DDGOvI_U{KNXMWFd_I$>wh1~oahnVZfSI4QfNhw7&o$tTvnvUY2ITck* z=wy$e!bD75S<=9zin09$l}os5$gde08NW6X<{#(0c}Dj?4nIIzLP5Mj)F9yhB1RvA z3&8tVjKFRJ4ZiEiQchxIm3tXczraUxe-fB2jaBIuEj{M#h7epBvzuK4CpG6*rk7+_t-FI zQlwa?$#u8l7$3>>B^`R}Az{Cx2h1t2<|_;bh?)heFvFmc#08k?xnqYMr=rKD2hY(N zlL4lxhqQ`Ith#3=&JEWLbgl|GP01P0OJGE-TvzupVBt*&=t&aIm1Ztj4(`WL1-|TK zk&Bm_X*z~GG~^|jRJ5qiQ>B6BV-?GnSm=t8wsaK~nJMH_fwcB&>6 zdUHn9c=~zibC2x3a?2*kUz?l`DM`JDC;q;fVuF7d!?fh>Q%%T6cV}ErmjX%a%*h1{ zr^?9P$%Psr?=j_I14#0Ob!fh)=JkZJ&#BFlofcyM`If^chgq3tCKBR2WNN(}?xkli zaOOi41UXo5kr~R(CPkW-+Seb;D*iVWm!=<i5-B%nct}d*kSRiOY_kke_e88h z-U(49at<>g-oN5ar;QRrykDouqC3WFhAfPYEmV(=hA>uHQjL{BH>JM?FZ=ol4bg(@ z2O7WYJJfJMs?3FACNGNs*lY>##vDmiaGu5fG;Lebzjq=^vNSMfRZUF|-R+x*OeX&| zxVwb}HkzHh$bc^?3L+*#@;Y#DyUFQZu)k>ka z7MXbA0H0Je%loLC#^vrKy&P9VW|#4=`~=tw(#(MJ-ASDV=%*TLUT7WHv%cT`obx%3 z%}3f*HVcH`hp}NVxMD3Cl(%y8pPAZeh zj#3GpUf^$ZK8G%9>fiEehKGDol087ZF6E2O`;2O8-`GMcO6+=f7$%pvMq7enaqk^d zdW_}BvIydg#nG-gxk@9K{3WOID1%Csl(ffNpKfM({=^}VSw_`(AaEt!@eXr?Xjk#jK=_aU@ZYINvX5c1f7PRtr{~QF_#dlB z7{Pzkqo!27hJ{?b8QQu~rn~)++v&)nc(z9^xV1J4~zZgPt6|YC1%3DVav0I1Yyf>c5 z`Pul+z1gly)fD|)T%>#$Q-XDFhEkBV+Y7|XWjzbXipY6tyJNK;9M-1tI)3RsGSXMh z5|;DG8+y_QI8#GOrJ}L+M#SI`DH=-J|ETG5S|b!2a#;Jcn&XKWk$AF)ZB>e7$}-W$t%2e zpoDY#Bi5JY!)A#UwWl4ANRgI&AOr%n)m99v9Xe68zaF57FqMVA6)q2wB72@nq@2yr z8H7?>lel%WME%==AE?5Lz@ml82T}yu9L2Gvs%Z!AX5Lc^6tRl&u*`z0RVkF49SAE{ zeF0vm_VuU0b-jCXNoa9{ct5E1(g*O;`YEhtX9y>oXOg;AhwNKhQeL92L}oWbr`twzH-3@h zcEP1*Z>h*Zm5`R5DpN*m1xI>UbGENZKQt~em5DCkqa>$>k-4?}&=>eT?XPW_Z5OjP zx9U;8cg%&A+2|VmX+I7FO++Bv({5LYp*Uug+KFG5(pBehXGgIMI2m}`v;Wet1+^0% zcth3kfhp`^htdxurMRHH)#o!DeV9BPzldg_l1r)8l*(9paCa&GB1=YJO^po# z4RlO;4Ymy+l~W_!YA^LjeTCGUaN_?+wC3rjq7QJ*<4m{`*(9nDB@WGNDXBy)ElDJ& z>CPB16XqI>V9^8H+KYa+|9)13wH>{FrA|}&BAL)*BVK-^_Z8@2uG~NZAnE={5D1g*jd(O+;=oww*0=J zr@qc*)77l=)3gCtZzO~1cL9pakO)(v1%ZYntLRUa_M!RW)t_*l186MHayT=q!Hbu= zjLRf9vd)|u=Rr#igV?sv-=ED_6z~sepKs4d#gmA3rp1fgkqTH7)Al&Zrmquicd5u1 z$KazTN2gi*dF9x~MZ{+0ogs_)yhU3`%y_9bu`aTz5o*ko8giPkXSQd)@G8-KCoygw z_h6A8VJuajn?1Sa(yq~7&C3S42udEk*nBr{4h&j)oPN}NY%_sB8%xg!LDX2Wf0H3J zQmxI`@)(&)oQm}+H{B5QYbLF5FB(RRV|~Y{G(Y+p2a@iQr8@R}`NRKL%MnayEBRI-hoFGhm-vy@fU;#|uoKA|}9 z*SIwetGZ{4$g9!%O=0|Dl>w?s*_t>ux+(Q6OP!m;v*l&szO&9Vmp!=0eICXS+w5yn+QC zEAp4QD4pI{<49aNo3z>3MFp#JX=cB43#)0))34LT)$3hYHt6#5eNY@m@np1c74*31 z;=70ZW{)^`D|T)ypHdHhm}M^6b>64n^Y6E3T~{a+JptSex^r=H`wB*xP@-f?Zjqwc znjZ<|Y<-pQaXMfyZD1t|X8Q~Hb<^A3C77B^9<_ZCeBYkf#YFpQjBhUOufu;Hb3?Aa z=6)IWqmX=*+Ax;GTGW6bj4+3=$Nv~OhkV*a=kzUD^X++Gyz2@;blPI{17qFnZvcv=Gf-#W z(}#5T(LdOHw(tH@eLM$-LW)fLnLF*s?gGftx9tw@KQ=#udTY1_Ays#u`9nlqcb;*R z{X=I6msHTds)^?E;=my7i5OV|dZ)tamLs{2I)+u5)cJNGM!6eLkXnv<<$}fZ;?W;{ z`^zdlQs=8NIg_4^l#5tdj8zMKd_q>R;gK93hAG(oQ7b$yKIi1T0Z02tX+c(}PI_ez z-c~mAWl(oOS8>EYWEBF;%#*@{;6@@_CR!dtS2){sJ2ZwcqK)W*OE$cjY`#D>Fxq@3G+9OI(D`wI zFswSL`k7OfZIHNo64Yy%!n6l12YDcDra4_A7h1nCFPwX5w<$a&#)U?~nNV+(Ml`s)^XT~?MU5cUio(x5 zt=~`Fs(?RW*HMcrm~)A@hfX)_(@mLah{xj!F}=?$_VmK3bbwbCDH8E9{tNSKX&lO-sK$Wo-VFbtx?V z;v7#>DSpg&#zvf&2cuZTZ|Ub)fucHt`wMf{>F>u>NY0nNdyID5)MVxEhn9RjG>S=v z*@?SV_=JKIQFAf?aag%iG^KZM;%G))M^9qXtSH0G9F)hK6@L+1pk~KekvZ%n5ZwFg zy2i<}uVGbyt>${tGmS;0jA*>6m+ActW~ag(zu;YflF#OwPxx#?l0L|vspB64z$mv& zhfMuvI!?PsL~81>Si^!u%7b{t#qEde6XVqbh^X`jr0wmZZJpk>%hOFkJi@mosCyeX z(00~5+4sF!ja5if8YH|OoKSztR|b|U?Vn(;c{U~rU~SfGMY?;l_mu$4s2H;aPNIW^ z3PBHg7IU9}E6~QC$v)t!wXxRQl({8nHSjvB&$Z9LBe&bAECf6~?Eer$zr+lv=Ok2kI*74z)y{9HP%}GlsE1 zqsJ72&7O=WMZXO*A?iI@$(M+igg;Ph5ITtWU!T&-Mb4MX=M5UjMYDA)Bpcfr1s=at z_x)UHv1$CKPT}`TJ)i|2#hf{#sIHsn9Tkg9C!|V$D9H9l)!%zSGhvcRqc^2(2}P%a zhIuwj^&2wWmrw9gRhINDw2M3l%EajE1$_2#U%G*q!pM@%uoruqH+t@_$KaS9CN8z9!IobIdCevWtPC zqB#yDL3Ug?G*;nY=030ik>gwWUD3C-w`;0@s_5klkIudzrK080CJ)nzEpDEs7I(!- zQ4KWZ(fO*Sj6c4>5r*QTB{dX6Q=l+!lSq%5W`)Q}`GszYx>`1-R*-tEI9z(1Ki{SA zFVmL$cK&9;s=e&8B6BCA8>zVQhzucA{JQ+2%zpoK+3G2o-wC^Ezh?4l9V{A9K5@a)UuWlLqeorUR>%Rq~8IY(LE&W^2Qsn`Y%Q^HMlB%_wX z6NStq^dA3smw-Qu^+eR}33kR68otJTX8YZsFxHg{;bu1*RFWZh%ZL*sDikBUj2)t&$^KHs?!MuRmmd17Qe)z3s7 zRj*%3;XHftuOZ3l{~wN<@W;6Dzm8j}g~R&)#&JX3`^Rx}*E`jLSHjl7dF7DBZ~P>= z)JHo4i!Q_m=U}Ydg@=nJE}`ro0z}WFjY=jr-y->&EJA4;#v7L!!K=zfWyx9oB`T_f zMSV5$IpC_(`fMbFw{d3U?X~fZ>|0E3nZneK-+70VU8mcI*ZrD$3B&g?q0f=hGGh$L zVFa#=%oKXZTiAsz7jU-I_IC&~mK0m{@;Z8p-yM&L(s0ydAZahxW{yGpQ@JJi;{| zZ++8F1fWEsGRemXCcD=ugR)h}1A68heUK{*jSCZ-V8y2hbs;05INXG34miE7pJ1BT zz2(zc+!E6CKe-&?n5#W5KpgDtkxIw0IwE`Z3`cWGJ_q%w48YJuJ3M&eBG)%)%+TLi zdal*e2*o9-A|&oFUkA4_rNRtBW>Y%r>a_{}OrXi#KvIx-E-{82;0tY^Z>E91BNU~d zx5+cHYE-h{`9ggLMz>a5Cs2ZQ34BH^D6rzHl^c{KMCKAjYtLXm#6o{n|5jx!pRb|h z97wD;Q1P~dk0&9e@ICu^BFr)iEu*=#U}vbd#<2#iFbO5^T0mcWt*nP^S`SAE@8STB12ur^>QP$aAm%o?#$~j-EBfomr zrrN3tgYnRv30KrRB zXlniKHcXcHnUzT2Yo7#*&xqpNI%SKt$2%_N>|_Gpeen_cl$m~_GHw=}X)%7^2All{bg`(B6cuobH%+bxKRShWnz-k-am(_v^7O`^aEzzl%of%A^wv_UF2hY^y{5~p$YF<~GyIWQAzX(|N$e5O z2NlX$f&ER0s4|%b)o8uyQQeAYc^D8V$RWk~YRe`wKv>dQK^`3p^Uow;+i^G?WYD|T zO-Nn2L-TonX2e4fN`4$JJyLu~@RW%QDweY}B3esbl^#Hk zpN**u3k+pDeR!>YYs~9%$cburkiQ7HC?+l6=+>21BT+bV9aCNIuo4*2VJ{WCF+25~d%OCjv&fnTrZ$vOtuJa}{f3X2@$k?)akxS?7So$uWHybeX_o2d|^GZmtT+ZMwr`IvxPLfXGrEu4{(r?P>!O zwYf9&REO$e-^ZCU?QHBIb8>*T|BgaSz7*{+u!8NT0QPi_Kzq)Qm`~=R5ih$BzzAb5 z2-9^lUu^~WP*=it>Vut7OMo9fJS2#KGetGzSnyRgy zVU(`+&$)Zqm3Bpif|o;e4L68ScY}(~C$lEJFWVl=?dM*pzjZ!hgApC!&+n2ffE_Zl z&Ao+aTm`^-dObL(?#(L0XK0B&!Z>`9mK-M(89%X(>r8M<0vV!kgGjKOUfn*n;#0zv z9TueX368L>uaAY_qeH2eWJKj~bS?Uy;8OAJoV54rG)lGScy0^^#~4UiT`1uy^InoJ z^|wePK*ZRg(}r1JUrW=#G*;)(h%7k_pId@YrH~FPm%-JC&Vfh4vFl--Ldrtk+%A%!kf+9Mdls zf2m5%X;-YfZ#BN@PxJk8PLiMOmxm|JO%k48`R)^Nl!>u@+)MM77|+B7t_wh^TDu5H zvRiOyZQAgT##TJSu2zg?7uSH8Iwybz87kb&-@MY74;4tC@2 z$xMbnh>~Qp=yx|hAz!d&$NPl?wUH)`KsbwU(xmlP^N$5Fa zu3UOBt426T-C1UkarVZ(Mew3mvS#_#mA|^ zwZ-rsiMAF9 z`>b@%G}m+beQTuORWUD`^5;z2BABdKH@oFP6J=PJ0_nt# zYb;SZ196(W9*6hnEThcKZm``rLTJTT|{|GI5lpgJpl8keJf)a6ub&aKHx*^}tGs?CUBP)yC391|8fI zl&F$>Sj?@S;uA3HDsrDaN6!K`R`osHmJ`2+fE74U>7I;N$v0i2AosiblTU~*8O?2Y zgAKIvH&_YP?wVZlw;!g12cKzx9wvf#;0xm_^+qBP$4lAcpiWB6qvLcm5su> z0uZ^@Z`LSl(xLn3W_>JYZfB3bTJ3yVI*4NYIbF|qx@x9imPOOSryE%KZFwl&EQ7*D^P4cySP%= z+as=Fs1*9pEv$f3S=r7hci>l=LoRgU5m}*Qg2V!7`kU&oCMr?IA7gAz+k{aL0J<0D z;Mo)C!i}~Uy6+v-Os3{C&8>cZnM@btHD6>0h16|5V|@9M&nC%DzsfvrVfS4+3Y6<< zHJ?i$k#KmJR3-;ere1Jogu5l9WThA&uYKA?|Ks%GK>J%&9&Ad5L}a!(s_S*@IJW06 zyaSt>okncANC^KQ0R`w|9Q1EM(JN>;^1lfv@KXLOpwRVjme;_YxK}(hFqaj@5DP=X z5EuPHMsA7`L{K{W=NFv}I)Su&PC3er)fA4zvI@59DE9TAx%t(?;*`J1u?;nyw<{Bo z`)x`0XFkpMyK8o9t@Cza_h$lT8yoBA={|QnW~5w=6nf%nhPO{+#-6Ao9Mw*h$p6s$hOsb+o{FXLi{*qCAnJ~tdb&AU*cV_bahdpi0$25oNEVJI`~eY6a_Q-FCiSF zKr4HcGqcgur0VlY#d7gibJuds_Uc_LO6ni%mU9VhFO((LyS!>=!7V#_0`YXS#dclr zfCVtosu#4lO0T-^VLiGuKK+9A^f=ntTI&M8$FdYYvJqx+u0FSb4ps81&yg0KSy!`t zrCziPFhiJYa!TlZhYR<1|3X4s@yFR}+Ko|Vw?_g5;dE`naZE^#dlIBG#G0@a^BqC# zeeYd6=JoZpZ0M7kv&dtX->tl7nSxJAyarkVSE@%6Yzyo`sYy}CO`pa_h4Ug@_DeOU z>P#Sowv)e+1k88Bf;NcHw7JMsg`-NgP)cuUn1zMUvyla6WwX_^s~|xTQ#VciKXF`w z9n|CFW5N2XyMm{%rmSa$OqE0q%nkGbT1z_5Ga{bIXXOo?1zf~Vgygj%4|VmrVz-V-{s_7B z^g~^?;oSgUye)oxj$|1xXcdcdvbU9lpSjRi9yC;m$}6Qea;vuwI!FxtQWSF!cR7Bm1vwvw_&LVTT$OHDD=w_Ue3!=zf>ju2V^TAb zvEo=5Wlq#|P;YACDrF18nGka!!}XLW1|lQv{Yf^0L}(X7G<-{}5B~GtbpCC&r^6i& zUawoGSBFPVVnN?TYPmFdJs>*&DteX^&V|)I3Y?i13bD{gjYxl%LPk*-n_z+Coan)0 zsS9H!y+VAsT8Lz)z%46!0>c*&)rK#rDs!Rk`ZaS?R8`@ix=rfi5=M(h*1>cnKd&2O zlEbAW$y$?Qe1`)rG}o`ZBL0{3<(*unS^U*v#sa(5u^M4&=?+;g`K$K#B_onw%_@UL zyeL-FI+W}p@H`wAF&a|^yROa?p5WVLkmg&2>$`x*cgMGo#_R@3M+pO=TTa4)vewDoK z$u!kg_w9wLg1sid?pvp{=-c?(vs7!lAhkt{ojF@l>VXK`ZG@S8DvKBS?rQBC#8=RD zu$PAIa(Ssf+3mdxLI2cnWb5aBW;%PxQ{YwG^N_|1ljUG8jzI*6XxemDBlWmFVG8%5 zs0D7slYnY9wTrvQ7u}9WEUK$keAfUcjs(XKYof+s|KCfR z9(3zqnV6@oCfmyC9AmR)W}Kl$XdYh?8h*N?7RY|Ef(>)7<% zml~z9n?HY(2J=!4Ad7QyfZ6a2Iwd$`b4PzCFaD}&H(Q`)Jh7#T zHREAYGW^;^A5@1$ZK`w?f5aF!+b|Bz8i79gi4@bPA~*nu>PiT!_1;7K-h)>PJ@92< zSC7^+Ji8S5^%xd5Ql^kNL_x`wiRE)LdM&4_sW3?wF*j5ao(X70V?)k2!bvJ86`1XZ z3TGvXHHUouYU#9$fVJ`ARNRja}|BA)4u|m>Jiz>{-lyF~yb|1EDIP`Z>{EGHs#?e8P z7XBp#5sAVV2rVFmFXC9Y-|&c70i(8$vo@lwC|O&5D7jxK$cW(jHuz9Tiq7Y4xS;vf zZ9l_U7gEu4)(|b6?+G>h!u8u`^A7Q{uiU*}hFk^Zox(=5nVE0xiqoJ+Nh%E&*%~i( zQ-mM^NcInQ?oT;iRaS`ud@gS-@6_pGB^~e&1oJf;Rh8RGV9QEj2PPj%YCY7IV{vQU zHCLj~?NQI_-Php(T;O6y12NaLE@EGE$2}Ufbzi3~uNq>}J{Zs&v?v8#aG?xV7g20i z;JVzV7AM0wL^W+7P2ewhGQ4uUL~}zobk{5XWeY6LDtA@y@n$%~iY_gSN-_d=x&kmP z)s;2M0Tf&8IFlX^EQ6K~4*2H3d@yzG2-zQ9$mb ztfbdDW=mkF!3F3KS!a63SFSVk%K7?wNsO-hCe-`)mlX+LlHF|nY80!FiFNTo;_7Kk zkmhgGNXs4vQ0EC!oRJN>uooD#j5?5QsPnrVmaiB-oxWz>{TP?8yTZ#s0ST&++HTv9 zr}$MkFUS3WeEqj72UQP|Pn^OaalnW8?d;B!QVFMgWn8#S$Vo33i?q2=DfNmEar})q zn0TUnQAr31GSYFw@A}m*f@GBOfMm6`l1-%)gRF+4`AFg$Kj{Sb>beO-V~%#rjG#m^ zEPucf6e;{iKj34!9)J=`+a6E&O!GU|aUAG;k5Nn7gnq8!QC0=vgsdkAn|k+MUMq>= zm14Gt)_7SBp9BMndD?Lu%0v|7m z+J)Z7)7Wzus`ctY$aT-Yj~U0c4b7^EZ!U%a?!OT0Z{xi**1Wn8D6Pi&#l$uV!crjc zShAZS*jm@fXP(UCDY_kS@}4?5 z8}72jRtLB!oa3dFWCeLx(DTGl&VL5Bx=wLyY6z0Ma6WFg37>19a2w5hev7u*XV@R( zPt$2L1cVag>h%IfK{eTn=$M5wS*WN+fRyPe>-~!L-SaABU3plvG{fKNQ|hBxUwC@9{Nw?kevze7EBy;*hCS>C)yY=1< zuX&*HRY(2x-{*a#ngy){BR>A(VE`aL%t0_!Jpu{vQvMA9*YyQ$N&fCVR-*ArE{eD& zckM02{Jm0ZwnkOIOd*Y7)E3?tn9n?cmecuwAF>l4e%0qS_OCAB2G|3z>UX=ZRkXI;=Cl7%4+n7n zC;(8@|6hQD4?t+75!PrE6QnwIMjo!IQmo{>r7NQ)~vylwnS|jimgh zPT@q(*WIh^TJ#0j_yCYto;}&`Q?WcbSON~4cZ4hg>O{jX7LcsH<#;uRFxTgEpZ*q% z5wx5|K$Wq{piW$K3XfA?jU)VE$n{3>2+wu!6fzd^*_`MSKv3%w1e;s0IRLP^0@{#yu>`iXC1?)INWS9RazO!d&F812=beNykG3 z!Z)mtKU_?5$RFsPZdl-Al`&dNqK!Z|nn1DU5UJ?ts|#}gpJtW6!~O3WXX(Am%XHoC zX~tz=Fdm~$Yve!;oH-UeJ?AC*Ilj*Q%yRkjgPpgjLzcR`=KBZGf~h}(U<_nzoCX)^)1wEEPAnZ$Q-HapThV$V@ zqY1eYp(4b}FJIk_k(&?&Dr6HX>j!rmLJGeJJv&6SrJvx#)ImGPbXag5i%XyM8A1Z8 z7+YiP+Kg{o4y6$MFTivG8m-f6#BY zu~4GMqi&WZZ12C!yO6z6+UVun_l!R&93^34h#;H#(1;#?SxGb`3hdOvpk_Vo5Key@ zU}WcIT4`@N4TJO&c12X&_%goKdY;6<3FTh<_2@?(LKNhxznDF|LicIaUyGeX-2N0< zaRFMOGYJ84()N7yKz17UoM@ZKXRyB!-j;qReq`DUdie-{*Jt(0{psGnVShUUb|(i2 zNG{N1!!F6xO83xe?fJnif1eN`*Sp!(5@-myJ^yM6W^~vGva+I1E)J2hHi9+78udS_ zSE2;;+IKk}U-H8X+D`|fb|L)X*yZh6b648LDXB-ZXSEQ`?_Nym?3X;BOm}hgG*$2} z1Yp0ZTW`%;Izo`E6i)VyaB0RDaViabc;Ge)BQl_qfwv~@3qi+qVj_Qfd(N#>{PbLs z?*XdKA|yN?L1;vp^HBLlRW+Nps1agMj3w~WTuz1zkuzvuW7<1FAfDk|w*6 zq;O}bD&NrnwLP(smL;UitX*uE%ks4w86UO9Q(NZX`Oc;>g5gTQUqWt3Hw-(9P6?p1 zE@j3ThA`>7?vtV5k?bU3qcf^g$h0Z&#`Zv) z(sEVhHFIKc3&FgV>u(Dc{<#I*&MypE_(7i?F=2zdorjbEiR83F6*TX5SJPA&x{d5R*&?6P@0s>(j|cQyg)A zvL+lWc_;baPv~}@?MjL(evD-gDpaR|#hWoYdmcXZJ9(>VIuvrr%rlU<4J8I1 zA=%uvQ}x+<8x)o&35vbCE*ec-pp)4}7jZJ%QpeL(ga>X38YgsKbUiWE1uI@& zUNDT~RGG2OxIjB$UlSv8qT4t19XMVo;+>(1x<|%}Hn)p#t>^*7(HY#Y|K^YY6i*W! zkV1uIf5M&jx5Tu?t}29HkGxvNaolE@MWr=Bv?S~3M7UCJ;hx4T_xX?^QEiYJxOiQ? z>e*^W!(Jlk7|VgJiUNXRm`j~D_@JHJd*;1D{h-wfz_}?az(sXs1n7K3j3ym6 zbZ$E_F~R(rd{uC~+8jPb?A7Ml8sAeQ!2vRLI9UA5Nt0mDJsV)sw{a8xVm+*Bp*(EZ#2UTE6tq`}ZR)$KCG1vAb{A6Cy;8MHd?~0VbP| zvdnWoTfqm(#ULMo$;-_DYKqj`yepTItS%m<0JI@mtTQLm)GJqFDUMdQL^ZznUDkZ7 z&s(nf79MN?pAHMUxctaO9GYexj_Ps;KkOPj)jE84OIo*wSjt`L_*)}tdgu*I=K#5h zYHE}l+w4H_{=r`Q+3~&3*(FE5)7?<*i{bQhS?NL_h1k9}eUyK&Du>11cNy6Y@$f`D zFn$%NYEOY?0$o^g5Fw@W$riG+H$UZi(W0+4?A{6<|vG*g@(Fj-{0O* z!tCtz>s^r~IQ@R}-V?O=jx@AyBT&27b+Edf*sYaMRL$NoBR63Y&HdcJ82QF_E0jFn zmn-&%zt0i}G76F?K-M0?wwt91#dh&0Mhj}&tWVd)EU_0#BhKx1U3loQ_4w)xugTM& z9j5k>KgWE|atiJx?`A>?%1>sPjX_LVegoD_jp%3Y9{qd+|RJA{$@lM0HZ7g4_i;s^}HKwzwEZ2J{m@jddAqd6(_D4p)^H#s=x=jPfqnFAF!Z^bR1qc}6>1!_7OEYT_ zdVh&?6x0?*9oXu80#MuA`g}1Xcc>_N#GOBFJ-1T|sbCH#h5@{CFL3YA@*|D(<9;q( zuwZh<0F0I>H$r1)AznlG*zn!+aq>j1bA9M|Q@f?T#;ZwdV71z_k~II&%ABlboI&&kh8 z0=r1kKg!*o>n}BYEsX&NSs7j^FzQgg&-Wj7M{y0g+|pq{od?!j zqPOcKhF#2f%yRX<3S2o8Yb#YLL;YcvUBKB21>2P_SV=z~BlPhp6)!)Q-nCX7GNNpF^fmX$R0-r`aMJZqVSeua_=t zOq}$0q30UTeY+AtaR73w1SyzsgZSb3{Qb9tL0@X*Rv&mJj)UNr0n)d8eO$OZ?@or@ z(oJn)vIo)5)OVJpcGz&-06Q0zm2lzN&2VNrsT~)85lR{!W7UB&vD0{@Dbk1jW?#jt z>kPlPrtBxti*k8&ee_UXiQ$j?!2Rcri%50LPB395(ao`4=NcnK(-Udax>$dx@a)*# zF+|J6z0aYbLH+^ZS+vtz<^O5ty1$xgy7dVNs3;&U2r7z5FE$_)2}%{DNH0PVRC@0{ zNKpuaiV!-A1rTXUCnO;4MFb2TB3%g`BPD@Aau2-UcmIdG*8S6(VNS6S@W01QHb+{*v zZUdR!QwjFhTWXdWMEb~P?H1S|Rn`E(X&S9T{3wy$lAT&>e&pS4JDkjP#|}1;YC>lG zwmlL6V6W-yU!)W+ceiS)Wz7#QGkE5%mo#ifnys)>I_o7l;QJ>h)p@wYq9)i*z4{Qc z8VNC(T1Uqb^WcmDQO)pP!&o24V;ovg{^T6tX#P zj=}O+Z8>Y~a(I5o{;eW?7@0(k>iHU=voahI?VY2xnOw&bxNgUtSZl8rd`r8y;I#b4?_J)vW?L~p zrcyf7UD}$1j~jrFdBe^gE0mswa|7rQ#TIulqu0>mY8k<+9E2JZ?jMXy=aVom{p}>R zrt_YP1ABjT?9P04Y4jX?Lr^2mRm*tkMtSr*hwza@52=J@lskTn5#@CvfT7 z1F7~7IbMa~~pf zoMfV8MR=Pkn1{(1Md-J)HCVRU7>3uTH65k0j&GhfW`qkhCl zDQNXWh6+;0DEv3`!|gxA*I+m90_Tc2483|XBt=e~=X{nqn;RG0Y6s*M4WF0Nb7PDh zDB#InBz>Gg48cAzA!U=?CoF_TSkCjiPx|G6*$nY3F8ixFK&imlw}Pu20RBPO$5I2C zqMe!a3La#_dJi-e!tsollSfyZq@`5d|PA5fWb?u{08832XZi**{8QB=0oG0?PLwMx4 zT6>nb|NP$|hi1TgWKnObp#h6@W3um9QMqCXJf~R1y3#Ucbs;+L{RSucx$T@hN#CSj zU5iqDmYXG+9-Jtzxbe3b_Vl7`WZ!p1JCEM_pxLy}PU{=PMoU-3#O#c*iH{C>3; zR}vIy{fv;#SMXgy|-$vGpUb@rsS78Lgc8tFRS}R)nC=8NUrxThV!=LjJ7* z4P3)%Ss-RAM~L}D>sX0`SV^B-0bEP~e&*f7PP;)bip?Oo`dBYr`_xH;5o~L&rRkgr zi$WgS9s@{CRoI-6GNqzqjB*FJ3uKYr({ZX4<#0Iz^=kKhHayx>ixr>`T99w$?aAG~ zrE7zSs=aXcjc<5874;GPYxnBz>cxSZi)^a4vWYritt$HDX& z!ANDQbH(dO5-)NLWr2dkIecSb)er>iu1W{JcuFw(!#U?Fs02QsF*)ZHG9gE{Em*h` zhpl82Q~^wpM_|+`+?#3CqN`v7pCq8<1t^7(1P;goo&^cLamFS)v3#M?A2V!keY&TrCXNUZ^$Ms2LRMZr>6qz=Nu?@mU!bUmiVgLuP;jpt#%w z9E%~UPuVoQtvO|IUUe{X!a;BEEr2XJK$~(5i7gkqSxU(Xm@|n-Khfu)PBYugf=5){ zOU)S1lV4MkP(=(3L(lo{nv*+)4=qVyRlc_T@YRb-QTsI%_ld6&DVZ78 zlxTj+**?!;eJV3tizs4Sw$?OAW_*UXjwo}|~QC5=e>&d;0dIK6C-Ts~|D z-w%+^5gA@7u^}kc6D&#GtD_%7o6`MXFFfWe99g*$nEX*#o{L%@BTY(Scx{z`!LgkN z_Et%alYoJkz)M_gv5@^icQ|jl$80{wPR^2j1za0zrZr|`jXQq_VEWQHv!0S-zaE6y zk67H)HZcb$C8@9)Fm6mBE7_#t+R9p;s(2v>eygQwE%u@I*?4Q;*Q`IR5sf;Qt9clo zKOQtMd;Qm7md0u9(ue=zaH3}1XW{xmhMGI^>d?I|ZIpS!f9jik;SDC2VC_jG=p`8A z!$ThnHaIc};Cl;xnOiwbUDVv4&ut54ynkr(rEET|oI3nCn#9hKw;CVz4DUN&qAM=Z z?gH1XKD(ku*X@05T&Z>@*TijCg5J#tWDWXIyAK=oU}f;$e=C2mYV>r#UDTf&9&`9f z`+Qh^v7c)8U%Lp1gATZWxmItQmT;+wEU}g;i4HFptq3GQNTCx~!%pzdZKMBfkE7WV ze*Q*hJC?Gpr>Rm=I`)d-4tI!Rhs`H$cEe(fte9PVz?H_rJW_QaN)~q`F?*l68ehv3BTu|IVD+f_X ze=WmHT>5Ul702FnBZU%92q@Tw+tJ3c^w<|>Arw0rxL+ZGwkJpF5ijUb8$0}_uSOcD z>}IAtoT!Q67APm8gWiWUn7bMkGB$XAJshd?4|wwQ1F;Pza0SGp@4Z|e8CU(*jPW>G z6=qv);X~+f9w<;xq0+<>ar5CW3ow%E0A(kdST?~)HF-?VB-=(H#M~32xPc#KTKnb) z0_h~bV!~vwn@GSi{A<0-6R&h*#4_)ivf_>IG3cb|q{5{3q{UXX(jn>q%>REm=z@;p z&94+mN|N^)Pv-CkkuKFnS*$LU<;$qF`VjbnZ0LBI7p5< z-7C_}oS)hyYoCI7P6`Z6(5SmSs8>~vs$?)KbYTHg=UGXt2$(uQgMVryz}URHu5)*x%X&ff)mDv&a)0P9L*fptu-SzvsXHJ2K zgPZ|h+~o{`h3|gmGpbO9(=Vl!++{WaL`Rcx*szrcDv2PAjKq_lNSO#EZN!mnTEWq_-1nUJNEpAr9|QBCu?8VF#< z2;|Vjd$!L8jCkBfrFZ*#f+2iLp;>$xY)<8quX~%~6KTNDS3^WOQQjfS;Qb#uezgGl zvK??h9-|kphjsCPyw_N~0BsgduHVrcK?M&MgUO-*F_8oX(hqp0LE$wtE-3O!8mN>P z1k?)SU2G4MnCScD<;0VU#I2XiZmGt%2?h+ zS-5{_msA)C^Mk*?lIoN&0v=YE`UwBMUQgU>$lQs|7cTBSuMv?8P0Gz$ZsY8H)k_HL zHg2q}Cq&VE(-@llX-eN*5$L~OciTs5Z((USb}=?u*jxAOskY?_^N|1AauGDL$KI0%b{NR?G{_hP{|_EHqb_%N8M(~p2<54JQ`j%%+;ALp*RpXHnPCseT33gZ^PmPm zz5GiWagjD{hN<{xnQQX>N#&HMw^@!BZpA%u9;KYt%AYF~AVz+lanzh;moKubjq{^# z{_P9whazUJA$6#7Ly~cjEPJ+^8CN!nQUw8vmgDxdQdDiV_we$SB>kL+ud-^Ewa$Y3 zcF!?tWt*xH+%dIS)8;XXrR>N&!@v!u993^q% zN8h;kX@gJ2BqxF1klua1Neo3FiQYPQRqMzbnV&V|h<6Lu9(>{}muX^7;oXn+D!mp) zlidjsxZ?RS81gC^?KWG;RSYR=?XQk2!0CMP9XNNz^DO0>kDs_SK|k+BSS|2rs>&7v z=yu-z$ehoH=WGsNWoEj*SF<(>+J0gJI=RRqVQ1O)x0;L9f_=)S8X}0Ekm+ZA#|EKa zZDE@utc46SetkPpU5^I#ff=NFRFk&^hiWgGzO+4H2p`}YT0%h?+4l8Yijh=M)TTtz zcA!>3@G@OOL_bbF>WjC;v`nv$+&X=Hdj&2v$+4-ea;02EGdTmx1xF?U4sVg}IP zxh-icgS3LP#JVgp*}aZ0*y5Ytm{5^_!n}G^T9--muT3dDqDo?GHot*Hj#(0TYqs{4 zN9eVG^BdfQB~zrJZpk)J>DtcP)W|kCA7-37+=o)o&K{+7Xz>cS)VnO+iI8m0zaQt+ zRLl2-z+V`2q1QuhN^ipa)0i0lQ+bciyNUMwkS8>3rV`HqobM*ehu4;85mjs9G|I)L z6o&CfWUgp#i8H(Cp0;AOP$F6bj(^wXXVs=yI=>T9JEvLrb+_%MclW|L;)t=B_rkg$ zS0O{`mx5Zrn@tezqg3%rcC28>;<3kL&OiUS#>OdtSdldn=|$fiF$5>Z zHLvrZJXd0DNFWcuHraE^Ork+}d#f!u!n3L!!yjP7F*Q>%AD~89i+ED7jea};w-#cM zFanDvgGF8HQ5pn1(y!)wcI4hfFbSxRasF|+6m_&nZRTUtAOD==gSuTDt6DK(yn9Cj zsOwR5J&y(rqJ{W@X1n>joL#j=U+m4U_l)k9avJ}QkaTBR{;IW~D45fb3Y$x#&xZVv zfy+fD6!RDKr&BFv>53t41-mxfw!v(0>nh#vURJ74`;`GjZAFWry`j&xb^ezmWRI(w zUWxuYvdVFDp7kX{Hm`eUXSrR4`uV|7dQnbPuYT`cIj?HZC&|3{*2-!PBK|cM`J#$! zx!4^IMeO8FW}^+Im8LsQKfDWA0_84R*{`%0P9X0<^FnvavSFf%3Pa&?pQWv8tXI^o zyNp#rn?~u+aCyql$NY*ms^9D?U7kyDJ(;5@YgL;MXO&@FS}r(7e3w_U9@7_UJcz+m8U;QQQ9f_0s!3V4_F>=sX+i&!8V@cm#BVf!46 za}pd+$~Q-2RI-C8L=CswGd}QMas=~Y?-D^4M_ZDD6|G4D3)kE?+Zx%oHfKBBlF=oT zYAZ0{ND6v|-T7{cx913MH$HSzP=*--L@(pL_#$?;Y6FGoQ?<5X?8dkL`Z=fpMl}t^ zrzh)wKv%ZGiQ6~tC5LB(E+F&yudh$5U{QYBJ)$MG;G%#r;gc#ji^Dz%E*xb8}2m7x!IX$ zpy||49WS$^zMPl-R=6@5LoyT6IFbA?c`<}_Bf;_BvoLqn8Wg&`zoS&y<ySqz}put^&Cb$I+9^BnwumC}W26qqcZo%Ch26uOVlkfb`JNK<~ z&Rh4~wQjB1Q~OuFch}xE-PKjy)8D=W0e@*k2LSlDGZ-L?qm!fYKhl5u5`~lrLQcIQ z{4XIu4$*%;f6>1a6aX4>N&x_X&>$QG2mnFY|3dHpCfaB1o^-~_%8^Dh44xU zhlB6|2#1GoFogfra}VJV_P-DifC#x!^xq!#KgpH;8@a)MBe(x=jX9`oPGv;GVD zUlIL(vctbUe#ea?RFIvC=0Gbg09YJhI1OOS`yc!NI|PI&DM+It5ka&7P(Oc?P=RoW1^@=aU!CMv zZ-x*CbW)KP1C)=E96$h!*+==0066in(n7qqT^l0;+9kgS**%^X2#}7PsiQ4Puloi((*gI z=q}}&LhQVt`bH|sd2MXV8pe-SP!83TWM!WjW!CgAcaBfLzd}hfJB!h{hZED9k(RHW zinj0jPwfX)PawAK8*w>Ck|?KSb0!*{Oye`PFH1}w zXyYWN^>!b^%{8=de#?%t_#`VVCGC>3#eAw(UWFsiJ2XRFxI;Qc6&I|^IXB4{Tw?=oH2-ADZ>7OVzAb#lYhp~53a6vnl+iXaOmiwb*e+o>?UG@`o9C@Ex+ANYaW1J4X)1sez%~seX$F? zu!}&@wMMEg@2wsB0*)!(qC2maDbh^1_(#>412()U<99g2av#US-=|pYk}>5nVgz)U zMdW{WqQjtD9u^I~38~evT@6XYpHAyK{hacsC4gWxJoWZ$Qbcr_6GcYO8X>#FyeomM(OdMk&S;7V#iTm?j+F8(Zj5Ck9 zay}=o+9BrZ;l*)sbwXNEP3P;++Q!2;SZ+lX69(Bsn6Qr~u578`62;g)gNjA`Rn(XC z^z11j4DN5bVIOYcmeJO{YI%xQ>m;tlO zD|re-0b*u>Dy(oAWN`szdhWO(M=6+bX~DA$#uR|bszI%yVymv{@iW6!1D(r!Zc|F; zvtqc9R<0|%>F|gqM2uvK=1SA&Z2Nbk=t3X%a4028%rqUt9qMzFOv;Q@^to-%*kLZOnU z8<0-X5LctB5-ldNE`RnX`eRWVcky*6mDj0?Smf0iS>y5hh0h(T_wo&glwi%TG-xT> zT>^=>jbszTgBX@YZ=Wh+0ft-SI)-FudS`APcm!2u-VPr02zif52OB_=C%i+`9WB2n zoPBmprtFjm=l9oa0Xgi7T(gfM&V#1bOW|I62K}c#Btg&vbru<+yd2WxsVTjEvFs9m zQt;{e;2CXRWGeX<@s36HzJTKLvL+lfg%GQWhErBt=2RL`Yo)?rt?Y z-3j*>|sn6&% z{*jjee@>neP_{FnGY|V%P0J6f<9gcrvyXc&+p+0TyV7Q!`0EfZ{5emoC9}Ndepm;* z+8IW&|4k87HaoQ0bBAZ8vt?qBpMr8NTcBBzLayI-BEKe@Z>p?NAjvxhHYZAuobk zqtge3y5~cY;{J|0-5`~9Oz-<#Ec|D0h8ri9iC>P=37%fyFAP2hE^6vua;t|1eUg89 zfO=fY7Mk{$)zrRlgq9cE^=vauEb@#r2gTywIVN`-%TZ(!#TiRrTygW1L@xSEP3BSu zl_)7`kF`8r&+z?@J*;F;XMKZO3%xwYeL0tPQiX&Uy0Eh)bmS$3HrGdS3sZ_(wS5zy48Ex=zDFF5V1dO(etJe$esQ zC_!=o7VkY1atp%(?lumwa#JnW1=O@bo{e_dtxN*h88*ai8I1E@e;O0y5IFPZxGGUo^mB2M_F+yA*0~-` zM%8Z17cZ0b%%>=%Te7SZ9fz$3R1u1TN-zp}1Pf%8=_1Hs=AM zkZ7$oW7zF5NuvGr0ENUUY>X`kxzJQub2J~zI1HUZXf@S|o7ao9KOF>tD(pyXTG#?0 zMWD@L97l?pcHmCN9j#CyyEq@)45&(#O1a5_xO~MI;Dv5qcLH3~yQ7qXl`x3+gIO!N z2QRLjz-x9C6B!qdbOtHyImB31nwz60K9yvJ{z1F5kJ6B)<{Uz?n!{?X%0h9v8jeZS z3Aop4y0?BJt6g!(y0InaC+SRNbu)CjX)t#a{CLzRy!hlT9XX&9(!5=1%8aYv$mnX$ z@hRz>#s#)A$vI+_)Z`EtFaV;0T*s>2cfmzw`H*7m197^!YLO;(!c1YS``eH#);8)4HT=E>^2TBVbS zJ||#QFJBLk*Xiim#7&qYF6tNC9(@x-n=sd#v?SqmhI#0<3YuB<56foDaeB1oXnstm zdD3IG2hVSX%2a=rXnoKh0CE$>Ubvl~BNm$<16Qt)qou87@Q5mw^Yts=W%5sl>fDiM zqjzTK`VCZ7vbWblKlei_z1Yg8)>KkC#RPT~E5MKG+G5ku@0Eu2d>_@{uvHX6dv(Ry znx%FInT?dw^JNkKf(BNM22G{}x?7dM)9nN%F5myqyI;RXMFTtRda?dR@Ds5=*0 z@{R~hx{Xh4o8$I*_ljutlV5%~y@jZxamc+lH8uz|&@t&T*fM}tPKj`qZ#|;n&&fj7PMGDfNKl;^~ro$ zf$)I->E@JNB8g;sN}|vmC7(Spb(gDj>MGH8hlX-t6ftUIWQxt7Uyft!qxiJEGjx%F zw^%co89&Vi&Ut1PQni^^w#tTekHoEWX4Tn9&De7nM>4XXO6FUw5zpOa2G@=8v$TMadN3phR&rJrK#+`YPYywZmcC zz)R-O^yLfcrnR|CvNRPxX!{`fzCLn_i}leNUtic?g?~Tdg1CG{Y^ZY+nh zye-PIjguUsT=i;ZCs@48oaw>0?nV1<7cn9)pd$Hx9&ua3mXgh4!1FYigz<9tYMu^W zvd|!C9EMoZ%Yd=zKBB)TSA09QW0w7Pf?U@T<%dG=)q{u(?xj?&gSqq6h-FgMPwwtp z(ZFa=NdviocX{80k;lQjD1@XdbX4)#OfE%7xa^40p22sYHGoZ0bYs6M-E?m^@ssX@ z=sL^1TJK=3xmaJm9kV>3r|>u1?=M*V$sg?n;OmF9d=058L;i9zs@Z5=hM|Uz zY%+jif6)_=|4>w7hedi=9ToB5p;EXMMIFP@bR=dx9YK9&bdN#ZN)0AY4vJRzvZW0l zq_FEHmJLY2{H5Op8Ipv z(JUASB{KDU&Xgmk3m{Y9wkx>r$ov%Mwf+i(Qq^_p4;6LQamq{ax1K?Ka$*0hSuxr(64LtN0|%tr7)SgoLbsRJ-Jmk+`?tHIOoY{ zBW*Jb)ic@U>glzTskJ9Ol*)}o4?j7YtoTKPo5Iy|HouZ;^ZS?bfcoYHQW_m!(1^5c zwY2qti1S2t2soEW^ve_8vd*(N@3Z8W%6T=SJ;K!&d9x1y9gqfuWz|m8$C|ukgTmXL zpkBigrafRezz1bB#qAO~-|~TV{#e4UoEy!)wQ6|(X2aRl2qU@{e@vggT>Cl7XG=2hV$HS|sTfG(p}e{XWtb1;T#2_8NTQ z>VB=f1*qG^G$moZRZrLB&D-@f{WBd7TXAQ%sz2)QUGf?t*439_96! zupuI9->VRt^UN4}Sy%o0>t!HTl*~xH>K=`1YTC^SbJOq43sH$@=XkOTi6iDy4${P2 zIK@IiOFzeQG}USRAJ{Wae;}$*oG*HIneDb{DazarECssh6q5|I5_c*IiG?MjW@P{p z@Ny>@N^jnz(agGzo}}cNQHB}WXb;!R{vU0Dn(e2B=I|3haLZ=LQ zRJM<$Bx8*|EN`!H+vVGZT-oNNda5WhCU+}XH+wle1^zV6OwtU#O6 zp%852h5A#!FtJr?e+PTbaj;MUYcgNT)7+c9FNN4f#91xy5*;K}h`KQ|S$q9kfHw9l z_5qhI4K?1TtW^=WkKKD20`?8|eS5_4?UuY-S8vCi^L%9XhaeY;KJa&e%@;?|u+bi;EQL*?8BC3oB!W_R<{k{7&6DC+RdXifg(RAABSZBgizn~&~c!wx$ z1@G-e0_LgFze@W3le2LXre(sx^u(2C=Io@aO3-GqK<7c9~c&Ct7KzpglR{M!at7*=DGC#VcB%w%G=0av6o#^ zWbHt9BbP89wxOK##=h_Ru5a9la7CEmsBJ)KGS_zdeT7n~p=t+jZS{(kFTL9K=M@q@ zaqZ~3_8s&#Ns{1GjP1r|f^53(BR4B)J7Z(@QNcm(J!=UJs4=RBP)%j~8_G-3Y59LUU zxaujnU80V0hhUswhLpIucPwV~EaINnzbCf{^D=zwpx#7s*C0t1Ed9Q+Q|`X{7+L(d zVByoeUvSaN1&cN8m~W&x2$EvnLz(f|$VXM_uXA-GSWHTlah&NF^e4x&mZ@nj8z0jB ziH(y0l~kqB94(DjVgGJ?_p=Ft}X$qGEo{Xpe&zo^IWUqrsZkd@-_~>Zf9ks#h=M2%g<}SJ0G< ze*?Dvcc$Sl_%Gu(QHWUdpZKlB!eI>p{>Skf^6uaG&0X(A2T=)E1Mh`PmayTS)M79F zFg&J+0D^kj)bJL!$%-yE`8K5O7l&!KfgXw+lKMSr9|+na?n_EmwkweDrQq( ziF^vU?65u^PUml!UVnXQc%}FflT)fNdF^-B?qt{Dw(fPes$R_WwN&JNtCe$z@ z*9BH8y`xRs0+(|H+bR27q-jg4%{t1T;%{-BY&Fh1OY+d$o?2}`L>6&od4xSG+y(%S zmq>I1cLs;scoI<=+1q(&)O1|0+>$|T(gF<>yC|m<*>>KJTFQgF=1m#26SBVf4PMCtaqy{#YNn%2DK z)7ab+QuW`t9O9X)JQ(87V~BRR_ryo7tJj!jytVXP zt)ml(OHxHj+*`T|Ze>Y<8-&iHcGlHv75<(;m$QzdAoEmg4Bamf+BVlj2YX8_#yDq_ zYhu-)WWW7^_7se1t+qy_1n&~~gr1*o#Zx0UAVrMIBZkqI&Ut`?`J(=%(po-GL&-Ui zRIk7Mb(@etQd;3_*3)>HWhO>?Q%U~zU`_ECI&_F>ib8pZ7Mfrk)0HjwoOdm0h66^Z z>3nHjFudbCl!bHvx@`f*`#re5$|ZS-gsJImJ&{O@Ke*A>I9->%miWp!U#O$Lc-N%Z zste^T*NxNr9q@b3=7-EuQo4G?>)vnlJGb1vU;XVlQcZzftva=*Vg+xP+guODPgh`S z{pBW1mj8*JMBi(V3`f9->dP8+v$n?@KK0B*g1}wTA?Bo+exfpdCW7VBzReVZ`}g&) zK2+w*`gbZf)SJyQKrM~473veAoCVCdMrIEm9bo#?ADVPk3#!dh;-wd0#3EWbk+zE3 z5R(41@I*(GY=hpp?lcbQEW=~?X>d=c`;R=p0?Zcn?kRduIu!23&l59jW7TVNBYwd)`0 zDQX=Jh37!(gO*&ngXU98M&oV##IoG_s8zyq%x-tTJPk6@)j(-Ql|P*liHM7dOK?2) z@mJii`^R9+->QbthJ%0r<0&O8c-Bjl(7dkPf~m%XD|1Iz;r!9B=6KX9HS~5DMEqV~ zhK(PBF=0CF?|{k&V}28Tq<(u|2k!9YE5%#Q$cZ>LOikYJJ7Lq{=oC$&z(`|r*N*2X zRki?xBTPKmQW@dGEwc($$s6ou)w{Ho5}HoK4OG3xi_OR(hr(0B;aCxThYcy*VGyJi z>KUQE4XCJ6nR?Y|y{Zx2@@RQD5GcqY+4*wQCL=&p%2`1k6AbrP3D|ZF0S^`QruA^4 zICLFVU23-y8U3Y0&zq_BP){w8 zslu_VI{7{QcpH?X=yu8RY^tUQguJ)#dR;W%4+Z>{Z1KtOq`%{+pbdyF5cU<{1f1kW zg1bs8caNWOXO`B&@tOOkV;l|Qrq>ojI57i1UCmLn_j7pg=>QZKxW>lx$A-fzqSnX+uJZ=-T^fwumPz)C$AZZoli?Ir>C430p1Zb<4UbKgLa)eB&TGZ%*I zyq>GF0zllA=&gEzT>f)vFc5?IPc4e?>FdkB{Q6p}ZKn}RU(6+5cD!FMA)vZ)vwH}w zv+aG(ZdQd|VS(_)U~T<1^5gA*qVw^L3IFq!$5PvwSISQvNQyA5Bl`YLiVd($fw8eW zAC0d7SWBw|2i3k>rTYvnGDaAOFVIusg`yHB*7BSRZ%U#<6|9p8cQLBl$CkfKxU|E8 zc0R@vwe|I}@OyA5@sf(D7>cgJ{2g2(v6Y?rc9lx4_7u;H#pD+NlB?4QEv7#xH zy`E!-O2WK^Ng2a6{?CG#-#dYb1p^j*58kimCE*pXZu!r|qX1N(duXBTuPq0=G523A zhTlk%WHT9e*59F?vuDNog#)!wCX7IM(6xUXiz$i$15-xgs(1HhT=lELAAJwam60xl z#JbTPgKv*%cc^GmF#@23{G32cYsM|?XMQRsK7LkmvG$*IHc!%2u6*4EzZ-4m6O-R-3r5ghqH^w_3q-MgAXEIAWT?E|8Mc}Q1@l5 zcjqEZiM(v7TekZuqb!mIhirFnLBmW11H8rJby24EX#{@Ms%BFM!wI7-pONf@9mMK` zHNVmGd4(LPwYR}(%OwN@-_t&MigO9PVhLDS^+YOHQ_k>p#L%$_ko1yG8 z)3{Sz&lvZtQGQm&JZs9Iv1ot9X1~1NDFd1)!@J~vPK=2J(^E-Z-m037nAw66$}c~~ zlB6+_rn>8Kd5_F6%S`VCt1X@i${Kt{RE^;&UTTc-%j|M~yd?E0-~~ZP1SA}No?T1m zF}^GwCQ(wK9ZPs^%=tqhfws86P?E@`gI|mm zReT4Jz1dxK3`So;?X~CXp6A7>x67&$V0tYJHQSdAIrfKBoe06{H3Gt<%y9uwi zfpz`@FR9v9on!v`&2(V@J>Ah$o03l9X<9_+ed|8OML`TR8P~V8#WU#NKo=#R%XFOP z0^CbPN$J8CMNY?VIkf!YqxV2((iFi5PCxd*Qumj+Um2KzyvIze)~Nfgh+7=RhuPp- zQH(v{8S@)r!f}zTtahmP-e}F`jEn3O;NhyYhu~5n zs2uB8YqZtRp?ha$y=0`?0lNrNn-rDT~GNst0&=>#8Pj$(SUjP8_QkC53rJO zL*W|T3)iTt&yUO34{6h^W0Sot`1yHzm)+3g4l1gd<*e}EL=ywe5Qh88(QMwkxKi2M zBd=nq6!|Bbu?1i1?IPhQa@Y&Z-7|Kq%ZAo*W;g|3IQyaxXGo#KIkxvUtL zco+(ngxEIi*LqLP{4KNw^%i9X9`m!Vx-P2x!|so<)P;9mWnom(j=O8)aJw!XUK zW_dhvuQloJ)TilgXVq@CWzH__?o`NZeSPgL&F7ZSjGU)|N>4(~@aA#U*b|+MtIDZ@ zlF%n4YB0(?CFTU5DD`A#onm>Fj&r%EFarn=IPIitgee5q%brmZg87ZN(2YCWiqH#n zpU_@s9G6BEIW}8vI<$D2Ngrmdq;^VzRgz`uioNTUF3*b2HuF2qTojU zsR)lG(8?a|)NCXrsp@P(u}tE{+_g-zt!l@Ln)Vx~;1e;sqP>1=Mka6C6Y7FCawPjw=V@aA2)d;&NN1sa|ftmq!8=@l;L1QA|j-dlIxW)T*d7 z>n&02UC(VB_SMyuZ0Mt!^T&rwzZ-eaQU#ylcnypMo)nKH_-6S15|hIA>t2oZa_0qv ztmi6h)#*SgZ6|*tNw}}X`K?gz>2pwN3PzM{VU%7|u?q^GW+L;=N@uF*S3trbmM*%y zzv*0p9n8bSL;l)}yMm{vrmSbVOr>Nt+%@bzMsph96EeZa_sZ*d^Z3Xe3O_7oBZu7Q ztLaBT!pa3I%mE5}%+LHmUUk*F&GDZ|dr`MQ-gA zf)R3QX$QI-LpuTd1e=2TT)$+zU{x&6C|;KnzURPRdcapjl+%&}(mf$5U8qA`gO)R_ z#C0aA*DVcClvi?3+tSg&6iO{!;vAGin|l=~IlIxhlhsR$N%Q`3|2M6uT(i z`h;d8bNP`d+N_xAfZpW%W%4GJGcopjy6XvF3{-mR+oNm*naB>7Soo%RFXH<@X@XlE zj|bZx{9ZRKFAfjfq{6<5v~sEPdO%FU70gU0ymPBNGz2p(G*Xe_>W_Vy3h9Mm9K!jI zvts)XB`(Yvj0*8(Y9Ue`LO1M~2}~bAH0!?Ps;mXNYgep|QI!P)>NY74i&)Jbnfp_b zg8XjGNe&lMWUGye@$C-yuslC<3k9FkmbPKex;B$X~X-EgF&i zXi^y<;YYKYqO@E5F&mk``xcg0%;9&A66(wlcB?eR5wqk%E`e{iQYcdK*4x42l-A@J zQvF87?|h8nhN{^7r1D@%iMLaPI&+cNLSA>mAA?3F)B~}TvhV;(uKC-g)ia9`UoY(b z!n8<$ii*ez(rU}j?yje+x5tE##^IBHA;Z1uVgJ>EDVbJ`RTN{x+m4?;xj^ucJG=#6 zV?c#}^~QcS%T+Tn^f$%yI#9;tc>{d~FHqO*kZ<4D*0wxpIZF3QI=I($#4W!a>E+ew z%i+YHMd`k$Z98n$?m)A1ILr1*VX50|e)O)7c!uxB3QxER9_~3;>vQ)(Pm{irZV|Ri z_~oxHPnOBn+Aq&6<($<4c3(O^i@lDmK1sK<3Da6M+nIADrR;y?xQQ^6Phs<-+*zqv zh57`#3ii^lT`DWlr?|OuA?lkPifnnm$I4(YbppI>eHzqwX0aT|!83^95=)(`Y@i*p zCr;))5VOFKcob5tqIGfi_@LYVz$2he)ih_nduekThhDOcuX%RHd{hTFfU+oXO)vsS zk*K=@8k3Il+RM?|%LueBWcye68X#i;11ubWC(f6{~eJD!TVMks9(02=d z7V1MwUO=h(t&1xA+3B6t37{{KcWDc2q$>rEQKz^JIUrk4kT5DS+uFlYU?D(ltfI@N z^}W+?dWnX;2>y79JZ16-8~x}Napv~^@C-QcoyBOO(Lyl!yE@Dn#oQK>sCAC1<`qf9 zkpIs`O%H}OuuRP3W}|JzRJO5M6D!_e11z8KN99MaVl)ZpEq%{~;VShI*)E<4hS`V# zhn`0#jXD)4!5{6tlO>}=tqz^qt)kk#WzODqXvGOybsp%mkx>oHLybu1Yh=PV>BayG z7=!+DZF+RR1gLQ+MU8W_4Uu1QQb~3%@_RMRxj}OV%z7XM(!0CYp}spTC7y4cHMo{R zRo1a-InUKfqu0OxBn{-I7(f?gX9KeknRJTrctR-P{AA+<&sAjFi^KCY(T}<80UiC0xG{=`^_HynK=KWrrl(L zp8m*@D&9nZP0jRU19Lzf9=)-`RpJ3_%xv8_G; zjqcrE)FNd22=b-!9=hVs-smBbSp#qY);f!p4Cm)#u4dQy+xRlzjl;<)G{5UE1DC~o zoQ|Qg$gR>lXi*9p`c~W%KF1VT%wP^MNvGk;)wBf?lHDLb=FK-`G-42EOP#YkEstc5 z5?mVczQN*>&Gts1XzcT(e-GSwUI;lVLB=2Pcn)@Gx+yV*>6l{P3((H}RyCLY4w_%# zZp;`ah}y!xI6op$^c<-fr0_uk=jICm2|HlK_F=|GtOYG|vllJr0~G}cLhrf&8d>4l zoDC1Gpt|jQIO_r$M(%2o`LkV-`XBgyTO8gYUiK9`S4+?SX`TBTB7g^6I)kdB52OqHfk;76Mv!Q3=!WTfDY#^Tqgm;$>OIzkU|8O%Wl=#v z#L18khNro-M%#zsh@JRJqAywnj4%6|O2m2ciEd18q!FP1+1Y$e%rXfPYi}R%@=>HQBbR|exc7cf(8X%91esX=Pe-J zQ_y}eQ|8yHWMt5{1B8sLp=9xet5;@HT`~jt{cR@>hZaM!763AkfoAGj(LFU$vnj}F z{=(ysM)Z52At=y$a>CJ9eqr0T>a_ zmy;_h&uT|Dl4B3EfeAWkcqIo`IV0+}$w0PQ)b|{>&76{O6wGSB-cGceT?@}m5#%TC zj@-^Uq^>H+-mxTAEE_sC5jjJX4t`!K9suOgXc3ey5dpyh5hn5(MK(Z`fsj9MvAC(n zmv5sb%+>dh>FBJlUQnH%wh;_Og=@j<9{{8ukzY}vCounyK?2Y>Z&py#fT|4Cy|>vW zlMVa!9asctrHhp{ASF}jrfO14$ok&Oi~Z*4I5>pfQL%Y%sH1&uM<9ynZw(&6isDFE z`;^wFGn-^)T{z++&6mve+WU6F*S=SiBxrT%>sQ6(H4)&qN(9&zy<#vM(?QPCJB~z% zwCG4i5su^g?xYnm3eooplt0M{`05P?5HjD-rvtO(@<6>q zy1%1atbJZV3=J~UaU<;f(f1L>DE%JAYI8Y@MkxkW4Ndcb%r}0*3E{)JTL=YSZnBFayl97X8K8WLKyV10Zz5P$YW@48)O6D2Gf zZoNw>F0m*fZ>8nwrn8nuSiYRz09yp}*Y{_m8-5plwPf|TDk&Y%hdpF+7o`8(4(LQY zhM43nWnw1WWs{>8a9%LW&mhGP@~~j!i=m!-4{ULrrgY(c*lHC$(>~@kntuNp zZL`O;H!7H_(`pC^CCbt30gQmEvlcM13#K#C(TxDfQEcNjhUz>J7Tfy`_;34hQ*#lHn@EI(6C$PKB_Vb(1^^Cs zHw^ZCuLlEm;+x*+x2A)Ki@)so!ea7YH01zv6(eIb%lozO0%^&&-1D+nmCL2F`$EQL z<14!KUJtJLUTAj1{8e;xdp*2&js$Z&*%GFu?=Y<}5JeMTi?Z{45tEjNDCKzHkH#iEQiEP|Q z?e}XW15=m;Yq5Qb>Al3PRU&pv{a5eb(dyM-(tuxM_TB&K4tb>?Q+S*WH(#(I_cp*O zk#y!^$FgL3^iiR2%LLRETpO5AKZ2Ih1b~pf6CZxr>oxk1FW?2(190kgx~^2THr?j3 z{Kgue~|6!m`>pb-L)8t6o|n+5-IgdMHU7a{|j`agkLm(uW3n&p-Wpdf^G6L4qf zhpkQoTuO%~CgYZLXlkgCbNgu?Ddhev&Z<6mUb5Ts#86Np58Nv)gk zMBMNoVKjz_8wom6to+jD?I@)QNuWX&v9f+}mm#$1ThNn3L~GhHAzUr2b4 zr>Chxa4rP@McsOH#?ldrQl(&`cbG>rzK~m~9}y=d;-eN|`-7%KH3v)Rc3S{n}{`D#E0HJuMGugK9 z5nBeKLeQcep{fFh{j~O^Mp~B8E;Dwqoi0mPZWIEv7LTnN183VC#z=d^_?7aI0gNR}$2_Kzal}w>c zMKHPx+W0J2SynwOjxZn0U$OQ&U+$li&+Gimlt~!$-Vqx`*-S1+QNK092C6GpGe|+XOVFBt;EFC$?bRf~`b*e6n zawORi?>l?Kk&<_k@7=g=$LWrggyK7S55H0~(Vzl#I(ULc#^RmyBEk z$(vA8;30}FfwZ*3;1Lpd@Q(b|6|?W;5Ws(X9iEjp$ib2A#q%{hvCRByhr?6r9@jD4 zF%S(cmXW&Enz684f<8r`v!`BRX@aQ8tMk0U#0B;jr`Q5sMr+Dgnu=)u4N=3m&aK z5|Y3Q(piu_3tA*o6iXIlCer1Q@BX$a9N$=?O~kcq@xJE|WLl25&4X)a&#pT}gc64# zHe?)3F&AZ->wdaS2$G9IJpfadn*Y)KSZDL5Ttc?8aF`6xhHAFXm`GKxSdOJST;3GZ z_~3U@{iQB#M=#HMe6G#?hX^gCc9 zi9{ruF7uJm+j)+zzD(M*s3yxK7HU}_TAX$!yOf}Q_BHc-E<$e(7+ZUD&)6DDV!=5- zxttvwUAh^#LJHm1Y||_T&I6pc4#+<;W9vP5Ee_SamsD?maA7#I4&N}Qv#}E7;|BlYTcIpJ{D{T7a2EK_EV#+*`M zsB8B1^$jh|&R)OH6-AQU?#kjMhcNdm^x1mNwP~ zR{=ktEO1*3cb6j^ulEK$3nE}c<3dG;ENlFKmO}pXzH!J#b^s9Y&pOCg$0F0e=zqFz zoXF*0>maKkZn(3z@aNBjBEvP5pJGbh`A>);1mRI(Xj8!Dhl4m_VGkH?{0V4eRJGq> z@J}JO;fo_vkP66Wo;-M;t}k7wON@=tG^BC3jkT?79bL`oY_B=(rnTI;nx3w3u(6S? zvFNqS5Z51PB{y9azm?_|yO=7)eS7EQ`>-#>;Mv2)!|FMwyut*4O*N|*d3%m?6xJ3+@89fr1kl>s`g|~>bSN)=z@Iy5IkQs=DQ68Qg#*0s z&hzfg2%-%4;lD4Lw_tI_0*n+ZH^Aa%B45FF+X&q8^RO2Qzyo$2PEUmF-w8?@SafN7 zo)^PZsRFy)*Sf73&7Mn ze{8$bO6rXXo|T`K0(O$6L3YAF)m>=#S{efkGSj`#;M8G!pY9+VlH%%fC;^pdp zxToE80gH67B3v3lhea-j!ev?a^o?i9w?t3o+xarh&iP2r@E_4!2;XPcFTSMlsqB^g z>C=@ysH=LzISZ`5z--e;4m+RrnBnPp5xR6H)mExhhWX7ZJCC;+3bremxBC2Wgw!ke z!0vb8ISChUa}?EPf)6q!)a@niYPcMKH~rWxPW5Tn5i*Y`V-t=Wr{lc8F%rx#VxrY% zLY`0>RYwoi6-@{ES7ipO#w#?>*|ZB4FEpF|ABjoRT7v7{cWmVTzfv0%Y7y_;?lpg& zQGw20pyOt$YY>JEylc&IUvgD)%tcf#-eX5=rH2@BTF*+>qPIV^vV!+A{TwokO4@!kNjXKz0x4SG`|H+#X$aa=?{ z3{bx0>Ek2ZdUr7Glx%2=QrwGmq`a{$w!w$v2iUo&EQgEEY=pDgNpHLOf25}4Ggj>{ z{eRlI?yn}gZaoPCDhdcf5L6VEUTi=p5|pY)kzRx#sC4PQ1rY>-pdtk6VgW>oN+%>B z?L`C(9U@%`9U~=yKyoMez2Cb3!d>gmFSE|s=giqNvuE#TpL6EXSblMcAWUtmjWGPP zmK}+$$=km6yF}j{c_dC*>npe>NTT6>KQVX3PYRVCUtjdZkI6u5wuNiPHQM6T`@1$5 zp>;i!@Od@+H*-QO$-eY5==osn(jdr>-1##op7w+?leNt&nhd+4PmXeDNVK<8#MlA) z6dg`tNCIKesBLHs{;{K5-^BvuRf|y39*SA31ujsH!(VukPOp>LPhvD>rc|5n zdvo0mFFV<`g-f8BP*}ch4*3JPOS-#fs0E9i&06XivjdCJ2idE|b?XskOB~epS}AVC z?%{C_K3?(2G0r0|-UTm5Kuji9F)^eZ1dD%UBcj{zkvHW0PC6{!2 zOl*JRT0JUBZk;z0)fh7awLDN=#_?z|EH`-fTA@CiLZ(G_eeu^_8uX9y%3clq^+N2q z*GjuR*>G1x{<=s*jh0aNZu-|s<5uBQe$V{UkLwpG5+3;?*c2fzc@M<%UXMAn(poG0 znto=^Y4Mfso1CwWw&H;7N12!IGS=JzM*-M~7yQJ50+~s~Q2-OH)Z{L1lm|Pgo*uN! zO{_9G`kjUKR3i4dpPiKUWKNs}u=88j?)WE{`Um~5i0Y)NEDdda|C6%w_g1kg$w^st z($AX@6Dhe~g^ZQh<^ov)@zQL<)^EY2(@E!&iryWQQ@+4|olCN^3NXE)`xd9suF^B2 z5;?ZZ2)XJvlTP#N3Rt*wSGu)Lo}cW(QG5_Uz6tsDB+&WSy9Phbs#St1U|r}VmPK9|=G`K_9Ci>^I0!Zt6G)bLLq>*Tz3(dFIdbMO}SMXzrL z*_GedhB#EM0P07ZnRns3PO_15qWq1bPO{-Tuf=-)EX{PPS^$C-|GatKYSF3*9bTI3 zK%3%&#g42_%@#qw*A5ve2QI%$S4HU>h5cf?xA}YU68y?7;AA1Up=VdRl<1*TJWpOu zXU7CJ+W|R+gQukR+*ls<<@06EliyDv2jCxAQF4jyV-{yc*-r_&kNakU(G2-BCi9Cq zKrP4HH-jr30O4-O`w|1$!mX*aaz0f2Y8Na8!u^E0jmZ#xa6k_-HyHUSeILp>Z7eYT zA6~^8JHR-nle5#%5Q1IW*|6!asigJHc~t_x6Xe1)9w`R_)MuPIHM+^F%Pa47OZib# zGZTE#Y^eI!q#V&dZD)rLs<&oH_|5(abZ7*;hUWDq>gsSPH&*+$CDjX-z*DNlBUgI* z^bLrvd#}Nv-lKL-56E9-UR;V)dXk+Xl@^qsptSa<2p)GvE~4j~lAcy5A=WX@(WT?P zjNb=pUAmYFkMU@n>cys1=u>>D(IF!-`pEf6%j+^KXx!-}(3{RnXTR&tl*9I?dPos# z-rli>2`XJ^ry34yncu1ObC!9z4&7?0Jv|1H~Ju} z96oEX2s6jt$kD4p@pdqMLNrpHXkYT&m&lJAL0h09F%DnZIW&aO0@oE6J&?6x72>#V1dfrvIPCyD!_5_rJ$wEx9Kww9~ zt{k_?OemYHH#sj`TREoz1G{_5-eTwZ@GE!u=rT)?Ra6@dnNhtcJ37>A37Ome<8|Sw zeV4V-sZsXd7jYqFZQkWK$H}fAfJ4XR%?_QloM9;GfI8jdeb;lu`?z#U^*$jfU}sU) z|5Vd-RsSXg`z?*C0|CxA{vST|#Ff0fwrnS$4sPcuAf!0m>c-xy@bfs$cM`5uAR93= zjAOi6^W3Svm7?Zl*ST6GFr-@nGpK4o90VTOqB*a)Y)%jOZI0@bM`-8?a5*xyA8Q*( zh=33CFvJ0Fe#1d$-RSWw%@}q=bdQQ@SW9Da|EyYn#F&HL&T9Zw%=9*8STbieZoPz> zoE#nh;R1nW436_W6!;j2cjb9pDo9&P0L?$=GNVOY+#ok!ZZ`8nL){8v@@!I7foMQ07EZE63Oqk1h|t4{1pA%bR;BP%yM~B_Qej zSp{BNS+oo}8JcI6d)l#;4nD2oDkmWWaiQnaKI>{P)IVd|}(qCMY( z-sR-4ep|JAuSzWPK(^MNeSW{&xaj$>z$}%=*rkW?+2K&tsQ27uW`vqK^kPrF4jr_4 z{D1nJedZ6M6yxm4Lzo3P%e_4v^Vhi3iQu^TKh3QiCeCQ>&Stj+vE12{`B2yIeVo|) zI2y!{Q#R`Fbq(%1V57><(Qg5ltvrZCq(~CYK~^mjd5R31#$q(>nKV?Cw(k z?SDJJv#NjKfIp)@GdSY#gZ^o+`+P6W>|ePk=m#C}{xi*9bnUYxCUT@|)pK2c!|XyI_nY6#ItJZ)*Sd3?`}Bj|tMY18z_m`~<=c3`i*dfE}X zt>i`6lADi-C_@KeWj(tTE~MH2u2m}&d{ZSX{=ji;Gdh3QUfTMjRfX{Wzh)`8(d)eB zq|$0>x|8v>GBbff%)3)8>o--b;&3x+7)yg+v8&8+ReNe07 z#NP{)*ZdsphMrbh!>9yO$$u=vid}lHzLvnM`lwJD2^07>xX#?wsDP#J!I!-^b?Po(j(z~H z&IF-|y#KA6*CYL+?}{-2FQ>|8t8;eGbvXCsYb4X?;tBZKFqb(vS*?${6-6o?6 zq`aiqh9kw@<0Fp(-%GW3&6x?(VL_$%@gO%*|3$=?T9?P3X~xJ!{uLFaE1e^-aj|j5 zah-9CjY{P`uLH3C|B3ks+7346DT>8M?Q{E4F_Iq+$jzji&A=_Q^jvAo;K_{YEC z2ocxBREkTB2H0J`vwi#s$Qp7S9Js?70uS5%Bw$pohG3jaEh89#7dQu6a(@0A5`+bV zQM@qCJw(C5p%4$^4dd?id@exlrLx;A5O0KKF<$Ngiab&t!9o31&F_H|H&%b>tg}qA zdgQJaY*b)C`?7FNbyrzQ0{xGzV)un^e1{H-rxAF9L%_K5{G( z2Ba}@r9r_Z3_dX8LMoUk&k3m)#JborgP7QCKfJ%Agg9X<0Wfn|NXD0Hds(Z>7@t|W zf(s@-*qvMO(PPYn4*$3-v|3y^!J*KLqmFrQ9cctJRb6958p{^g&2? z&1!~c!xFPI7F&4QUvv{gJB{l<))FHb-Ko$T5Xqm2Iq|9P($f zL@6H%%){qEaPCkJcv+BBg-Y%Ed`f`x+32%?rihw{*;?CK3l8<#h3kIP9O0Df#7PGU z2AfevPmgVZ*n4^IV8`dhtf-eYwmgjiVVU(EF^Dkl2H-rd>1sBBavqGN5=9JuaHl~V zscxtId4T*Qq=(F0!3Gipf;`FLa=n{ux;07MwQzPgtbfN#Z}=cEgMJ9+Xh&0^oAm-4 z6cun911BPBA#)wfMI25Ry%47p^uq>x=SPhUj+2l=c|IP z{Vu)cJEyY*tGfgE0q~c=#``-OB1sZq-?> z?!m#T^4-l(d=aAc6nipzSy0tg<*p7JDN%Xr$wfqcmmPLA{*HWU!$V)h{U2w2lTQxD zz_u;xmr?2VfSh$@a2X%E56qW;${^3sC(W?s|19#3zdfvy9Cw|4f5Aq~W9MP&G40%$ z0wGeww<$-hX)c9AyXqKU#`>S0fL<7K+8R=WE;A$>2g-3}s+;jLqVq2^pe(&a=>J;KM|PEf%=Ze*HaLE+>JfF>INQ_3}>D$}lMVf%R|arHF=};N0D4 zEK(2hE}f_gCp~~nKIu8o5Bp*ZUmxNqfKK`LY(;k5@7o2YP#Td9UXt8u-4w>cW}hLV zk9S}J4P)Wl)o&_7(ZHlOIfA|gvjTz^8Iq#DfEk<)rd^(Fp2uP zE=@pINN!B$){!aE3qr5WR=)6wl z!_+oy{NIi;WG5ls@=6S5~P>6zJYw>qrgpE$c)J`rB|% zOcl@is}onkALnmk9`+%uMWB*KVACY9sY@+dlSn}MR(;Ei*cl5V1C`Ov-_Mtz_ZO;9 zy^s9umvylJMhEw@c62EJ&VGN|Y9vF?qfV1#A;HYquKp_HQftx|f3@v3b>l)AUGPe9 znzNi>#mWyf+-X3S)1}^fO<};m<%}|#?K9@XktVY=rC_)GZJVREL7WKdiW}cNt<>Ur zRRAR&C5wTbflszIe&;0R4yu`6i25_M%zbs1<2h0;r*ms-u~n7!>Fz*UVOC_fe)lbT z&&mgnllTeEA1gIUgghGRSp~&%zB3Aj+{zix#2CsbPqv%Bd*ixmO8<;IqIaU)cN4@W|Bq4&*0~(JD044F((pzRmm~TGxoF zg0iF%h(Z07*L)d3K=ZJE6`hqmP za77xJyX3y!QqQ%qKHcV)gee|ZUxEYsk}*qM&bN}i9t`2PV?)-3W!WG=)FQ!CAbe}1 zI^Zm0qS`i;%lO(KUk7!-sH(2$*m&)C*wQ9Aar^46)ZmoJX;iM@<<&`59NJf>ORTsW zTof=SeozBvakxo4(*d2EW6x68-+E{jBoqxvmFfKAQUK^lt5d6ren-6x3WIt0h@h{# z?6YHl-@jhS$MB~c{>C2b{hrTdERBUg_% zyWS3Up+y=(t=o15l{HB_=O3J2=MQ5|fpA|9?5GS%808t62bh$X+9G njDMM_Spd|3>;7e*W&!g4TgPOQW*#iS|K{yxR /|\\\n/ \\ / \\"}, + "site": {"Screener Site", "Screener 站点", ".----. .---.\n|www |---| = |\n'----' '---'"}, + "setup": {"Choose a mode in the browser", "在浏览器中选择模式", ".o.o--------.\n| [] [] [] <|\n'-----------'"}, + "starting": {"Starting", "正在启动", "\\---/\n > < \n/---\\"}, + "ready": {"Ready", "已就绪", " /\n\\ / \n \\/ "}, + "stopping": {"Stopping", "正在退出", " __ \n| o|>\n|__| "}, + "stopped": {"Stopped", "已停止", ".---.\n| x |\n'---'"}, + "failed": {"Could not continue", "运行失败", " /!\\ \n/ \\\n-----"}, + "entry": {"Open in browser", "打开网页", "[o]"}, + "invite": {"Site address", "站点地址", "()=()"}, + "access": {"Site access", "站点准入", ""}, + "open": {"Open", "开放", " __ \n / \n|___|"}, + "password": {"Password protected", "已设置密码", " ___ \n| |\n|_*_|"}, + "error": {"Details", "详情", "[!]"}, + "exit": {"quit", "退出", " __ \n--> |o|\n |_|"}, + "openFailed": {"Could not open the browser; use the address above.", "无法打开浏览器,请使用上方地址。", "[!] -> www"}, +} + +func newClientConsole(cancel context.CancelFunc, machine bool) *clientConsole { + console := &clientConsole{ + machine: machine, + plain: consoleModel{view: consoleView{state: "starting"}, language: "vis", width: 76, cancel: cancel, + colors: colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII}, + } + if machine { + return console + } + if term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) && os.Getenv("TERM") != "dumb" { + console.program = tea.NewProgram(console.plain, tea.WithoutSignalHandler()) + console.done = make(chan error, 1) + go func() { + _, err := console.program.Run() + console.done <- err + cancel() + }() + } + return console +} + +func (console *clientConsole) send(message tea.Msg) { + console.mu.Lock() + defer console.mu.Unlock() + if console.machine || console.finished { + return + } + if _, finished := message.(consoleFinished); finished { + console.finished = true + } + if console.program != nil { + console.program.Send(message) + return + } + updated, _ := console.plain.Update(message) + console.plain = updated.(consoleModel) + fmt.Fprintln(os.Stdout, console.plain.content(false)) +} + +func (console *clientConsole) show(view consoleView) { console.send(view) } +func (console *clientConsole) setLanguage(language string) { console.send(consoleLanguage(language)) } + +func (console *clientConsole) finish(err error) error { + console.send(consoleFinished{err}) + if console.program != nil { + terminalErr := <-console.done + if err != nil { + fmt.Fprintln(os.Stderr, "Screener Client:", err) + } + if terminalErr != nil { + fmt.Fprintln(os.Stderr, "Screener Client:", terminalErr) + } + return terminalErr + } + return nil +} + +func (console *clientConsole) logWriter() io.Writer { + if console.program == nil { + return os.Stderr + } + return console +} + +func (console *clientConsole) Write(data []byte) (int, error) { + if text := strings.TrimSpace(string(data)); text != "" { + console.send(consoleDiagnostic(ansi.Strip(text))) + } + return len(data), nil +} + +func (model consoleModel) Init() tea.Cmd { return nil } + +func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch value := message.(type) { + case consoleView: + if model.view.state != "stopping" { + model.view = value + } + case consoleLanguage: + model.language = string(value) + case consoleDiagnostic: + model.view.problem = string(value) + case tea.WindowSizeMsg: + model.width, model.height = value.Width, value.Height + case tea.ColorProfileMsg: + model.colors = value.Profile > colorprofile.ASCII + case consoleTick: + model.tickPending = false + model.frame++ + if model.frame >= 16 { + model.idleWink = false + } + case consoleIdleWink: + model.idlePending = false + if model.view.state == "ready" || model.view.state == "setup" { + model.frame, model.idleWink = 8, true + } + case consoleOpenResult: + if value.err != nil { + model.view.problem = model.text("openFailed") + } + case consoleFinished: + model.finished = true + model.view.entry, model.view.invite = "", "" + model.view.state, model.view.problem = "stopped", "" + if value.err != nil { + model.view.state, model.view.problem = "failed", value.err.Error() + } + return model, tea.Quit + case tea.KeyPressMsg: + switch value.String() { + case "q", "ctrl+c": + model.view = consoleView{mode: model.view.mode, state: "stopping"} + model.cancel() + case "o": + if target := model.view.entry; target != "" { + return model, func() tea.Msg { return consoleOpenResult{browser.Open(clientLaunchURL(consoleAddress(target)))} } + } + } + } + if model.animating() && !model.tickPending { + model.tickPending = true + return model, tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return consoleTick{} }) + } + if !model.finished && !model.animating() && !model.idlePending && + (model.view.state == "ready" || model.view.state == "setup") { + model.idlePending = true + return model, tea.Tick(time.Duration(8+rand.IntN(13))*time.Second, func(time.Time) tea.Msg { return consoleIdleWink{} }) + } + return model, nil +} + +func (model consoleModel) animating() bool { + return !model.finished && model.view.state != "stopping" && + (model.view.state == "starting" || model.frame < 8 || model.idleWink) +} + +func (model consoleModel) text(key string) string { + index := 0 + if model.language == "zh" { + index = 1 + } + if model.language == "vis" { + index = 2 + } + return consoleCopy[key][index] +} + +func (model consoleModel) View() tea.View { + view := tea.NewView(model.content(true)) + view.WindowTitle = "Screener Client" + view.AltScreen = true + return view +} + +func (model consoleModel) content(styled bool) string { + width := max(12, min(model.width-2, 74)) + compact := model.height > 0 && model.height < 24 + visual := model.language == "vis" + accent, muted, link := lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle() + if styled { + accent = accent.Bold(true) + muted = muted.Faint(true) + link = link.Underline(true) + } + revision := BuildRevision + if len(revision) == 40 { + revision = revision[:7] + } + var out strings.Builder + heading := "Screener\nClient\n" + muted.Render(revision) + animated := styled && model.animating() + mascot := accent.Render(consoleTV(model.frame, animated, compact || width < 34)) + if styled && model.colors && width >= 36 { + mascot = consoleBlockTV(model.frame, animated) + } + fmt.Fprintln(&out, lipgloss.JoinHorizontal(lipgloss.Center, mascot, heading)) + fmt.Fprintln(&out) + state := model.text(model.view.state) + if model.view.state == "starting" && !visual { + dots := 3 + if styled { + dots = model.frame%3 + 1 + } + state += strings.Repeat(".", dots) + } + if visual { + stateStyle := accent + if styled && model.colors { + switch model.view.state { + case "ready": + stateStyle = stateStyle.Foreground(lipgloss.Color("#3fb97a")) + case "starting", "stopping": + stateStyle = stateStyle.Foreground(lipgloss.Color("#e8a33d")) + case "failed": + stateStyle = stateStyle.Foreground(lipgloss.Color("#e25a52")) + } + } + fmt.Fprintln(&out, lipgloss.JoinHorizontal(lipgloss.Center, accent.Render(model.text(model.view.mode)), " ", stateStyle.Render(state))) + } else { + fmt.Fprintln(&out, accent.Render(state)) + if model.view.mode != "" { + fmt.Fprintf(&out, "%s %s\n", muted.Render(model.text("mode")), model.text(model.view.mode)) + } + } + for _, item := range [][2]string{{"entry", model.view.entry}, {"invite", model.view.invite}} { + if item[1] == "" || compact && item[0] == "invite" { + continue + } + address := consoleAddress(item[1]) + style := link + if styled { + target := address + if item[0] == "entry" { + target = clientLaunchURL(address) + } + style = style.Hyperlink(target) + } + if visual { + label := model.text(item[0]) + if !styled && item[0] == "entry" { + label = "www" + } + fmt.Fprintf(&out, "\n%s %s\n", label, style.Render(address)) + } else { + fmt.Fprintf(&out, "\n%s\n%s\n", muted.Render(model.text(item[0])), style.Render(address)) + } + } + accessPicture := "" + if !compact && model.view.entry != "" && (model.view.mode == "local" || model.view.mode == "link") { + access := "open" + if model.view.protected { + access = "password" + } + if visual { + accessPicture = model.text(access) + " " + } else { + fmt.Fprintf(&out, "\n%s %s\n", muted.Render(model.text("access")), model.text(access)) + } + } + if model.view.problem != "" { + lines := strings.Split(ansi.Hardwrap(ansi.Strip(model.view.problem), width-4, true), "\n") + if len(lines) > 3 { + lines = append(lines[:2], "...") + } + fmt.Fprintf(&out, "\n%s\n%s\n", model.text("error"), strings.Join(lines, "\n")) + } + if !model.finished { + keys := "[q / Ctrl+C]" + if !styled { + keys = "Ctrl+C" + } + help := keys + " " + model.text("exit") + if visual { + help = lipgloss.JoinHorizontal(lipgloss.Center, accessPicture, keys+" ", model.text("exit")) + } else if model.view.entry != "" && styled { + help = "[o] " + model.text("entry") + " " + help + } + if visual { + fmt.Fprintf(&out, "\n%s", accent.Render(help)) + } else { + fmt.Fprintf(&out, "\n%s", muted.Render(help)) + } + } + content := ansi.Hardwrap(out.String(), width-4, true) + if !styled { + return content + } + padding := 1 + if model.height > 0 && lipgloss.Height(content)+4 > model.height { + content = strings.ReplaceAll(content, "\n\n", "\n") + padding = 0 + } + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + Padding(padding, 1).Width(width).Render(content) +} + +func consoleTV(frame int, animated, compact bool) string { + phase := 0 + if animated { + phase = frame % 8 + } + eye := "o" + if !animated || phase >= 4 { + eye = "-" + } + if compact { + return " \\ / \n [o " + eye + "] \n / \\ " + } + stars := [3]string{" ", " ", " "} + if phase == 5 { + stars[2] = "*" + } + if phase == 6 { + stars[1] = "+" + } + if phase == 7 { + stars[0] = "." + } + lines := []string{ + " \\ / " + stars[0] + " ", + " .----v----. " + stars[1] + " ", + " | o " + eye + " | " + stars[2] + " ", + " '---------' ", + " / \\ ", + } + return strings.Join(lines, "\n") +} + +func consoleBlockTV(frame int, animated bool) string { + phase := 0 + if animated { + phase = frame % 8 + } + shell := lipgloss.NewStyle().Reverse(true) + spark := lipgloss.NewStyle().Foreground(lipgloss.Color("#f4c14d")).Faint(phase == 7) + // Digits reveal the shell clockwise before the face; subsequent loops keep it drawn. + rows := []string{" 2 2 ", " 2 2 ", " 0000011 ", "3sssssss1", "3sessses1", "3sssssss1", " 3222222 ", " 3 3 "} + lines := make([]string, 0, len(rows)) + for rowIndex, row := range rows { + var line strings.Builder + for column, pixel := range row { + if animated && (pixel >= '0' && pixel <= '3' && frame < int(pixel-'0') || (pixel == 's' || pixel == 'e') && frame < 3) { + pixel = ' ' + } + switch pixel { + case '0', '1', '2', '3': + line.WriteString(shell.Render(" ")) + case 's': + line.WriteString(" ") + case 'e': + if column == len(row)-3 && (!animated || phase >= 4) { + line.WriteString("\u2584\u2584") + } else { + line.WriteString(shell.Render(" ")) + } + default: + line.WriteString(" ") + } + } + star := " " + starStyle := spark + switch { + case phase == 5 && rowIndex == 4: + star = "\u2584\u2588\u2584" + case phase == 5 && rowIndex == 5: + star, starStyle = "\u2584 \u2584", spark.Reverse(true) + case phase == 6 && rowIndex == 2: + star = " \u2584 " + case phase == 7 && rowIndex == 1: + star = " ." + } + lines = append(lines, line.String()+starStyle.Render(star)) + } + return strings.Join(lines, "\n") +} + +func consoleAddress(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + parsed.User, parsed.RawQuery, parsed.Fragment, parsed.RawFragment, parsed.ForceQuery = nil, "", "", "", false + return parsed.String() +} diff --git a/native/client/internal/clientapp/console_test.go b/native/client/internal/clientapp/console_test.go new file mode 100644 index 0000000..d94ffd5 --- /dev/null +++ b/native/client/internal/clientapp/console_test.go @@ -0,0 +1,110 @@ +package clientapp + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestConsolePresentationAndShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + model := consoleModel{cancel: cancel, view: consoleView{ + mode: "link", state: "ready", protected: true, + entry: "http://localhost:8787/#client-access=private-secret", + invite: "https://a-long-public-link-name.example.test/", + }} + for _, language := range []string{"zh", "en", "vis"} { + for _, width := range []int{30, 52, 90} { + model.language, model.width, model.height = language, width, 18 + for _, styled := range []bool{true, false} { + content := model.content(styled) + if styled && strings.Count(content, "\n")+1 > model.height { + t.Fatalf("%s width %d height overflow: %d", language, width, strings.Count(content, "\n")+1) + } + if strings.Contains(content, "private-secret") || strings.Contains(content, "client-access") { + t.Fatal("console exposed the entry credential") + } + for _, line := range strings.Split(content, "\n") { + if ansi.StringWidth(line) > width { + t.Fatalf("%s width %d overflow: %q", language, width, line) + } + } + } + } + } + model.colors = true + for _, compact := range []bool{false, true} { + model.width, model.height = 80, 32 + if compact { + model.width, model.height = 40, 18 + } + for _, frame := range []int{0, 3, 5, 8} { + mascot := consoleBlockTV(frame, true) + lines := strings.Split(mascot, "\n") + if len(lines) != len(strings.Split(consoleBlockTV(0, false), "\n")) { + t.Fatal("mascot animation moved the layout") + } + for _, line := range lines { + if ansi.StringWidth(line) != ansi.StringWidth(lines[0]) { + t.Fatal("mascot frame changed width") + } + } + model.frame = frame + content := model.content(true) + if lipgloss.Width(content) > model.width || lipgloss.Height(content) > model.height { + t.Fatal("color console overflowed") + } + } + } + if !strings.Contains(consoleTV(5, true, false), "- | *") || + !strings.Contains(consoleTV(0, false, false), "o -") || + !strings.Contains(consoleBlockTV(0, false), "\u2584\u2584") || + consoleTV(8, false, false) != consoleTV(0, false, false) { + t.Fatal("mascot wink or static state changed") + } + model.frame = 16 + updated, cmd := model.Update(consoleIdleWink{}) + waking := updated.(consoleModel) + if !waking.animating() || waking.frame != 8 || cmd == nil { + t.Fatal("idle wink did not resume without redrawing the shell") + } + for range 8 { + updated, _ = waking.Update(consoleTick{}) + waking = updated.(consoleModel) + } + if waking.animating() || !waking.idlePending { + t.Fatal("idle wink did not return to rest") + } + var output bytes.Buffer + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), tea.WithoutSignalHandler(), tea.WithoutRenderer()) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + program.Send(consoleLanguage("zh")) + program.Send(tea.KeyPressMsg{Code: 'q'}) + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("quit did not request application shutdown") + } + program.Send(consoleFinished{}) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + program.Kill() + t.Fatal("console did not finish after application cleanup") + } + updated, _ = model.Update(consoleFinished{}) + if text := updated.(consoleModel).content(false); strings.Contains(text, "http") { + t.Fatal("stopped console retained a live entry") + } +} diff --git a/native/client/internal/clientconfig/config.go b/native/client/internal/clientconfig/config.go index e04d85b..8f7066a 100644 --- a/native/client/internal/clientconfig/config.go +++ b/native/client/internal/clientconfig/config.go @@ -2,8 +2,6 @@ package clientconfig import ( "bytes" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -16,11 +14,12 @@ import ( ) const ( - currentVersion = 1 - passwordBytes = 24 + currentVersion = 1 + minAccessPasswordBytes = 8 + maxAccessPasswordBytes = 128 ) -var passwordPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32}$`) +var passwordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`) type Config struct { Version int `json:"version"` @@ -45,13 +44,8 @@ func LoadOrCreate(path string) (Config, error) { 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), + Version: currentVersion, } payload, err := encode(config) if err != nil { @@ -110,6 +104,20 @@ func NormalizeSite(value string) (string, error) { return parsed.Scheme + "://" + parsed.Host, nil } +// NormalizeLocalAccessPassword keeps the local authority open by default while +// retaining a bounded, user-chosen password when one is supplied. +func NormalizeLocalAccessPassword(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if len(value) < minAccessPasswordBytes || len(value) > maxAccessPasswordBytes || + !passwordPattern.MatchString(value) { + return "", errors.New("Local access password must contain 8 to 128 visible ASCII bytes") + } + return value, nil +} + func load(path string) (Config, error) { payload, err := os.ReadFile(path) if err != nil { @@ -119,7 +127,11 @@ func load(path string) (Config, error) { 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) { + config.Version != currentVersion { + return Config{}, errors.New("Client configuration is invalid") + } + normalizedPassword, passwordErr := NormalizeLocalAccessPassword(config.LocalAccessPassword) + if passwordErr != nil || normalizedPassword != config.LocalAccessPassword { return Config{}, errors.New("Client configuration is invalid") } if config.Site, err = NormalizeSite(config.Site); err != nil { @@ -129,7 +141,11 @@ func load(path string) (Config, error) { } func encode(config Config) ([]byte, error) { - if config.Version != currentVersion || !passwordPattern.MatchString(config.LocalAccessPassword) { + if config.Version != currentVersion { + return nil, errors.New("Client configuration is invalid") + } + password, passwordErr := NormalizeLocalAccessPassword(config.LocalAccessPassword) + if passwordErr != nil || password != config.LocalAccessPassword { return nil, errors.New("Client configuration is invalid") } site, err := NormalizeSite(config.Site) diff --git a/native/client/internal/clientconfig/config_test.go b/native/client/internal/clientconfig/config_test.go index eb6e329..d07f8f5 100644 --- a/native/client/internal/clientconfig/config_test.go +++ b/native/client/internal/clientconfig/config_test.go @@ -3,6 +3,7 @@ package clientconfig import ( "os" "path/filepath" + "strings" "testing" ) @@ -17,11 +18,24 @@ func TestLoadOrCreatePersistsOneValidConfiguration(t *testing.T) { t.Fatal(err) } if first != second || first.Version != currentVersion || - !passwordPattern.MatchString(first.LocalAccessPassword) { + first.LocalAccessPassword != "" { t.Fatalf("persisted configuration = %+v, %+v", first, second) } } +func TestUserChosenLocalAccessPasswordIsBounded(t *testing.T) { + for _, value := range []string{"", "valid-local-password", strings.Repeat("x", 128)} { + if normalized, err := NormalizeLocalAccessPassword(value); err != nil || normalized != value { + t.Fatalf("NormalizeLocalAccessPassword(%q) = %q, %v", value, normalized, err) + } + } + for _, value := range []string{"short", strings.Repeat("x", 129), "has space"} { + if _, err := NormalizeLocalAccessPassword(value); err == nil { + t.Fatalf("invalid local password accepted: %q", value) + } + } +} + func TestSavePersistsOneNormalizedSiteChoice(t *testing.T) { path := filepath.Join(t.TempDir(), "client.json") config, err := LoadOrCreate(path) diff --git a/native/client/internal/launcher/server.go b/native/client/internal/launcher/server.go index a81e662..695ed2e 100644 --- a/native/client/internal/launcher/server.go +++ b/native/client/internal/launcher/server.go @@ -31,24 +31,27 @@ const ( ) type Selection struct { - Mode Mode `json:"mode"` - Site string `json:"site,omitempty"` + Mode Mode `json:"mode"` + Language string `json:"language"` + Site string `json:"site,omitempty"` + LocalAccessPassword string `json:"localAccessPassword"` } 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{} + ctx context.Context + cancel context.CancelFunc + listener net.Listener + httpServer *http.Server + static http.Handler + indexPath string + site string + localAccessPassword string + revision string + selection chan Selection + resultReady chan struct{} + handled chan struct{} + done chan error + ready chan struct{} mu sync.Mutex selected bool @@ -60,7 +63,7 @@ type Server struct { handledOnce sync.Once } -func Start(parent context.Context, staticDirectory, site, revision string) (*Server, error) { +func Start(parent context.Context, staticDirectory, site, revision, localAccessPassword string) (*Server, error) { if parent == nil { parent = context.Background() } @@ -69,6 +72,10 @@ func Start(parent context.Context, staticDirectory, site, revision string) (*Ser return nil, errors.New("Client launcher Site is invalid") } site = normalizedSite + normalizedPassword, err := clientconfig.NormalizeLocalAccessPassword(localAccessPassword) + if err != nil { + return nil, errors.New("Client launcher local access password is invalid") + } 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") @@ -79,18 +86,19 @@ func Start(parent context.Context, staticDirectory, site, revision string) (*Ser } 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{}), + ctx: ctx, + cancel: cancel, + listener: listener, + static: http.FileServer(http.Dir(staticDirectory)), + indexPath: indexPath, + site: site, + localAccessPassword: normalizedPassword, + 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, @@ -184,8 +192,9 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re return } writeJSON(response, http.StatusOK, map[string]any{ - "site": server.site, - "revision": server.revision, + "site": server.site, + "localAccessPassword": server.localAccessPassword, + "revision": server.revision, "defaultMode": func() Mode { if server.site != "" { return ModeSite @@ -285,13 +294,26 @@ func decodeSelection(reader io.Reader) (Selection, error) { if decoder.Decode(&selection) != nil || decoder.Decode(&struct{}{}) != io.EOF { return Selection{}, errors.New("invalid launch request") } + if selection.Language != "zh" && selection.Language != "en" && selection.Language != "vis" { + return Selection{}, errors.New("invalid launch language") + } switch selection.Mode { case ModeLocal, ModeLink: if strings.TrimSpace(selection.Site) != "" { return Selection{}, errors.New("local launch cannot include a Site") } selection.Site = "" + normalizedPassword, err := clientconfig.NormalizeLocalAccessPassword( + selection.LocalAccessPassword, + ) + if err != nil { + return Selection{}, errors.New("local access password is invalid") + } + selection.LocalAccessPassword = normalizedPassword case ModeSite: + if selection.LocalAccessPassword != "" { + return Selection{}, errors.New("Site launch cannot include a local access password") + } normalized, err := clientconfig.NormalizeSite(selection.Site) if err != nil || normalized == "" { return Selection{}, errors.New("Site launch requires an HTTP or HTTPS origin") diff --git a/native/client/internal/launcher/server_test.go b/native/client/internal/launcher/server_test.go index 77134c0..b586444 100644 --- a/native/client/internal/launcher/server_test.go +++ b/native/client/internal/launcher/server_test.go @@ -21,13 +21,14 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) { } defer response.Body.Close() var state struct { - Site string `json:"site"` - DefaultMode Mode `json:"defaultMode"` - Revision string `json:"revision"` + Site string `json:"site"` + LocalAccessPassword string `json:"localAccessPassword"` + 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 != "" { + state.Revision != "" || state.LocalAccessPassword != "" { t.Fatalf("launcher state = %d, %+v", response.StatusCode, state) } @@ -37,7 +38,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) { response, err := http.Post( origin+"/api/client-launcher/launch", "application/json", - bytes.NewBufferString(`{"mode":"link"}`), + bytes.NewBufferString(`{"mode":"link","language":"vis"}`), ) if err != nil { requestErr <- err @@ -48,7 +49,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) { select { case selection := <-server.Selection(): - if selection != (Selection{Mode: ModeLink}) { + if selection != (Selection{Mode: ModeLink, Language: "vis"}) { t.Fatalf("selection = %+v", selection) } server.SetResult("http://localhost:8787/#screener-client=1", nil) @@ -90,6 +91,54 @@ func TestLauncherIncludesTheInjectedBuildRevision(t *testing.T) { } } +func TestLauncherCarriesAndNormalizesAnOptionalLocalPassword(t *testing.T) { + server := startFixtureWithPassword(t, "", "existing-pass") + origin := strings.TrimSuffix(server.URL(), "/client") + stateResponse, err := http.Get(origin + "/api/client-launcher") + if err != nil { + t.Fatal(err) + } + var state struct { + LocalAccessPassword string `json:"localAccessPassword"` + } + if stateResponse.StatusCode != http.StatusOK || + json.NewDecoder(stateResponse.Body).Decode(&state) != nil { + _ = stateResponse.Body.Close() + t.Fatal("launcher state was unavailable") + } + _ = stateResponse.Body.Close() + if state.LocalAccessPassword != "existing-pass" { + t.Fatal("launcher did not expose the saved Local access setting") + } + result := make(chan *http.Response, 1) + go func() { + response, err := http.Post( + origin+"/api/client-launcher/launch", + "application/json", + bytes.NewBufferString(`{"mode":"local","language":"zh","localAccessPassword":"new-local-pass"}`), + ) + if err != nil { + t.Error(err) + return + } + result <- response + }() + select { + case selection := <-server.Selection(): + if selection.Mode != ModeLocal || selection.Language != "zh" || selection.LocalAccessPassword != "new-local-pass" { + 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") + } + response := <-result + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("launch status = %d", response.StatusCode) + } +} + func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) { directory := t.TempDir() if err := os.WriteFile( @@ -99,18 +148,22 @@ func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) { ); err != nil { t.Fatal(err) } - if _, err := Start(t.Context(), directory, "https://example.test/path", ""); err == nil { + 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}`, + "unknown": `{"mode":"other","language":"en"}`, + "local-site": `{"mode":"local","language":"en","site":"https://share.example"}`, + "missing-site": `{"mode":"site","language":"en"}`, + "site-path": `{"mode":"site","language":"en","site":"https://share.example/path"}`, + "site-password": `{"mode":"site","language":"en","site":"https://share.example","localAccessPassword":"valid-pass"}`, + "unknown-field": `{"mode":"local","language":"en","extra":true}`, + "short-password": `{"mode":"local","language":"en","localAccessPassword":"short"}`, + "missing-language": `{"mode":"local"}`, + "unknown-language": `{"mode":"local","language":"other"}`, } { t.Run(name, func(t *testing.T) { server := startFixture(t, "") @@ -176,10 +229,21 @@ func TestLauncherRejectsCrossOriginControl(t *testing.T) { } func startFixture(t *testing.T, site string) *Server { - return startFixtureWithRevision(t, site, "") + return startFixtureWithRevisionAndPassword(t, site, "", "") } func startFixtureWithRevision(t *testing.T, site, revision string) *Server { + return startFixtureWithRevisionAndPassword(t, site, revision, "") +} + +func startFixtureWithPassword(t *testing.T, site, password string) *Server { + return startFixtureWithRevisionAndPassword(t, site, "", password) +} + +func startFixtureWithRevisionAndPassword( + t *testing.T, + site, revision, password string, +) *Server { t.Helper() directory := t.TempDir() if err := os.WriteFile( @@ -189,7 +253,7 @@ func startFixtureWithRevision(t *testing.T, site, revision string) *Server { ); err != nil { t.Fatal(err) } - server, err := Start(t.Context(), directory, site, revision) + server, err := Start(t.Context(), directory, site, revision, password) if err != nil { t.Fatal(err) } diff --git a/native/client/internal/loopback/protocol.go b/native/client/internal/loopback/protocol.go index eac68ea..321b53e 100644 --- a/native/client/internal/loopback/protocol.go +++ b/native/client/internal/loopback/protocol.go @@ -9,16 +9,16 @@ import ( ) const ( - ProtocolVersion = 5 + ProtocolVersion = 8 ServiceName = "screener-client" - ControlSubprotocol = "screener-client-v5" - MaxControlMessageBytes = 64 << 10 + ControlSubprotocol = "screener-client-v8" + MaxControlMessageBytes = 256 << 10 DefaultPortStart = 39721 DefaultPortEnd = 39730 ) var ( - requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,256}$`) errInvalidMessage = errors.New("loopback control message is invalid") ) diff --git a/native/client/internal/loopback/protocol_test.go b/native/client/internal/loopback/protocol_test.go index 2b259bc..7b89f52 100644 --- a/native/client/internal/loopback/protocol_test.go +++ b/native/client/internal/loopback/protocol_test.go @@ -1,13 +1,14 @@ package loopback import ( + "strings" "testing" ) func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) { valid := []string{ - `{"version":5,"id":"request_hello","type":"hello"}`, - `{"version":5,"id":"request_ping","type":"ping"}`, + `{"version":8,"id":"request_hello","type":"hello"}`, + `{"version":8,"id":"request_ping","type":"ping"}`, } for _, payload := range valid { if _, err := decodeRequest([]byte(payload)); err != nil { @@ -16,10 +17,10 @@ func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) { } 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`, + `{"version":8,"id":"short","type":"ping"}`, + `{"version":8,"id":"request_ping","type":"ping","extra":true}`, + `{"version":8,"id":"request_hello","type":"hello","nonce":"obsolete"}`, + `{"version":8,"id":"request_ping","type":"ping"} trailing`, } for _, payload := range invalid { if _, err := decodeRequest([]byte(payload)); err == nil { @@ -28,6 +29,21 @@ func TestDecodeRequestAcceptsOnlyTheCurrentShape(t *testing.T) { } } +func TestDecodeRequestUsesTheSharedIdentifierBoundary(t *testing.T) { + identifier := "request_" + strings.Repeat("a", 248) + if len(identifier) != 256 { + t.Fatalf("test identifier length = %d", len(identifier)) + } + payload := []byte(`{"version":8,"id":"` + identifier + `","type":"ping"}`) + if _, err := decodeRequest(payload); err != nil { + t.Fatalf("maximum identifier was rejected: %v", err) + } + tooLong := []byte(`{"version":8,"id":"` + identifier + `a","type":"ping"}`) + if _, err := decodeRequest(tooLong); err == nil { + t.Fatal("identifier beyond the boundary was accepted") + } +} + func TestValidateMessagesRequireTheExpectedPhase(t *testing.T) { hello := controlMessage{Version: ProtocolVersion, ID: "request_hello", Type: "hello"} if err := validateHello(hello); err != nil { @@ -46,7 +62,7 @@ func TestValidateMessagesRequireTheExpectedPhase(t *testing.T) { } func TestEnvelopeAllowsAnExtensionToOwnItsStrictShape(t *testing.T) { - payload := []byte(`{"version":5,"id":"request_extension","type":"extension","value":1}`) + payload := []byte(`{"version":8,"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) @@ -56,6 +72,21 @@ func TestEnvelopeAllowsAnExtensionToOwnItsStrictShape(t *testing.T) { } } +func TestResponseBoundFitsOneSourcePreview(t *testing.T) { + preview := map[string]string{ + "type": "source-preview", + "mime": "image/bmp", + "data": strings.Repeat("A", (54+320*180*3)*4/3), + } + if _, err := encodeMessage(preview); err != nil { + t.Fatalf("320x180 preview was rejected: %v", err) + } + preview["data"] = strings.Repeat("A", MaxControlMessageBytes) + if _, err := encodeMessage(preview); err == nil { + t.Fatal("response beyond the message bound was accepted") + } +} + func TestNormalizePortRangeUsesTheBoundedDefaults(t *testing.T) { start, end := normalizePortRange(0, 0) if start != DefaultPortStart || end != DefaultPortEnd { diff --git a/native/client/internal/loopback/server.go b/native/client/internal/loopback/server.go index fc17d56..df1649f 100644 --- a/native/client/internal/loopback/server.go +++ b/native/client/internal/loopback/server.go @@ -4,8 +4,10 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/url" @@ -25,11 +27,12 @@ const ( ) type Options struct { - PortStart int - PortEnd int - AllowedOrigin string - NativeMedia NativeMediaCapabilities - NewControl func() ControlSession + PortStart int + PortEnd int + AllowedOrigins []string + NativeMedia NativeMediaCapabilities + NewControl func() ControlSession + Presentation func(language string) } type ControlSession interface { @@ -58,18 +61,20 @@ type NativeMediaCapabilities struct { ProcessAudio bool `json:"processAudio"` SystemAudio bool `json:"systemAudio"` HardwareH264 bool `json:"hardwareH264"` + SoftwareVP8 bool `json:"softwareVP8"` } 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 + ctx context.Context + cancel context.CancelFunc + listener net.Listener + httpServer *http.Server + endpoint Endpoint + allowedOrigins []string + nativeMedia NativeMediaCapabilities + newControl func() ControlSession + presentation func(string) + done chan error mu sync.Mutex activeConn *websocket.Conn @@ -105,10 +110,11 @@ func Start(parent context.Context, options Options) (*Server, error) { Port: address.Port, InstanceToken: instanceToken, }, - allowedOrigin: strings.TrimSpace(options.AllowedOrigin), - nativeMedia: options.NativeMedia, - newControl: options.NewControl, - done: make(chan error, 1), + allowedOrigins: normalizedOrigins(options.AllowedOrigins), + nativeMedia: options.NativeMedia, + newControl: options.NewControl, + presentation: options.Presentation, + done: make(chan error, 1), } server.httpServer = &http.Server{ Handler: server, @@ -131,6 +137,12 @@ func (server *Server) Done() <-chan error { return server.done } +func (server *Server) SetAllowedOrigins(origins []string) { + server.mu.Lock() + server.allowedOrigins = normalizedOrigins(origins) + server.mu.Unlock() +} + func (server *Server) Close() error { server.mu.Lock() if server.closed { @@ -172,11 +184,42 @@ func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Requ server.handleHealth(response, request) case "/control": server.handleControl(response, request) + case "/presentation": + server.handlePresentation(response, request) default: http.NotFound(response, request) } } +func (server *Server) handlePresentation(response http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodOptions { + response.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + response.Header().Set("Access-Control-Allow-Headers", "Content-Type") + response.WriteHeader(http.StatusNoContent) + return + } + if request.Method != http.MethodPost { + response.Header().Set("Allow", "POST, OPTIONS") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + request.Body = http.MaxBytesReader(response, request.Body, 256) + var presentation struct { + Language string `json:"language"` + } + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if decoder.Decode(&presentation) != nil || decoder.Decode(&struct{}{}) != io.EOF || + (presentation.Language != "zh" && presentation.Language != "en" && presentation.Language != "vis") { + http.Error(response, "invalid presentation", http.StatusBadRequest) + return + } + if server.presentation != nil { + server.presentation(presentation.Language) + } + response.WriteHeader(http.StatusNoContent) +} + func (server *Server) handleHealth(response http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet { response.Header().Set("Allow", http.MethodGet) @@ -315,9 +358,14 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http. } 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) + server.mu.Lock() + origins := append([]string(nil), server.allowedOrigins...) + server.mu.Unlock() + patterns := make([]string, 0, len(origins)+2) + for _, origin := range origins { + if parsed, err := url.Parse(origin); 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) @@ -330,7 +378,7 @@ func closeControl(connection *websocket.Conn, reason string) { 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) { + if origin != "" && !server.allowedOrigin(origin) && !server.localOrigin(origin) { http.Error(response, "forbidden", http.StatusForbidden) return false } @@ -345,6 +393,17 @@ func (server *Server) requestAllowed(response http.ResponseWriter, request *http return true } +func (server *Server) allowedOrigin(origin string) bool { + server.mu.Lock() + defer server.mu.Unlock() + for _, allowed := range server.allowedOrigins { + if sameOrigin(origin, allowed) { + return true + } + } + return false +} + func (server *Server) hostAllowed(host string) bool { if host == server.endpoint.Host { return true @@ -423,6 +482,23 @@ func sameOrigin(actual, expected string) bool { return strings.EqualFold(strings.TrimRight(actual, "/"), strings.TrimRight(expected, "/")) } +func normalizedOrigins(origins []string) []string { + result := make([]string, 0, len(origins)) + seen := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + continue + } + if _, found := seen[strings.ToLower(origin)]; found { + continue + } + seen[strings.ToLower(origin)] = struct{}{} + result = append(result, origin) + } + return result +} + func newInstanceToken() (string, error) { buffer := make([]byte, instanceTokenBytes) if _, err := rand.Read(buffer); err != nil { diff --git a/native/client/internal/loopback/server_test.go b/native/client/internal/loopback/server_test.go index 3d5422e..ea14c01 100644 --- a/native/client/internal/loopback/server_test.go +++ b/native/client/internal/loopback/server_test.go @@ -22,8 +22,8 @@ func TestStartServesHealthAndStrictControlHandshake(t *testing.T) { Video: true, ProcessAudio: false, SystemAudio: true, HardwareH264: true, } server := startTestServerWithOptions(t, Options{ - AllowedOrigin: testOrigin, - NativeMedia: expectedMedia, + AllowedOrigins: []string{testOrigin}, + NativeMedia: expectedMedia, }) endpoint := server.Endpoint() @@ -87,6 +87,79 @@ func TestHealthAcceptsTheLocalHostAlias(t *testing.T) { } } +func TestPresentationUpdatesOnlyLanguageWithoutClaimingMediaControl(t *testing.T) { + languages := make(chan string, 3) + var server *Server + server = startTestServerWithOptions(t, Options{ + AllowedOrigins: []string{testOrigin}, + Presentation: func(language string) { + server.SetAllowedOrigins([]string{testOrigin}) + languages <- language + }, + }) + endpoint := server.Endpoint() + connection := dialControl(t, endpoint, endpoint.InstanceToken, testOrigin) + defer connection.CloseNow() + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + client := &http.Client{Timeout: time.Second} + for _, input := range []struct { + method, path, origin, body, language string + status int + }{ + {http.MethodPost, "/presentation", testOrigin, `{"language":"zh"}`, "zh", http.StatusNoContent}, + {http.MethodPost, "/presentation", testOrigin, `{"language":"en"}`, "en", http.StatusNoContent}, + {http.MethodPost, "/presentation", endpoint.URL, `{"language":"vis"}`, "vis", http.StatusNoContent}, + {http.MethodOptions, "/presentation", testOrigin, "", "", http.StatusNoContent}, + {http.MethodPost, "/presentation", "https://other.example", `{"language":"en"}`, "", http.StatusForbidden}, + {http.MethodOptions, "/presentation", "https://other.example", "", "", http.StatusForbidden}, + {http.MethodOptions, "/health", testOrigin, "", "", http.StatusMethodNotAllowed}, + {http.MethodGet, "/presentation", testOrigin, "", "", http.StatusMethodNotAllowed}, + {http.MethodPost, "/presentation", testOrigin, `{"language":"other"}`, "", http.StatusBadRequest}, + {http.MethodPost, "/presentation", testOrigin, `{"language":"en","capture":true}`, "", http.StatusBadRequest}, + {http.MethodPost, "/presentation", testOrigin, `{"language":"en"}{}`, "", http.StatusBadRequest}, + {http.MethodPost, "/presentation", testOrigin, strings.Repeat(" ", 257) + `{"language":"en"}`, "", http.StatusBadRequest}, + } { + request, err := http.NewRequest(input.method, endpoint.URL+input.path, strings.NewReader(input.body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", input.origin) + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != input.status { + t.Fatalf("%s %s status = %d, want %d", input.method, input.path, response.StatusCode, input.status) + } + if input.method == http.MethodOptions && input.status == http.StatusNoContent && + (response.Header.Get("Access-Control-Allow-Origin") != testOrigin || + response.Header.Get("Access-Control-Allow-Methods") != "POST" || + response.Header.Get("Access-Control-Allow-Headers") != "Content-Type") { + t.Fatal("presentation preflight did not preserve its bounded origin/method scope") + } + select { + case language := <-languages: + if language != input.language { + t.Fatalf("unexpected presentation callback: %q", language) + } + default: + if input.language != "" { + t.Fatal("presentation callback missing") + } + } + } + writeControl(t, connection, requestJSON("request_ping", "ping")) + var pong controlMessage + readControl(t, connection, &pong) + if pong.Type != "pong" { + t.Fatal("presentation changed the active media control") + } +} + func TestOnlyOneControlSessionIsClaimed(t *testing.T) { server := startTestServer(t, testOrigin) endpoint := server.Endpoint() @@ -110,7 +183,7 @@ func TestControlSessionSharesOneBoundedSocketForResponsesAndEvents(t *testing.T) closed: make(chan struct{}), } server := startTestServerWithOptions(t, Options{ - AllowedOrigin: testOrigin, + AllowedOrigins: []string{testOrigin}, NewControl: func() ControlSession { return extension }, @@ -180,7 +253,7 @@ func TestUnexpectedServeFailureIsReported(t *testing.T) { func startTestServer(t *testing.T, origin string) *Server { t.Helper() - return startTestServerWithOptions(t, Options{AllowedOrigin: origin}) + return startTestServerWithOptions(t, Options{AllowedOrigins: []string{origin}}) } func startTestServerWithOptions(t *testing.T, options Options) *Server { diff --git a/native/client/internal/mediaedge/audio.go b/native/client/internal/mediaedge/audio.go index 5a689d7..9ac7da6 100644 --- a/native/client/internal/mediaedge/audio.go +++ b/native/client/internal/mediaedge/audio.go @@ -7,6 +7,7 @@ import ( "time" "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/pion/rtp" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" ) @@ -27,7 +28,9 @@ var opusCapability = webrtc.RTPCodecCapability{ // as the corresponding video source. type AudioSource struct { engine *Engine - track *webrtc.TrackLocalStaticSample + track webrtc.TrackLocal + samples *webrtc.TrackLocalStaticSample + packets *webrtc.TrackLocalStaticRTP encoder *nativeaudio.Encoder capacity int @@ -62,7 +65,30 @@ func (engine *Engine) NewAudioSource(capacity, bitrate int) (*AudioSource, error return nil, err } return &AudioSource{ - engine: engine, track: track, encoder: encoder, + engine: engine, track: track, samples: track, encoder: encoder, + capacity: capacity, edges: make(map[*Edge]bool), + }, nil +} + +func (engine *Engine) NewRelayedAudioSource(capacity int) (*AudioSource, error) { + if capacity < 1 || capacity > 4 { + return nil, errors.New("native audio 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( + opusCapability, + "audio", + "screener-native", + ) + if err != nil { + return nil, err + } + return &AudioSource{ + engine: engine, track: track, packets: track, capacity: capacity, edges: make(map[*Edge]bool), }, nil } @@ -84,7 +110,37 @@ func (source *AudioSource) WritePCM(pcm []byte, duration time.Duration) error { return err } source.bytes.Add(uint64(len(packet))) - return source.track.WriteSample(media.Sample{Data: packet, Duration: duration}) + return source.samples.WriteSample(media.Sample{Data: packet, Duration: duration}) +} + +func (source *AudioSource) WriteRTP(packet *rtp.Packet) error { + if source == nil || source.packets == nil || packet == nil { + return errors.New("native Opus RTP packet is invalid") + } + source.mu.Lock() + closed := source.closed + source.mu.Unlock() + if closed { + return errors.New("native audio source is closed") + } + source.bytes.Add(uint64(len(packet.Payload))) + forwarded := connectionNeutralRTP(packet) + return source.packets.WriteRTP(&forwarded) +} + +func (source *AudioSource) SetBitrate(bitrate int) error { + if source == nil || source.encoder == nil { + return errors.New("native audio source is unavailable") + } + 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") + } + return source.encoder.SetBitrate(bitrate) } func (source *AudioSource) snapshotBytes() uint64 { diff --git a/native/client/internal/mediaedge/candidate_gathering.go b/native/client/internal/mediaedge/candidate_gathering.go new file mode 100644 index 0000000..1dae6e3 --- /dev/null +++ b/native/client/internal/mediaedge/candidate_gathering.go @@ -0,0 +1,158 @@ +package mediaedge + +import ( + "context" + "strconv" + "sync" + + "github.com/pion/webrtc/v4" +) + +const ( + surveyCandidatePriority = 1_694_498_815 + mappedCandidatePriority = surveyCandidatePriority - (1 << 8) +) + +type localCandidateGathering struct { + engine *Engine + servers []webrtc.ICEServer + mappedPort int + emit func(*webrtc.ICECandidateInit) + ctx context.Context + cancel context.CancelFunc + once sync.Once + + mu sync.Mutex + pionDone bool + surveyDone bool + endSent bool + closed bool + mappedCandidates map[string]struct{} +} + +func newLocalCandidateGathering( + engine *Engine, + servers []webrtc.ICEServer, + mappedPort int, + emit func(*webrtc.ICECandidateInit), +) *localCandidateGathering { + ctx, cancel := context.WithCancel(engine.ctx) + surveyServers := stunServers(servers) + return &localCandidateGathering{ + engine: engine, servers: surveyServers, mappedPort: mappedPort, emit: emit, + ctx: ctx, cancel: cancel, surveyDone: len(surveyServers) == 0, + mappedCandidates: make(map[string]struct{}), + } +} + +func (gathering *localCandidateGathering) addPion(candidate *webrtc.ICECandidate) { + if candidate != nil { + value := candidate.ToJSON() + gathering.emitCandidate(&value) + return + } + gathering.mu.Lock() + gathering.pionDone = true + sendEnd := gathering.finishLocked() + gathering.mu.Unlock() + if sendEnd { + gathering.emit(nil) + } +} + +func (gathering *localCandidateGathering) emitMappedCandidate( + mapped mappedAddress, +) { + if gathering.mappedPort == 0 || mapped.address == "" || + mapped.port == gathering.mappedPort { + return + } + key := mapped.address + ":" + strconv.Itoa(gathering.mappedPort) + gathering.mu.Lock() + if _, exists := gathering.mappedCandidates[key]; exists { + gathering.mu.Unlock() + return + } + gathering.mappedCandidates[key] = struct{}{} + index := len(gathering.mappedCandidates) + gathering.mu.Unlock() + + mid := "0" + line := uint16(0) + gathering.emitCandidate(&webrtc.ICECandidateInit{ + Candidate: "candidate:mp" + strconv.Itoa(index) + + " 1 udp " + strconv.Itoa(mappedCandidatePriority) + " " + + mapped.address + " " + strconv.Itoa(gathering.mappedPort) + + " typ srflx raddr 0.0.0.0 rport " + + strconv.Itoa(gathering.engine.localPort), + SDPMid: &mid, SDPMLineIndex: &line, + }) +} + +func (gathering *localCandidateGathering) start() { + gathering.mu.Lock() + closed := gathering.closed + gathering.mu.Unlock() + if closed { + return + } + gathering.once.Do(func() { + go func() { + index := 0 + gathering.engine.surveySTUN( + gathering.ctx, + gathering.servers, + func(mapped mappedAddress) { + index++ + mid := "0" + line := uint16(0) + gathering.emitCandidate(&webrtc.ICECandidateInit{ + Candidate: "candidate:ns" + strconv.Itoa(index) + + " 1 udp " + strconv.Itoa(surveyCandidatePriority) + " " + + mapped.address + " " + + strconv.Itoa(mapped.port) + " typ srflx raddr 0.0.0.0 rport " + + strconv.Itoa(gathering.engine.localPort), + SDPMid: &mid, SDPMLineIndex: &line, + }) + gathering.emitMappedCandidate(mapped) + }, + ) + gathering.mu.Lock() + gathering.surveyDone = true + sendEnd := gathering.finishLocked() + gathering.mu.Unlock() + if sendEnd { + gathering.emit(nil) + } + }() + }) +} + +func (gathering *localCandidateGathering) close() { + gathering.mu.Lock() + if gathering.closed { + gathering.mu.Unlock() + return + } + gathering.closed = true + gathering.mu.Unlock() + gathering.cancel() +} + +func (gathering *localCandidateGathering) emitCandidate(candidate *webrtc.ICECandidateInit) { + gathering.mu.Lock() + closed := gathering.closed + gathering.mu.Unlock() + if !closed && gathering.emit != nil { + gathering.emit(candidate) + } +} + +func (gathering *localCandidateGathering) finishLocked() bool { + if gathering.closed || gathering.endSent || !gathering.pionDone || + !gathering.surveyDone || gathering.emit == nil { + return false + } + gathering.endSent = true + return true +} diff --git a/native/client/internal/mediaedge/edge.go b/native/client/internal/mediaedge/edge.go index 1263997..6bea973 100644 --- a/native/client/internal/mediaedge/edge.go +++ b/native/client/internal/mediaedge/edge.go @@ -3,8 +3,11 @@ package mediaedge import ( "errors" "io" + "strconv" + "strings" "sync" + "github.com/pion/ice/v4" "github.com/pion/rtcp" "github.com/pion/webrtc/v4" ) @@ -41,6 +44,8 @@ type Edge struct { closed bool qualityMu sync.Mutex qualityBaseline qualityBaseline + answerMu sync.Mutex + localCandidates *localCandidateGathering } func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error) { @@ -51,8 +56,9 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error 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() + mappedPort := 0 + if engine.portMapping != nil && !options.Local && len(options.ICEServers) > 0 { + mappedPort = engine.portMapping.Prepare() } if err := source.reserve(options.Local); err != nil { return nil, err @@ -63,9 +69,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error return nil, err } } - connection, err := engine.api.NewPeerConnection(webrtc.Configuration{ - ICEServers: options.ICEServers, - }) + connection, err := engine.api.NewPeerConnection(webrtc.Configuration{}) if err != nil { source.releaseReservation(options.Local) if options.Audio != nil { @@ -121,6 +125,14 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error return nil, err } edge.sender = sender + for _, transceiver := range connection.GetTransceivers() { + if transceiver.Sender() == sender { + if err = transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{videoCodecs[source.codec]}); err != nil { + _ = edge.Close() + return nil, err + } + } + } if options.Audio != nil { audioSender, audioErr := connection.AddTrack(options.Audio.track) if audioErr != nil { @@ -129,15 +141,14 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error } edge.audioSender = audioSender } + edge.localCandidates = newLocalCandidateGathering( + engine, + options.ICEServers, + mappedPort, + options.Events.LocalCandidate, + ) 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) - } + edge.localCandidates.addPion(candidate) }) connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { if state == webrtc.PeerConnectionStateConnected { @@ -172,13 +183,29 @@ func (edge *Edge) CreateOffer() (webrtc.SessionDescription, error) { if err = edge.connection.SetLocalDescription(offer); err != nil { return webrtc.SessionDescription{}, err } + edge.localCandidates.start() return offer, nil } func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error { + edge.answerMu.Lock() + defer edge.answerMu.Unlock() if answer.Type != webrtc.SDPTypeAnswer { return errors.New("native media edge requires an SDP answer") } + edge.mu.Lock() + if edge.closed { + edge.mu.Unlock() + return errors.New("native media edge is closed") + } + if edge.remoteDescriptionSet { + // The connection identity fences the answer to one edge. A repeated + // answer is a retransmission; the first applied description remains + // authoritative. + edge.mu.Unlock() + return nil + } + edge.mu.Unlock() if err := edge.connection.SetRemoteDescription(answer); err != nil { return err } @@ -188,14 +215,17 @@ func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error { edge.pendingCandidates = nil edge.mu.Unlock() for _, candidate := range pending { - if err := edge.connection.AddICECandidate(candidate); err != nil { - return err - } + // Candidates are disposable edge input. The connection state callback + // remains the authority for a real media failure. + _ = edge.connection.AddICECandidate(candidate) } return nil } func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { + if malformedRemoteCandidate(candidate) { + return nil + } value := webrtc.ICECandidateInit{} if candidate != nil { value = *candidate @@ -203,24 +233,37 @@ func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { edge.mu.Lock() if edge.closed { edge.mu.Unlock() - return errors.New("native media edge is closed") + return nil } if !edge.remoteDescriptionSet { if len(edge.pendingCandidates) >= maxPendingCandidates { edge.mu.Unlock() - return errors.New("native media ICE candidate queue is full") + return nil } edge.pendingCandidates = append(edge.pendingCandidates, value) edge.mu.Unlock() return nil } edge.mu.Unlock() - return edge.connection.AddICECandidate(value) + // A candidate may become stale between validation and delivery. Dropping + // that one edge input keeps the shared control session alive; ICE state + // events still report whether the edge itself can connect. + _ = edge.connection.AddICECandidate(value) + return nil +} + +func malformedRemoteCandidate(candidate *webrtc.ICECandidateInit) bool { + if candidate == nil || candidate.Candidate == "" { + return false + } + _, err := ice.UnmarshalCandidate(strings.TrimPrefix(candidate.Candidate, "candidate:")) + return err != nil } type SelectedPair struct { - Local webrtc.ICECandidateType - Remote webrtc.ICECandidateType + Local webrtc.ICECandidateType + Remote webrtc.ICECandidateType + NatTraversalPath string } func (edge *Edge) SelectedPair() (SelectedPair, error) { @@ -232,7 +275,31 @@ func (edge *Edge) SelectedPair() (SelectedPair, error) { 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 + return SelectedPair{ + Local: pair.Local.Typ, Remote: pair.Remote.Typ, + NatTraversalPath: selectedNatTraversalPath( + pair.Local.Foundation, + pair.Remote.Foundation, + ), + }, nil +} + +func selectedNatTraversalPath(foundations ...string) string { + for _, foundation := range foundations { + if len(foundation) < 3 || foundation[0] != 's' || + (foundation[1] != 'p' && foundation[1] != 'm') { + continue + } + if _, err := strconv.Atoi(foundation[2:]); err == nil { + return "predicted" + } + } + for _, foundation := range foundations { + if foundation != "" { + return "ordinary" + } + } + return "unknown" } func (edge *Edge) State() webrtc.PeerConnectionState { @@ -246,6 +313,9 @@ func (edge *Edge) Close() error { return nil } edge.closed = true + if edge.localCandidates != nil { + edge.localCandidates.close() + } edge.pendingCandidates = nil edge.mu.Unlock() edge.source.detach(edge) diff --git a/native/client/internal/mediaedge/edge_test.go b/native/client/internal/mediaedge/edge_test.go index 2658fa6..3287d40 100644 --- a/native/client/internal/mediaedge/edge_test.go +++ b/native/client/internal/mediaedge/edge_test.go @@ -12,10 +12,43 @@ import ( "github.com/pion/logging" "github.com/pion/rtcp" "github.com/pion/rtp" + "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" ) +func TestPortMappingCandidateUsesObservedPublicAddress(t *testing.T) { + var emitted []*webrtc.ICECandidateInit + gathering := &localCandidateGathering{ + engine: &Engine{localPort: 43210}, + mappedPort: 43211, + mappedCandidates: make(map[string]struct{}), + emit: func(candidate *webrtc.ICECandidateInit) { + emitted = append(emitted, candidate) + }, + } + mapped := mappedAddress{address: "203.0.113.7", port: 41000} + gathering.emitMappedCandidate(mapped) + gathering.emitMappedCandidate(mapped) + + if len(emitted) != 1 { + t.Fatalf("emitted candidates = %d, want one mapped", len(emitted)) + } + fields := strings.Fields(emitted[0].Candidate) + if len(fields) < 8 || fields[0] != "candidate:mp1" || + fields[3] != "1694498559" || + fields[4] != "203.0.113.7" || fields[5] != "43211" || + fields[7] != "srflx" { + t.Fatalf("mapped candidate = %q", emitted[0].Candidate) + } +} + func TestOneEncodedSourceFeedsTwoIndependentEdges(t *testing.T) { + for _, codec := range []string{"h264", "vp8"} { + t.Run(codec, func(t *testing.T) { testEncodedSourceFanout(t, codec) }) + } +} + +func testEncodedSourceFanout(t *testing.T, codec string) { engine, err := NewEngine(EngineOptions{ BindAddress: "127.0.0.1:0", IncludeLoopback: true, @@ -25,7 +58,7 @@ func TestOneEncodedSourceFeedsTwoIndependentEdges(t *testing.T) { } t.Cleanup(func() { _ = engine.Close() }) keyFrames := make(chan struct{}, 1) - source, err := engine.NewSource(2, func() { + source, err := engine.NewSource(codec, 2, func() { select { case keyFrames <- struct{}{}: default: @@ -60,11 +93,21 @@ func TestOneEncodedSourceFeedsTwoIndependentEdges(t *testing.T) { 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 { + if codec == "vp8" { + accessUnit = []byte{0x10, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01, 0} + } + if err = source.WriteVideo(accessUnit, time.Second, time.Second/30); err != nil { t.Fatal(err) } first := waitPacket(t, packetA) _ = waitPacket(t, packetB) + if codec == "vp8" { + packet := &codecs.VP8Packet{} + payload, parseErr := packet.Unmarshal(first.Payload) + if parseErr != nil || string(payload) != string(accessUnit) || packet.S != 1 { + t.Fatalf("VP8 packetizer changed its encoded source: %v", parseErr) + } + } if err = receiverA.WriteRTCP([]rtcp.Packet{ &rtcp.PictureLossIndication{MediaSSRC: first.SSRC}, }); err != nil { @@ -95,7 +138,7 @@ func TestRemoteCandidatesAreBoundedUntilTheAnswer(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - source, err := engine.NewSource(1, nil) + source, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -103,6 +146,14 @@ func TestRemoteCandidatesAreBoundedUntilTheAnswer(t *testing.T) { if err != nil { t.Fatal(err) } + if err = edge.AddRemoteCandidate(&webrtc.ICECandidateInit{ + Candidate: "candidate:garbage", + }); err != nil { + t.Fatalf("malformed candidate escaped the edge boundary: %v", err) + } + if len(edge.pendingCandidates) != 0 { + t.Fatalf("malformed candidate was queued: %d", len(edge.pendingCandidates)) + } 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", @@ -112,8 +163,47 @@ func TestRemoteCandidatesAreBoundedUntilTheAnswer(t *testing.T) { } 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") + }); err != nil { + t.Fatalf("excess candidate was not discarded: %v", err) + } + if len(edge.pendingCandidates) != maxPendingCandidates { + t.Fatalf("candidate queue grew beyond the bound: %d", len(edge.pendingCandidates)) + } +} + +func TestSelectedNatTraversalPathKeepsOnlyAnonymousProvenance(t *testing.T) { + if got := selectedNatTraversalPath("ordinary", "sp3"); got != "predicted" { + t.Fatalf("predicted path = %q", got) + } + if got := selectedNatTraversalPath("ordinary", "remote"); got != "ordinary" { + t.Fatalf("ordinary path = %q", got) + } + if got := selectedNatTraversalPath("", ""); got != "unknown" { + t.Fatalf("unknown path = %q", got) + } +} + +func TestRepeatedAnswerIsIdempotent(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("h264", 1, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = source.Close() }) + edge, receiver, _ := connectedReceiver(t, engine, source, "repeat-answer") + t.Cleanup(func() { _ = receiver.Close() }) + answer := receiver.LocalDescription() + if answer == nil { + t.Fatal("receiver did not retain its answer") + } + if err = edge.SetAnswer(*answer); err != nil { + t.Fatalf("repeated answer was not idempotent: %v", err) } } @@ -125,7 +215,7 @@ func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - source, err := engine.NewSource(1, nil) + source, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -136,18 +226,18 @@ func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) { start := 10 * time.Second frameDuration := time.Second / 30 - if err = source.WriteH264(accessUnit, start, frameDuration); err != nil { + if err = source.WriteVideo(accessUnit, start, frameDuration); err != nil { t.Fatal(err) } first := waitPacket(t, packets) - if err = source.WriteH264(accessUnit, start+frameDuration, frameDuration); err != nil { + if err = source.WriteVideo(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 { + if err = source.WriteVideo(accessUnit, start+5*time.Second, frameDuration); err != nil { t.Fatal(err) } third := waitPacket(t, packets) @@ -156,6 +246,34 @@ func TestCaptureTimestampsDriveTheRTPClock(t *testing.T) { } } +func TestNewCaptureGenerationKeepsTheRTPClockContinuous(t *testing.T) { + engine, err := NewEngine(EngineOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + source, err := engine.NewSource("h264", 1, nil) + if err != nil { + t.Fatal(err) + } + _, receiver, packets := connectedReceiver(t, engine, source, "generation-edge") + t.Cleanup(func() { _ = receiver.Close() }) + accessUnit := []byte{0, 0, 0, 1, 0x65, 0x88, 0x84, 0x00} + frameDuration := time.Second / 30 + if err = source.WriteVideo(accessUnit, 50*time.Second, frameDuration); err != nil { + t.Fatal(err) + } + first := waitPacket(t, packets) + source.BeginGeneration() + if err = source.WriteVideo(accessUnit, 100*time.Millisecond, frameDuration); err != nil { + t.Fatal(err) + } + second := waitPacket(t, packets) + if got := second.Timestamp - first.Timestamp; got < 2_999 || got > 3_001 { + t.Fatalf("generation timestamp delta = %d", got) + } +} + func TestOneLocalBridgeDoesNotConsumeRouteCapacity(t *testing.T) { engine, err := NewEngine(EngineOptions{ BindAddress: "127.0.0.1:0", IncludeLoopback: true, @@ -164,7 +282,7 @@ func TestOneLocalBridgeDoesNotConsumeRouteCapacity(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - source, err := engine.NewSource(1, nil) + source, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -214,7 +332,7 @@ func TestBandwidthObserverReceivesTransportFeedbackWithoutPacing(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - source, err := engine.NewSource(1, nil) + source, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -237,7 +355,7 @@ func TestBandwidthObserverReceivesTransportFeedbackWithoutPacing(t *testing.T) { deadline := time.Now().Add(5 * time.Second) captureTimestamp := time.Second for time.Now().Before(deadline) { - if err = source.WriteH264( + if err = source.WriteVideo( accessUnit, captureTimestamp, time.Second/30, @@ -272,7 +390,7 @@ func TestAudioUsesTheSamePeerConnectionAndCapacityAsVideo(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - video, err := engine.NewSource(1, nil) + video, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -313,7 +431,7 @@ func TestAudioSourceDeliversOpusOnTheVideoPeerConnection(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - video, err := engine.NewSource(1, nil) + video, err := engine.NewSource("h264", 1, nil) if err != nil { t.Fatal(err) } @@ -431,11 +549,10 @@ func newAudioReceiver(t *testing.T) *webrtc.PeerConnection { 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) + for _, codec := range []string{"h264", "vp8"} { + if err := mediaEngine.RegisterCodec(videoCodecs[codec], webrtc.RTPCodecTypeVideo); err != nil { + t.Fatal(err) + } } if includeAudio { if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ diff --git a/native/client/internal/mediaedge/engine.go b/native/client/internal/mediaedge/engine.go index 55a6432..240a72c 100644 --- a/native/client/internal/mediaedge/engine.go +++ b/native/client/internal/mediaedge/engine.go @@ -1,19 +1,26 @@ package mediaedge import ( + "context" "errors" "net" + "strconv" + "strings" "sync" + "time" "github.com/TNTcraftHIM/Screener/native/client/internal/portmapping" + "github.com/pion/ice/v4" "github.com/pion/interceptor" "github.com/pion/logging" "github.com/pion/rtp" "github.com/pion/rtp/codecs" + "github.com/pion/stun/v3" "github.com/pion/webrtc/v4" ) -const H264ProfileLevelID = "42c01f" +const H264ProfileLevelID = "42c033" +const stunSurveyTimeout = 5 * time.Second var h264Capability = webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeH264, @@ -28,18 +35,33 @@ var h264Capability = webrtc.RTPCodecCapability{ }, } +var vp8Capability = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeVP8, + ClockRate: videoClockRate, + RTCPFeedback: h264Capability.RTCPFeedback, +} + +var videoCodecs = map[string]webrtc.RTPCodecParameters{ + "h264": {RTPCodecCapability: h264Capability, PayloadType: h264PayloadType}, + "vp8": {RTPCodecCapability: vp8Capability, PayloadType: vp8PayloadType}, +} + type EngineOptions struct { BindAddress string IncludeLoopback bool PortMapping bool + InitialBitrate int } type Engine struct { api *webrtc.API - mux interface{ Close() error } + mux *ice.UniversalUDPMuxDefault listenAddress string + localPort int portMapping *portmapping.Mapping bandwidth *bandwidthObservers + ctx context.Context + cancel context.CancelFunc mu sync.Mutex edges map[*Edge]struct{} @@ -60,18 +82,20 @@ func NewEngine(options EngineOptions) (*Engine, error) { return nil, errors.New("native media UDP socket is unavailable") } loggerFactory := logging.NewDefaultLoggerFactory() - mux := webrtc.NewICEUDPMux(loggerFactory.NewLogger("screener-ice"), connection) + mux := ice.NewUniversalUDPMuxDefault(ice.UniversalUDPMuxParams{ + Logger: loggerFactory.NewLogger("screener-ice"), + UDPConn: 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 + for _, codec := range []string{"h264", "vp8"} { + if err = mediaEngine.RegisterCodec(videoCodecs[codec], webrtc.RTPCodecTypeVideo); err != nil { + _ = mux.Close() + return nil, err + } } if err = mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ RTPCodecCapability: opusCapability, @@ -81,7 +105,11 @@ func NewEngine(options EngineOptions) (*Engine, error) { return nil, err } registry := &interceptor.Registry{} - bandwidth, err := configureBandwidthObservers(mediaEngine, registry) + bandwidth, err := configureBandwidthObservers( + mediaEngine, + registry, + options.InitialBitrate, + ) if err != nil { _ = mux.Close() return nil, err @@ -90,6 +118,7 @@ func NewEngine(options EngineOptions) (*Engine, error) { _ = mux.Close() return nil, err } + ctx, cancel := context.WithCancel(context.Background()) engine := &Engine{ api: webrtc.NewAPI( webrtc.WithMediaEngine(mediaEngine), @@ -98,7 +127,10 @@ func NewEngine(options EngineOptions) (*Engine, error) { ), mux: mux, listenAddress: connection.LocalAddr().String(), + localPort: connection.LocalAddr().(*net.UDPAddr).Port, bandwidth: bandwidth, + ctx: ctx, + cancel: cancel, edges: make(map[*Edge]struct{}), } if options.PortMapping { @@ -107,11 +139,104 @@ func NewEngine(options EngineOptions) (*Engine, error) { return engine, nil } +type mappedAddress struct { + address string + port int +} + +func (engine *Engine) surveySTUN( + ctx context.Context, + servers []webrtc.ICEServer, + emit func(mappedAddress), +) { + surveyContext, cancel := context.WithTimeout(ctx, stunSurveyTimeout) + defer cancel() + type surveyTarget struct { + address *net.UDPAddr + } + targets := make([]surveyTarget, 0) + for _, server := range servers { + for _, rawURL := range server.URLs { + uri, err := stun.ParseURI(rawURL) + if err != nil || uri.Scheme != stun.SchemeTypeSTUN || + uri.Proto != stun.ProtoTypeUDP { + continue + } + addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", uri.Host) + if err != nil || len(addresses) == 0 { + continue + } + serverAddress, err := net.ResolveUDPAddr( + "udp4", + net.JoinHostPort(addresses[0].String(), strconv.Itoa(uri.Port)), + ) + if err == nil { + targets = append(targets, surveyTarget{address: serverAddress}) + } + } + } + if len(targets) == 0 { + return + } + results := make(chan mappedAddress, len(targets)) + for _, target := range targets { + go func(address *net.UDPAddr) { + mapped, err := engine.mux.GetXORMappedAddrContext( + surveyContext, + address, + stunSurveyTimeout, + ) + if err != nil || mapped == nil || mapped.IP.To4() == nil || + mapped.Port < 1 || mapped.Port > 65_535 { + return + } + select { + case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port}: + case <-surveyContext.Done(): + } + }(target.address) + } + seen := map[string]struct{}{} + for remaining := len(targets); remaining > 0; remaining-- { + select { + case value := <-results: + key := value.address + ":" + strconv.Itoa(value.port) + if _, found := seen[key]; found { + continue + } + seen[key] = struct{}{} + emit(value) + case <-surveyContext.Done(): + return + } + } +} + +func stunServers(servers []webrtc.ICEServer) []webrtc.ICEServer { + result := make([]webrtc.ICEServer, 0, len(servers)) + for _, server := range servers { + urls := make([]string, 0, len(server.URLs)) + for _, rawURL := range server.URLs { + if strings.HasPrefix(strings.ToLower(rawURL), "stun:") { + urls = append(urls, rawURL) + } + } + if len(urls) > 0 { + result = append(result, webrtc.ICEServer{URLs: urls}) + } + } + return result +} + func (engine *Engine) ListenAddress() string { return engine.listenAddress } -func (engine *Engine) NewSource(capacity int, requestKeyFrame func()) (*Source, error) { +func (engine *Engine) NewSource(codec string, capacity int, requestKeyFrame func()) (*Source, error) { + parameters, supported := videoCodecs[codec] + if !supported { + return nil, errors.New("native video codec is unsupported") + } if capacity < 1 || capacity > 4 { return nil, errors.New("native media source capacity is outside the route bound") } @@ -121,23 +246,28 @@ func (engine *Engine) NewSource(capacity int, requestKeyFrame func()) (*Source, return nil, errors.New("native media engine is closed") } track, err := webrtc.NewTrackLocalStaticRTP( - h264Capability, + parameters.RTPCodecCapability, "screen", "screener-native", ) if err != nil { return nil, err } + var payloader rtp.Payloader = &codecs.H264Payloader{} + if codec == "vp8" { + payloader = &codecs.VP8Payloader{EnablePictureID: true} + } return &Source{ engine: engine, track: track, + codec: codec, packetizer: rtp.NewPacketizer( - h264PacketMTU, - h264PayloadType, + videoPacketMTU, + uint8(parameters.PayloadType), 0, - &codecs.H264Payloader{}, + payloader, rtp.NewRandomSequencer(), - h264ClockRate, + videoClockRate, ), capacity: capacity, requestKeyFrame: requestKeyFrame, @@ -168,6 +298,7 @@ func (engine *Engine) Close() error { return nil } engine.closed = true + engine.cancel() edges := make([]*Edge, 0, len(engine.edges)) for edge := range engine.edges { edges = append(edges, edge) diff --git a/native/client/internal/mediaedge/quality.go b/native/client/internal/mediaedge/quality.go index fc6fd78..171660f 100644 --- a/native/client/internal/mediaedge/quality.go +++ b/native/client/internal/mediaedge/quality.go @@ -10,9 +10,7 @@ import ( "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 +const defaultNativeSourceInitialBitrate = 3_000_000 type targetBitrateEstimator interface { GetTargetBitrate() int @@ -94,7 +92,7 @@ func (edge *Edge) QualitySample(now time.Time) (QualitySample, bool) { } target, observed := edge.bandwidth.targetBitrate() if edge.State() != webrtc.PeerConnectionStateConnected || !observed || - frames == 0 || bytes == 0 || video.width == 0 || video.height == 0 { + frames == 0 || bytes == 0 { return sample, true } sample.AvailableOutgoingKbps = float64(target) / 1000 @@ -126,11 +124,15 @@ type bandwidthObservers struct { func configureBandwidthObservers( media *webrtc.MediaEngine, registry *interceptor.Registry, + initialBitrate int, ) (*bandwidthObservers, error) { + if initialBitrate <= 0 { + initialBitrate = defaultNativeSourceInitialBitrate + } observers := &bandwidthObservers{pending: make(map[string]*bandwidthObserver)} factory, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) { return gcc.NewSendSideBWE( - gcc.SendSideBWEInitialBitrate(nativeSourceInitialBitrate), + gcc.SendSideBWEInitialBitrate(initialBitrate), gcc.SendSideBWEPacer(gcc.NewNoOpPacer()), ) }) diff --git a/native/client/internal/mediaedge/quality_test.go b/native/client/internal/mediaedge/quality_test.go index 3c08c86..cd7cf08 100644 --- a/native/client/internal/mediaedge/quality_test.go +++ b/native/client/internal/mediaedge/quality_test.go @@ -25,14 +25,14 @@ func TestQualitySampleUsesEstimatorCapacityAgainstEncodedPayload(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = engine.Close() }) - source, err := engine.NewSource(1, nil) + source, err := engine.NewSource("h264", 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) + // Encoded relay sources need no decoded dimensions to compare payload and capacity. started := time.Now() if _, ok := edge.QualitySample(started); ok { t.Fatal("first quality sample did not establish a baseline") diff --git a/native/client/internal/mediaedge/receiver.go b/native/client/internal/mediaedge/receiver.go new file mode 100644 index 0000000..000237c --- /dev/null +++ b/native/client/internal/mediaedge/receiver.go @@ -0,0 +1,316 @@ +package mediaedge + +import ( + "errors" + "io" + "strings" + "sync" + + "github.com/pion/rtcp" + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" +) + +type ReceiverEvents struct { + LocalCandidate func(*webrtc.ICECandidateInit) + ConnectionState func(webrtc.PeerConnectionState, *SelectedPair) +} + +type ReceiverOptions struct { + Offer webrtc.SessionDescription + ICEServers []webrtc.ICEServer + EdgeCapacity int + Events ReceiverEvents +} + +// Receiver owns one inbound WebRTC connection and exposes its encoded media as +// ordinary bounded Sources. Consumers can attach local playback and downstream +// P2P edges without adding a decoder or encoder. +type Receiver struct { + connection *webrtc.PeerConnection + source *Source + audioSource *AudioSource + events ReceiverEvents + localCandidates *localCandidateGathering + + mu sync.Mutex + closed bool + videoSSRC webrtc.SSRC +} + +func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.SessionDescription, error) { + if options.Offer.Type != webrtc.SDPTypeOffer || options.Offer.SDP == "" { + return nil, webrtc.SessionDescription{}, errors.New("native media receiver input is invalid") + } + hasAudio, err := offerSendsCodec(options.Offer.SDP, "audio", "opus") + if err != nil { + return nil, webrtc.SessionDescription{}, err + } + mappedPort := 0 + if engine.portMapping != nil && len(options.ICEServers) > 0 { + mappedPort = engine.portMapping.Prepare() + } + connection, err := engine.api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return nil, webrtc.SessionDescription{}, err + } + if err = connection.SetRemoteDescription(options.Offer); err != nil { + _ = connection.Close() + return nil, webrtc.SessionDescription{}, err + } + videoCodec, err := selectReceiverVideoCodec(connection) + if err != nil { + _ = connection.Close() + return nil, webrtc.SessionDescription{}, err + } + receiver := &Receiver{ + connection: connection, + events: options.Events, + } + receiver.source, err = engine.NewSource(videoCodec, options.EdgeCapacity, receiver.RequestKeyFrame) + if err != nil { + _ = connection.Close() + return nil, webrtc.SessionDescription{}, err + } + if hasAudio { + receiver.audioSource, err = engine.NewRelayedAudioSource(options.EdgeCapacity) + if err != nil { + _ = receiver.source.Close() + _ = connection.Close() + return nil, webrtc.SessionDescription{}, err + } + } + receiver.localCandidates = newLocalCandidateGathering( + engine, + options.ICEServers, + mappedPort, + options.Events.LocalCandidate, + ) + connection.OnICECandidate(receiver.localCandidates.addPion) + connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + if receiver.events.ConnectionState == nil { + return + } + var selected *SelectedPair + if state == webrtc.PeerConnectionStateConnected { + if pair, pairErr := receiver.SelectedPair(); pairErr == nil { + selected = &pair + } + } + receiver.events.ConnectionState(state, selected) + }) + connection.OnTrack(func(track *webrtc.TrackRemote, rtpReceiver *webrtc.RTPReceiver) { + go receiver.readRTCP(rtpReceiver) + receiver.consumeTrack(track, rtpReceiver) + }) + answer, err := receiver.createAnswer() + if err != nil { + _ = receiver.Close() + return nil, webrtc.SessionDescription{}, err + } + return receiver, answer, nil +} + +func (receiver *Receiver) Source() *Source { return receiver.source } +func (receiver *Receiver) AudioSource() *AudioSource { return receiver.audioSource } +func (receiver *Receiver) HasAudio() bool { return receiver.audioSource != nil } +func (receiver *Receiver) Codec() string { return receiver.source.Codec() } + +func (receiver *Receiver) createAnswer() (webrtc.SessionDescription, error) { + receiver.mu.Lock() + if receiver.closed { + receiver.mu.Unlock() + return webrtc.SessionDescription{}, errors.New("native media receiver is closed") + } + receiver.mu.Unlock() + answer, err := receiver.connection.CreateAnswer(nil) + if err != nil { + return webrtc.SessionDescription{}, err + } + if err = receiver.connection.SetLocalDescription(answer); err != nil { + return webrtc.SessionDescription{}, err + } + receiver.localCandidates.start() + if receiver.connection.LocalDescription() == nil { + return webrtc.SessionDescription{}, errors.New("native media receiver produced no SDP answer") + } + return *receiver.connection.LocalDescription(), nil +} + +func (receiver *Receiver) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { + if malformedRemoteCandidate(candidate) { + return nil + } + value := webrtc.ICECandidateInit{} + if candidate != nil { + value = *candidate + } + receiver.mu.Lock() + if receiver.closed { + receiver.mu.Unlock() + return nil + } + receiver.mu.Unlock() + _ = receiver.connection.AddICECandidate(value) + return nil +} + +func (receiver *Receiver) SelectedPair() (SelectedPair, error) { + for _, transceiver := range receiver.connection.GetTransceivers() { + inbound := transceiver.Receiver() + if inbound == nil || inbound.Transport() == nil || inbound.Transport().ICETransport() == nil { + continue + } + pair, err := inbound.Transport().ICETransport().GetSelectedCandidatePair() + if err == nil && pair != nil { + return SelectedPair{ + Local: pair.Local.Typ, Remote: pair.Remote.Typ, + NatTraversalPath: selectedNatTraversalPath( + pair.Local.Foundation, + pair.Remote.Foundation, + ), + }, nil + } + } + return SelectedPair{}, errors.New("native media receiver ICE pair is unavailable") +} + +func (receiver *Receiver) RequestKeyFrame() { + receiver.mu.Lock() + ssrc := receiver.videoSSRC + closed := receiver.closed + receiver.mu.Unlock() + if closed || ssrc == 0 { + return + } + _ = receiver.connection.WriteRTCP([]rtcp.Packet{ + &rtcp.PictureLossIndication{MediaSSRC: uint32(ssrc)}, + }) +} + +func (receiver *Receiver) Close() error { + receiver.mu.Lock() + if receiver.closed { + receiver.mu.Unlock() + return nil + } + receiver.closed = true + localCandidates := receiver.localCandidates + receiver.mu.Unlock() + if localCandidates != nil { + localCandidates.close() + } + _ = receiver.source.Close() + if receiver.audioSource != nil { + _ = receiver.audioSource.Close() + } + return receiver.connection.Close() +} + +func (receiver *Receiver) consumeTrack(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + if track == nil { + return + } + if track.Kind() == webrtc.RTPCodecTypeVideo { + if !strings.EqualFold(track.Codec().MimeType, videoCodecs[receiver.source.codec].MimeType) { + return + } + receiver.mu.Lock() + receiver.videoSSRC = track.SSRC() + receiver.mu.Unlock() + for { + packet, _, err := track.ReadRTP() + if err != nil { + if !errors.Is(err, io.EOF) { + return + } + return + } + // Pion completes all bindings; each edge owns its write failure. + _ = receiver.source.WriteRTP(packet) + } + } + if track.Kind() == webrtc.RTPCodecTypeAudio && receiver.audioSource != nil && + strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeOpus) { + for { + packet, _, err := track.ReadRTP() + if err != nil { + return + } + _ = receiver.audioSource.WriteRTP(packet) + } + } +} + +func (receiver *Receiver) readRTCP(rtpReceiver *webrtc.RTPReceiver) { + if rtpReceiver == nil { + return + } + for { + if _, _, err := rtpReceiver.ReadRTCP(); err != nil { + return + } + } +} + +func offerSendsCodec(raw, kind, codecName string) (bool, error) { + var description sdp.SessionDescription + if err := description.UnmarshalString(raw); err != nil { + return false, errors.New("native media receiver SDP is invalid") + } + for _, media := range description.MediaDescriptions { + if media.MediaName.Media != kind || media.MediaName.Port.Value == 0 { + continue + } + if _, inactive := media.Attribute("inactive"); inactive { + continue + } + if _, receiveOnly := media.Attribute("recvonly"); receiveOnly { + continue + } + for _, attribute := range media.Attributes { + if attribute.Key != "rtpmap" { + continue + } + fields := strings.Fields(attribute.Value) + if len(fields) < 2 || !containsString(media.MediaName.Formats, fields[0]) { + continue + } + if strings.HasPrefix(strings.ToLower(fields[1]), codecName+"/") { + return true, nil + } + } + } + return false, nil +} + +func selectReceiverVideoCodec(connection *webrtc.PeerConnection) (string, error) { + for _, transceiver := range connection.GetTransceivers() { + if transceiver.Kind() != webrtc.RTPCodecTypeVideo || transceiver.Receiver() == nil || + (transceiver.Direction() != webrtc.RTPTransceiverDirectionRecvonly && + transceiver.Direction() != webrtc.RTPTransceiverDirectionSendrecv) { + continue + } + // Pion has already matched the remote codecs and FMTP, preserving offer order. + for _, parameters := range transceiver.Receiver().GetParameters().Codecs { + name := strings.TrimPrefix(strings.ToLower(parameters.MimeType), "video/") + if _, supported := videoCodecs[name]; !supported { + continue + } + if err := transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{parameters}); err != nil { + return "", err + } + return name, nil + } + } + return "", errors.New("native media receiver offer has no supported video") +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/native/client/internal/mediaedge/receiver_test.go b/native/client/internal/mediaedge/receiver_test.go new file mode 100644 index 0000000..9241b84 --- /dev/null +++ b/native/client/internal/mediaedge/receiver_test.go @@ -0,0 +1,287 @@ +package mediaedge + +import ( + "strings" + "testing" + "time" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4" +) + +func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) { + for _, preferVP8 := range []bool{false, true} { + engine, err := NewEngine(EngineOptions{BindAddress: "127.0.0.1:0", IncludeLoopback: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + upstream, err := engine.api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = upstream.Close() }) + transceiver, err := upstream.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) + if err != nil { + t.Fatal(err) + } + preferences := []webrtc.RTPCodecParameters{videoCodecs["h264"], videoCodecs["vp8"]} + want := "h264" + if preferVP8 { + preferences[0], preferences[1] = preferences[1], preferences[0] + want = "vp8" + } + if err = transceiver.SetCodecPreferences(preferences); err != nil { + t.Fatal(err) + } + offer, err := upstream.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + receiver, answer, err := engine.NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = receiver.Close() }) + if receiver.Codec() != want || !strings.Contains(strings.ToLower(answer.SDP), want+"/90000") || + (strings.Contains(strings.ToLower(answer.SDP), "vp8/90000") && strings.Contains(strings.ToLower(answer.SDP), "h264/90000")) { + t.Fatalf("receiver codec %q does not match its single-codec answer", receiver.Codec()) + } + } +} + +func TestReceiverForwardsEncodedVideoToANativeEdge(t *testing.T) { + for _, codec := range []string{"h264", "vp8"} { + t.Run(codec, func(t *testing.T) { testReceiverForwarding(t, codec) }) + } +} + +func testReceiverForwarding(t *testing.T, codec string) { + engine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", + IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + + upstreamEngine, err := NewEngine(EngineOptions{ + BindAddress: "127.0.0.1:0", + IncludeLoopback: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = upstreamEngine.Close() }) + upstream, err := upstreamEngine.api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = upstream.Close() }) + track, err := webrtc.NewTrackLocalStaticRTP( + videoCodecs[codec].RTPCodecCapability, "screen", "upstream", + ) + if err != nil { + t.Fatal(err) + } + if _, err = upstream.AddTrack(track); err != nil { + t.Fatal(err) + } + for _, transceiver := range upstream.GetTransceivers() { + if transceiver.Kind() == webrtc.RTPCodecTypeVideo { + if err = transceiver.SetCodecPreferences([]webrtc.RTPCodecParameters{videoCodecs[codec]}); err != nil { + t.Fatal(err) + } + } + } + audioTrack, err := webrtc.NewTrackLocalStaticRTP( + opusCapability, "audio", "upstream", + ) + if err != nil { + t.Fatal(err) + } + if _, err = upstream.AddTrack(audioTrack); err != nil { + t.Fatal(err) + } + var nativeReceiver *Receiver + var upstreamCandidates []*webrtc.ICECandidateInit + var receiverCandidates []*webrtc.ICECandidateInit + remoteReady := false + upstream.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + value := candidate.ToJSON() + if nativeReceiver != nil && remoteReady { + _ = nativeReceiver.AddRemoteCandidate(&value) + } else { + upstreamCandidates = append(upstreamCandidates, &value) + } + }) + upstreamGathered := webrtc.GatheringCompletePromise(upstream) + offer, err := upstream.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + if err = upstream.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + waitSignal(t, upstreamGathered, "upstream ICE gathering") + + nativeReceiver, answer, err := engine.NewReceiver(ReceiverOptions{ + Offer: *upstream.LocalDescription(), + EdgeCapacity: 1, + Events: ReceiverEvents{ + LocalCandidate: func(candidate *webrtc.ICECandidateInit) { + if candidate != nil { + if remoteReady { + _ = upstream.AddICECandidate(*candidate) + } else { + receiverCandidates = append(receiverCandidates, candidate) + } + } + }, + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = nativeReceiver.Close() }) + if nativeReceiver.Codec() != codec || nativeReceiver.Source().Codec() != codec { + t.Fatalf("native receiver/source selected %q instead of %q", nativeReceiver.Codec(), codec) + } + if err = upstream.SetRemoteDescription(answer); err != nil { + t.Fatal(err) + } + remoteReady = true + for _, candidate := range upstreamCandidates { + _ = nativeReceiver.AddRemoteCandidate(candidate) + } + for _, candidate := range receiverCandidates { + _ = upstream.AddICECandidate(*candidate) + } + waitConnected(t, upstream, "native receiver") + + downstream, packets, audioPackets, err := connectedReceiverForSources( + t, engine, nativeReceiver.Source(), nativeReceiver.AudioSource(), "native-relay-edge", + ) + t.Cleanup(func() { _ = downstream.Close() }) + if err != nil { + t.Fatal(err) + } + want := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: uint8(videoCodecs[codec].PayloadType), + SequenceNumber: 7, + Timestamp: 90_000, + SSRC: 42, + Marker: true, + }, + Payload: []byte{0x65, 0x88, 0x84, 0x00}, + } + if codec == "vp8" { + want.Payload = []byte{0x10, 0x10, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01, 0} + } + if err = want.SetExtension(9, []byte{0xde, 0xad}); err != nil { + t.Fatal(err) + } + if err = track.WriteRTP(want); err != nil { + t.Fatal(err) + } + got := waitPacket(t, packets) + if string(got.Payload) != string(want.Payload) || !got.Marker || + got.GetExtension(9) != nil { + t.Fatalf("forwarded packet = %#v", got) + } + wantAudio := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, PayloadType: 111, SequenceNumber: 8, + Timestamp: 960, SSRC: 43, Marker: true, + }, + Payload: []byte{0xf8, 0xff, 0xfe}, + } + if err = wantAudio.SetExtension(9, []byte{0xbe, 0xef}); err != nil { + t.Fatal(err) + } + if err = audioTrack.WriteRTP(wantAudio); err != nil { + t.Fatal(err) + } + gotAudio := waitPacket(t, audioPackets) + if string(gotAudio.Payload) != string(wantAudio.Payload) || + gotAudio.GetExtension(9) != nil { + t.Fatalf("forwarded audio packet = %#v", gotAudio) + } + for index, input := range []struct { + track *webrtc.TrackLocalStaticRTP + media *rtp.Packet + packets <-chan *rtp.Packet + }{{track, want, packets}, {audioTrack, wantAudio, audioPackets}} { + padding := &rtp.Packet{Header: input.media.Header, PaddingSize: 4} + padding.Padding = true + padding.SequenceNumber++ + padding.Marker = false + if err = input.track.WriteRTP(padding); err != nil { + t.Fatal(err) + } + if packet := waitPacket(t, input.packets); !packet.Padding || packet.PaddingSize != padding.PaddingSize || len(packet.Payload) != 0 { + t.Fatalf("stream %d lost RTP padding continuity", index) + } + next := input.media.Clone() + next.SequenceNumber += 2 + next.Timestamp += 3_000 + if err = input.track.WriteRTP(next); err != nil { + t.Fatal(err) + } + if packet := waitPacket(t, input.packets); string(packet.Payload) != string(next.Payload) { + t.Fatalf("stream %d stopped delivering media after padding", index) + } + } +} + +func connectedReceiverForSources( + t *testing.T, + engine *Engine, + source *Source, + audio *AudioSource, + connectionID string, +) (*Edge, <-chan *rtp.Packet, <-chan *rtp.Packet, error) { + options := EdgeOptions{ConnectionID: connectionID, Audio: audio} + edge, err := engine.NewEdge(source, options) + if err != nil { + return nil, nil, nil, err + } + receiver := newReceiverWithAudio(t, audio != nil) + t.Cleanup(func() { _ = receiver.Close() }) + packets := make(chan *rtp.Packet, 4) + audioPackets := make(chan *rtp.Packet, 4) + receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + go func() { + for { + packet, _, readErr := track.ReadRTP() + if readErr != nil { + return + } + if track.Kind() == webrtc.RTPCodecTypeAudio { + audioPackets <- packet + } else { + packets <- packet + } + } + }() + }) + connectEdgeToReceiver(t, edge, receiver) + return edge, packets, audioPackets, nil +} + +func waitConnected(t *testing.T, connection *webrtc.PeerConnection, label string) { + deadline := time.Now().Add(5 * time.Second) + for connection.ConnectionState() != webrtc.PeerConnectionStateConnected { + if time.Now().After(deadline) { + t.Fatalf("%s state = %s", label, connection.ConnectionState()) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/native/client/internal/mediaedge/source.go b/native/client/internal/mediaedge/source.go index 9a7d06e..aac9e1d 100644 --- a/native/client/internal/mediaedge/source.go +++ b/native/client/internal/mediaedge/source.go @@ -11,9 +11,10 @@ import ( ) const maxAccessUnitBytes = 4 * 1024 * 1024 -const h264ClockRate = 90_000 -const h264PacketMTU = 1200 +const videoClockRate = 90_000 +const videoPacketMTU = 1200 const h264PayloadType = 102 +const vp8PayloadType = 96 var ErrSourceCapacity = errors.New("native media source capacity is exhausted") var ErrInvalidVideoTimestamp = errors.New("native video timestamp is not monotonic") @@ -21,6 +22,7 @@ var ErrInvalidVideoTimestamp = errors.New("native video timestamp is not monoton type Source struct { engine *Engine track *webrtc.TrackLocalStaticRTP + codec string packetizer rtp.Packetizer capacity int requestKeyFrame func() @@ -34,19 +36,24 @@ type Source struct { hasTimestamp bool lastTimestamp time.Duration timestampRemainder float64 + rebasePending bool + rebaseInput time.Duration + rebaseOutput time.Duration frames atomic.Uint64 bytes atomic.Uint64 format atomic.Uint64 } -func (source *Source) WriteH264( +func (source *Source) Codec() string { return source.codec } + +func (source *Source) WriteVideo( 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") + return errors.New("native video access unit is invalid") } source.writeMu.Lock() defer source.writeMu.Unlock() @@ -56,11 +63,22 @@ func (source *Source) WriteH264( if closed { return errors.New("native media source is closed") } + if source.rebasePending { + source.rebasePending = false + source.rebaseInput = timestamp + source.rebaseOutput = source.lastTimestamp + duration + timestamp = source.rebaseOutput + } else if source.rebaseOutput > 0 { + if timestamp < source.rebaseInput { + return ErrInvalidVideoTimestamp + } + timestamp = source.rebaseOutput + timestamp - source.rebaseInput + } if source.hasTimestamp { if timestamp <= source.lastTimestamp { return ErrInvalidVideoTimestamp } - ticksFloat := (timestamp-source.lastTimestamp).Seconds()*h264ClockRate + + ticksFloat := (timestamp-source.lastTimestamp).Seconds()*videoClockRate + source.timestampRemainder ticks := uint64(ticksFloat) source.timestampRemainder = ticksFloat - float64(ticks) @@ -71,7 +89,7 @@ func (source *Source) WriteH264( source.lastTimestamp = timestamp packets := source.packetizer.Packetize(accessUnit, 0) if len(packets) == 0 { - return errors.New("native H264 access unit produced no RTP packets") + return errors.New("native video access unit produced no RTP packets") } source.frames.Add(1) source.bytes.Add(uint64(len(accessUnit))) @@ -82,6 +100,49 @@ func (source *Source) WriteH264( return result } +// WriteRTP forwards one already encoded video packet without decoding or +// re-encoding it. TrackLocalStaticRTP rewrites the negotiated SSRC and payload +// type independently for every bound edge. +func (source *Source) WriteRTP(packet *rtp.Packet) error { + if packet == nil { + return errors.New("native video RTP packet 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") + } + // Forward RTP padding to preserve sequence continuity without counting it as video. + if packet.Marker && len(packet.Payload) > 0 { + source.frames.Add(1) + } + source.bytes.Add(uint64(len(packet.Payload))) + forwarded := connectionNeutralRTP(packet) + return source.track.WriteRTP(&forwarded) +} + +func connectionNeutralRTP(packet *rtp.Packet) rtp.Packet { + forwarded := *packet + forwarded.Header = packet.Header + // Header extensions are negotiated per PeerConnection. The outbound Pion + // interceptors add fresh TWCC using that edge's negotiated ID. + forwarded.Extension = false + forwarded.ExtensionProfile = 0 + forwarded.Extensions = nil + return forwarded +} + +func (source *Source) BeginGeneration() { + source.writeMu.Lock() + source.rebasePending = true + source.rebaseInput = 0 + source.rebaseOutput = 0 + source.writeMu.Unlock() +} + func (source *Source) SetFormat(width, height uint32) { source.format.Store(uint64(width)<<32 | uint64(height)) } diff --git a/native/client/internal/nativeaudio/encoder.go b/native/client/internal/nativeaudio/encoder.go index 977d062..9e5a94d 100644 --- a/native/client/internal/nativeaudio/encoder.go +++ b/native/client/internal/nativeaudio/encoder.go @@ -26,6 +26,13 @@ type Encoder struct { packet [MaxPacketBytes]byte } +func (encoder *Encoder) SetBitrate(bitrate int) error { + if encoder == nil || encoder.codec == nil || bitrate < 6_000 || bitrate > 510_000 { + return errors.New("native audio bitrate is outside the Opus range") + } + return encoder.codec.SetBitrate(bitrate) +} + 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") diff --git a/native/client/internal/nativeaudio/encoder_test.go b/native/client/internal/nativeaudio/encoder_test.go index 9168e87..82ab344 100644 --- a/native/client/internal/nativeaudio/encoder_test.go +++ b/native/client/internal/nativeaudio/encoder_test.go @@ -93,6 +93,21 @@ func TestNewEncoderRejectsOutOfRangeBitrate(t *testing.T) { } } +func TestEncoderUpdatesBitrateWithinTheSameGeneration(t *testing.T) { + encoder, err := NewEncoder(DefaultBitrate) + if err != nil { + t.Fatal(err) + } + for _, bitrate := range []int{64_000, 192_000, DefaultBitrate} { + if err = encoder.SetBitrate(bitrate); err != nil { + t.Fatalf("SetBitrate(%d) = %v", bitrate, err) + } + } + if err = encoder.SetBitrate(0); err == nil { + t.Fatal("invalid live bitrate was accepted") + } +} + func TestEncodeSteadyStateAllocationIsBounded(t *testing.T) { encoder, err := NewEncoder(DefaultBitrate) if err != nil { diff --git a/native/client/internal/nativecapture/probe.go b/native/client/internal/nativecapture/probe.go index d8ce712..6748392 100644 --- a/native/client/internal/nativecapture/probe.go +++ b/native/client/internal/nativecapture/probe.go @@ -16,7 +16,7 @@ import ( ) const ( - probeProtocol = 3 + probeProtocol = 4 probeTimeout = 3 * time.Second maxProbeOutputBytes = 64 * 1024 maxProbeErrorBytes = 4 * 1024 @@ -45,6 +45,7 @@ type Capabilities struct { VideoCapture bool `json:"videoCapture"` ProcessAudio bool `json:"processAudio"` SystemAudio bool `json:"systemAudio"` + SoftwareVP8 bool `json:"softwareVP8"` Adapters []Adapter `json:"adapters"` } @@ -53,6 +54,7 @@ type Summary struct { ProcessAudio bool SystemAudio bool HardwareH264 bool + SoftwareVP8 bool } func (capabilities Capabilities) Summary() Summary { @@ -60,6 +62,7 @@ func (capabilities Capabilities) Summary() Summary { Video: capabilities.VideoCapture, ProcessAudio: capabilities.ProcessAudio, SystemAudio: capabilities.SystemAudio, + SoftwareVP8: capabilities.SoftwareVP8, } for _, adapter := range capabilities.Adapters { if len(adapter.HardwareH264) > 0 { @@ -74,7 +77,7 @@ func (summary Summary) AudioFor(kind string) bool { if kind == "window" { return summary.ProcessAudio } - return kind == "display" && summary.SystemAudio + return (kind == "display" || kind == "picker") && summary.SystemAudio } func Discover(parent context.Context, executable string) (Capabilities, error) { @@ -122,6 +125,8 @@ func PackagedExecutable() string { name = "screener-client-capture.exe" case "darwin": name = "screener-client-capture" + case "linux": + name = "screener-client-capture" default: return "" } diff --git a/native/client/internal/nativecapture/probe_test.go b/native/client/internal/nativecapture/probe_test.go index 5ac64cb..9e6f5e8 100644 --- a/native/client/internal/nativecapture/probe_test.go +++ b/native/client/internal/nativecapture/probe_test.go @@ -5,7 +5,7 @@ import ( "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}"}]}]}` +const validProbe = `{"protocol":4,"platform":"windows","platformBuild":"26200","videoCapture":true,"processAudio":true,"systemAudio":true,"softwareVP8":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)) @@ -14,15 +14,15 @@ func TestDecodeProbeSeparatesCaptureAudioAndHardwareEncode(t *testing.T) { } summary := capabilities.Summary() if !summary.Video || !summary.ProcessAudio || !summary.SystemAudio || - !summary.HardwareH264 { + !summary.HardwareH264 || !summary.SoftwareVP8 { 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), + strings.Replace(validProbe, `"protocol":4`, `"protocol":4,"extra":true`, 1), + strings.Replace(validProbe, `"protocol":4`, `"protocol":1`, 1), validProbe + `{}`, strings.Replace(validProbe, `"adapters":[`, `"adapters":[{"index":0,"name":"GPU 2","identity":"0x0:0x2","hardwareH264":[]},`, 1), } @@ -35,7 +35,7 @@ func TestDecodeProbeRejectsUnknownTrailingAndDuplicateData(t *testing.T) { 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":[]}]}`, + `{"protocol":4,"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) diff --git a/native/client/internal/nativecapture/protocol.go b/native/client/internal/nativecapture/protocol.go index cc6f140..a101baf 100644 --- a/native/client/internal/nativecapture/protocol.go +++ b/native/client/internal/nativecapture/protocol.go @@ -19,6 +19,7 @@ const ( FramePCM FrameKind = 1 FrameH264 FrameKind = 2 FrameStatus FrameKind = 3 + FrameVP8 FrameKind = 4 ) type Frame struct { @@ -41,7 +42,7 @@ func readFrame(input io.Reader) (Frame, error) { maximum = maxStatusBytes } if string(header[:4]) != "SMED" || header[4] != 1 || header[7] != 0 || - (kind != FramePCM && kind != FrameH264 && kind != FrameStatus) || + (kind != FramePCM && kind != FrameH264 && kind != FrameVP8 && kind != FrameStatus) || size == 0 || size > maximum { return Frame{}, errors.New("native capture frame header is invalid") } @@ -52,7 +53,7 @@ func readFrame(input io.Reader) (Frame, error) { return Frame{}, errors.New("native capture status frame is invalid") } } else if duration100ns == 0 || (kind == FramePCM && header[6] != 0) || - (kind == FrameH264 && header[6] > 1) { + ((kind == FrameH264 || kind == FrameVP8) && header[6] > 1) { return Frame{}, errors.New("native capture media frame is invalid") } data := make([]byte, size) @@ -61,7 +62,7 @@ func readFrame(input io.Reader) (Frame, error) { } return Frame{ Kind: kind, - KeyFrame: kind == FrameH264 && header[6] == 1, + KeyFrame: (kind == FrameH264 || kind == FrameVP8) && header[6] == 1, Timestamp: time.Duration(timestamp100ns) * 100 * time.Nanosecond, Duration: time.Duration(duration100ns) * 100 * time.Nanosecond, Data: data, diff --git a/native/client/internal/nativecapture/protocol_test.go b/native/client/internal/nativecapture/protocol_test.go index fb241fa..eb74586 100644 --- a/native/client/internal/nativecapture/protocol_test.go +++ b/native/client/internal/nativecapture/protocol_test.go @@ -27,6 +27,11 @@ func TestReadFrameUsesTheBoundedVersionedEnvelope(t *testing.T) { !bytes.Equal(frame.Data, payload) { t.Fatalf("frame = %+v", frame) } + header[5] = byte(FrameVP8) + frame, err = readFrame(bytes.NewReader(append(header, payload...))) + if err != nil || frame.Kind != FrameVP8 || !frame.KeyFrame || !bytes.Equal(frame.Data, payload) { + t.Fatalf("VP8 frame = %+v, %v", frame, err) + } } func TestReadFrameRejectsInvalidKindsFlagsAndBounds(t *testing.T) { @@ -93,13 +98,16 @@ func TestCaptureTargetIdentityAndAudioScope(t *testing.T) { CreationTime: "456", Title: "Game", } display := CaptureTarget{Kind: "display", SourceID: "789", Title: "Display 1"} - if !validCaptureTarget(window) || !validCaptureTarget(display) { + picker := CaptureTarget{Kind: "picker", SourceID: "1", Title: "System picker"} + if !validCaptureTarget(window) || !validCaptureTarget(display) || + !validCaptureTarget(picker) { 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: "picker", SourceID: "1", PID: 42, Title: "System picker"}, {Kind: "other", SourceID: "789", Title: "Display 1"}, } { if validCaptureTarget(invalid) { @@ -109,7 +117,31 @@ func TestCaptureTargetIdentityAndAudioScope(t *testing.T) { audio := Summary{ProcessAudio: true, SystemAudio: true} windowWithoutProcess := Summary{SystemAudio: true} if !audio.AudioFor("window") || !audio.AudioFor("display") || + !audio.AudioFor("picker") || windowWithoutProcess.AudioFor("window") { t.Fatal("capture audio scope was not separated by target kind") } } + +func TestVideoProfileUsesTheProductBounds(t *testing.T) { + for _, profile := range []VideoProfile{ + {Width: 854, Height: 480, Framerate: 15, Bitrate: 2_000_000, Preference: "maintain-resolution"}, + {Width: 1280, Height: 720, Framerate: 30, Bitrate: 3_000_000, Preference: "balanced"}, + {Width: 1920, Height: 1080, Framerate: 60, Bitrate: 8_000_000, Preference: "maintain-framerate"}, + {Width: 2560, Height: 1440, Framerate: 60, Bitrate: 12_000_000, Preference: "balanced"}, + } { + if !profile.Valid() { + t.Fatalf("valid profile rejected: %+v", profile) + } + } + for _, profile := range []VideoProfile{ + {Width: 1920, Height: 1200, Framerate: 30, Bitrate: 5_000_000, Preference: "balanced"}, + {Width: 1920, Height: 1080, Framerate: 14, Bitrate: 5_000_000, Preference: "balanced"}, + {Width: 1920, Height: 1080, Framerate: 30, Bitrate: 12_000_001, Preference: "balanced"}, + {Width: 1920, Height: 1080, Framerate: 30, Bitrate: 5_000_000, Preference: "unknown"}, + } { + if profile.Valid() { + t.Fatalf("invalid profile accepted: %+v", profile) + } + } +} diff --git a/native/client/internal/nativecapture/stream.go b/native/client/internal/nativecapture/stream.go index 5f5957d..c25fc5e 100644 --- a/native/client/internal/nativecapture/stream.go +++ b/native/client/internal/nativecapture/stream.go @@ -5,15 +5,18 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "strconv" + "strings" "sync" "time" + "unicode/utf8" ) const ( captureStopTimeout = time.Second - maxPreviewBytes = 48 * 1024 + maxPreviewBytes = 192 * 1024 ) type audioCaptureState struct { @@ -31,8 +34,33 @@ type CaptureTarget struct { type VideoOptions struct { Target CaptureTarget + Codec string AdapterIndex uint32 EncoderIndex uint32 + Profile VideoProfile + RestoreToken string +} + +type VideoProfile struct { + Width uint32 + Height uint32 + Framerate uint32 + Bitrate uint32 + Preference string +} + +func (profile VideoProfile) Valid() bool { + validResolution := + (profile.Width == 854 && profile.Height == 480) || + (profile.Width == 1280 && profile.Height == 720) || + (profile.Width == 1920 && profile.Height == 1080) || + (profile.Width == 2560 && profile.Height == 1440) + validPreference := profile.Preference == "maintain-resolution" || + profile.Preference == "balanced" || + profile.Preference == "maintain-framerate" + return validResolution && profile.Framerate >= 15 && profile.Framerate <= 60 && + profile.Bitrate >= 2_000_000 && profile.Bitrate <= 12_000_000 && + validPreference } type Stream struct { @@ -95,10 +123,17 @@ func PreviewSource(parent context.Context, executable string, target CaptureTarg } func StartVideo(parent context.Context, executable string, options VideoOptions) (*Stream, error) { - if !validCaptureTarget(options.Target) { + if !validCaptureTarget(options.Target) || !options.Profile.Valid() || + (options.Codec != "auto" && options.Codec != "h264" && options.Codec != "vp8") || + len(options.RestoreToken) > 4096 || !utf8.ValidString(options.RestoreToken) || + strings.ContainsRune(options.RestoreToken, 0) { return nil, errors.New("native video target is invalid") } - return startStream(parent, executable, []string{ + environment := []string(nil) + if options.RestoreToken != "" { + environment = []string{"SCREENER_XDP_RESTORE_TOKEN=" + options.RestoreToken} + } + return startStreamWithEnvironment(parent, executable, []string{ "--capture-video", options.Target.Kind, options.Target.SourceID, @@ -108,8 +143,20 @@ func StartVideo(parent context.Context, executable string, options VideoOptions) strconv.FormatUint(uint64(options.AdapterIndex), 10), "--mft-index", strconv.FormatUint(uint64(options.EncoderIndex), 10), - "--protocol-v3", - }) + "--width", + strconv.FormatUint(uint64(options.Profile.Width), 10), + "--height", + strconv.FormatUint(uint64(options.Profile.Height), 10), + "--fps", + strconv.FormatUint(uint64(options.Profile.Framerate), 10), + "--bitrate", + strconv.FormatUint(uint64(options.Profile.Bitrate), 10), + "--preference", + options.Profile.Preference, + "--codec", + options.Codec, + "--protocol-v4", + }, environment) } func StartAudio(parent context.Context, executable string, target CaptureTarget) (*Stream, error) { @@ -175,6 +222,8 @@ func validCaptureTarget(target CaptureTarget) bool { return target.PID > 0 && positiveDecimal(target.CreationTime) case "display": return target.PID == 0 && target.CreationTime == "" + case "picker": + return target.PID == 0 && target.CreationTime == "" default: return false } @@ -210,6 +259,15 @@ func positiveDecimal(value string) bool { } func startStream(parent context.Context, executable string, arguments []string) (*Stream, error) { + return startStreamWithEnvironment(parent, executable, arguments, nil) +} + +func startStreamWithEnvironment( + parent context.Context, + executable string, + arguments []string, + environment []string, +) (*Stream, error) { if parent == nil { parent = context.Background() } @@ -218,6 +276,9 @@ func startStream(parent context.Context, executable string, arguments []string) } ctx, cancel := context.WithCancel(parent) command := exec.CommandContext(ctx, executable, arguments...) + if len(environment) > 0 { + command.Env = append(os.Environ(), environment...) + } stdout, err := command.StdoutPipe() if err != nil { cancel() diff --git a/native/client/internal/nativecontrol/session.go b/native/client/internal/nativecontrol/session.go index 8b5d4f3..d6a3831 100644 --- a/native/client/internal/nativecontrol/session.go +++ b/native/client/internal/nativecontrol/session.go @@ -17,6 +17,7 @@ import ( "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/TNTcraftHIM/Screener/native/client/internal/nativeviewer" "github.com/pion/webrtc/v4" ) @@ -39,12 +40,20 @@ type Session struct { cancel context.CancelFunc events chan any hostEvents chan nativehost.Event + viewerEvents chan nativeviewer.Event mu sync.Mutex host *nativehost.Session + viewer *nativeviewer.Session closed bool } +type outboundMediaSession interface { + SetAnswer(string, webrtc.SessionDescription) error + AddCandidate(string, *webrtc.ICECandidateInit) error + CloseEdge(string) +} + func New( captureProcess string, capabilities nativecapture.Capabilities, @@ -59,6 +68,7 @@ func New( cancel: cancel, events: make(chan any, 256), hostEvents: make(chan nativehost.Event, 256), + viewerEvents: make(chan nativeviewer.Event, 256), } go session.relayEvents() return session @@ -121,10 +131,57 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type || !identityPattern.MatchString(request.ShareID) || - request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity { + request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity || + (request.Codec != "auto" && request.Codec != "h264" && request.Codec != "vp8") || + (request.Codec == "vp8" && !session.capabilities.SoftwareVP8) || + !validQualitySettings(request.Profile) { return nil, errors.New("native start-share request is invalid") } return session.startShare(envelope, request) + case "update-share": + var request updateShareRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID) || + !validQualitySettings(request.Profile) { + return nil, errors.New("native update-share request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + if err := host.UpdateProfile(nativeQualityProfile(request.Profile)); err != nil { + return nil, err + } + return shareUpdatedResponse{ + responseEnvelope: response(envelope, "share-updated"), + ShareID: request.ShareID, + }, nil + case "replace-share-source": + var request replaceShareSourceRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID) { + return nil, errors.New("native replace-share-source request is invalid") + } + host := session.current(request.ShareID) + if host == nil { + return nil, errors.New("native share does not exist") + } + audio := request.Audio && session.capabilities.Summary().AudioFor( + request.Source.Kind, + ) + if err := host.ReplaceSource(nativecapture.VideoOptions{ + Target: request.Source, + AdapterIndex: request.AdapterIndex, + EncoderIndex: request.EncoderIndex, + }, audio); err != nil { + return nil, err + } + return shareSourceReplacedResponse{ + responseEnvelope: response(envelope, "share-source-replaced"), + ShareID: request.ShareID, + }, nil case "prepare-edge": var request prepareEdgeRequest if err := decodeStrict(payload, &request); err != nil || @@ -141,6 +198,51 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { return nil, errors.New("native prepare-local-edge request is invalid") } return session.prepareLocalEdge(envelope, request) + case "receive-offer": + var request receiveOfferRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) || + request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity || + len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes { + return nil, errors.New("native receive-offer request is invalid") + } + return session.receiveOffer(envelope, request) + case "receive-candidate": + var request receiveCandidateRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) || + !validCandidate(request.Candidate) { + return nil, errors.New("native receive-candidate request is invalid") + } + viewer := session.currentViewer(request.ShareID) + if viewer == nil { + return nil, errors.New("native Viewer session does not exist") + } + if err := viewer.AddReceiverCandidate(request.ConnectionID, request.Candidate); err != nil { + return nil, err + } + return response(envelope, "receive-candidate-accepted"), nil + case "close-receiver": + var request closeReceiverRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || + !validIdentities(request.ShareID, request.ConnectionID) { + return nil, errors.New("native close-receiver request is invalid") + } + if viewer := session.currentViewer(request.ShareID); viewer != nil { + viewer.CloseReceiver(request.ConnectionID) + } + return response(envelope, "receiver-closed"), nil + case "stop-receive": + var request stopReceiveRequest + if err := decodeStrict(payload, &request); err != nil || + request.Type != envelope.Type || !validIdentities(request.ShareID) { + return nil, errors.New("native stop-receive request is invalid") + } + session.stopViewer(request.ShareID) + return response(envelope, "receive-stopped"), nil case "edge-answer": var request edgeAnswerRequest if err := decodeStrict(payload, &request); err != nil || @@ -149,11 +251,11 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { 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") + media := session.currentMedia(request.ShareID) + if media == nil { + return nil, errors.New("native media session does not exist") } - if err := host.SetAnswer( + if err := media.SetAnswer( request.ConnectionID, webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: request.SDP}, ); err != nil { @@ -168,11 +270,11 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { !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") + media := session.currentMedia(request.ShareID) + if media == nil { + return nil, errors.New("native media session does not exist") } - if err := host.AddCandidate(request.ConnectionID, request.Candidate); err != nil { + if err := media.AddCandidate(request.ConnectionID, request.Candidate); err != nil { return nil, err } return response(envelope, "edge-candidate-accepted"), nil @@ -183,11 +285,9 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { !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") + if media := session.currentMedia(request.ShareID); media != nil { + media.CloseEdge(request.ConnectionID) } - host.CloseEdge(request.ConnectionID) return response(envelope, "edge-closed"), nil case "stop-share": var request stopShareRequest @@ -226,12 +326,17 @@ func (session *Session) Close() error { } session.closed = true host := session.host + viewer := session.viewer session.host = nil + session.viewer = nil session.mu.Unlock() session.cancel() if host != nil { _ = host.Close() } + if viewer != nil { + _ = viewer.Close() + } return nil } @@ -240,19 +345,23 @@ func (session *Session) startShare( request startShareRequest, ) (any, error) { session.mu.Lock() - if session.closed || session.host != nil { + if session.closed || session.host != nil || session.viewer != nil { session.mu.Unlock() return nil, errors.New("native share is already active") } session.mu.Unlock() + profile := nativeQualityProfile(request.Profile) host, err := nativehost.Start(session.ctx, nativehost.Options{ ShareID: request.ShareID, CaptureProcess: session.captureProcess, Video: nativecapture.VideoOptions{ Target: request.Source, + Codec: request.Codec, AdapterIndex: request.AdapterIndex, EncoderIndex: request.EncoderIndex, + Profile: profile.Video, }, + Profile: profile, EdgeCapacity: request.EdgeCapacity, AudioEnabled: request.Audio && session.capabilities.Summary().AudioFor( request.Source.Kind, @@ -264,7 +373,7 @@ func (session *Session) startShare( return nil, err } session.mu.Lock() - if session.closed || session.host != nil { + if session.closed || session.host != nil || session.viewer != nil { session.mu.Unlock() _ = host.Close() return nil, errors.New("native share is unavailable") @@ -276,9 +385,47 @@ func (session *Session) startShare( responseEnvelope: response(envelope, "share-started"), ShareID: request.ShareID, Audio: host.HasAudio(), + Codec: host.Codec(), }, nil } +func validQualitySettings(settings qualitySettings) bool { + return nativeQualityProfile(settings).Valid() +} + +func nativeQualityProfile(settings qualitySettings) nativehost.QualityProfile { + width, height := uint32(0), uint32(0) + switch settings.Resolution { + case "480p": + width, height = 854, 480 + case "720p": + width, height = 1280, 720 + case "1080p": + width, height = 1920, 1080 + case "1440p": + width, height = 2560, 1440 + } + audioBitrate := 0 + switch settings.ScreenAudioQuality { + case "", "music": + audioBitrate = 128_000 + case "saver": + audioBitrate = 64_000 + case "very-high": + audioBitrate = 192_000 + } + return nativehost.QualityProfile{ + Video: nativecapture.VideoProfile{ + Width: width, + Height: height, + Framerate: settings.MaxFramerate, + Bitrate: settings.MaxBitrate, + Preference: settings.DegradationPreference, + }, + AudioBitrate: audioBitrate, + } +} + func (session *Session) prepareEdge( envelope requestEnvelope, request prepareEdgeRequest, @@ -287,11 +434,19 @@ func (session *Session) prepareEdge( if err != nil { return nil, err } - host := session.current(request.ShareID) - if host == nil { - return nil, errors.New("native share does not exist") + var offer webrtc.SessionDescription + if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" { + offer, err = host.PrepareEdge(request.ConnectionID, servers) + } else if viewer := session.currentViewer(request.ShareID); viewer != nil && + identityPattern.MatchString(request.SourceConnectionID) { + offer, err = viewer.PrepareEdge( + request.SourceConnectionID, + request.ConnectionID, + servers, + ) + } else { + return nil, errors.New("native media source does not exist") } - offer, err := host.PrepareEdge(request.ConnectionID, servers) if err != nil { return nil, err } @@ -307,11 +462,19 @@ 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") + var offer webrtc.SessionDescription + var err error + if host := session.current(request.ShareID); host != nil && request.SourceConnectionID == "" { + offer, err = host.PrepareLocalEdge(request.ConnectionID) + } else if viewer := session.currentViewer(request.ShareID); viewer != nil && + identityPattern.MatchString(request.SourceConnectionID) { + offer, err = viewer.PrepareLocalEdge( + request.SourceConnectionID, + request.ConnectionID, + ) + } else { + return nil, errors.New("native media source does not exist") } - offer, err := host.PrepareLocalEdge(request.ConnectionID) if err != nil { return nil, err } @@ -323,6 +486,36 @@ func (session *Session) prepareLocalEdge( }, nil } +func (session *Session) receiveOffer( + envelope requestEnvelope, + request receiveOfferRequest, +) (any, error) { + servers, err := pionICEServers(request.ICEServers) + if err != nil { + return nil, err + } + viewer, err := session.ensureViewer(request.ShareID, request.EdgeCapacity) + if err != nil { + return nil, err + } + answer, audio, codec, err := viewer.AcceptOffer( + request.ConnectionID, + webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.SDP}, + servers, + ) + if err != nil { + return nil, err + } + return receiveAnswerResponse{ + responseEnvelope: response(envelope, "receive-answer"), + ShareID: request.ShareID, + ConnectionID: request.ConnectionID, + SDP: answer.SDP, + Audio: audio, + Codec: codec, + }, nil +} + func (session *Session) stopShare(shareID string) error { session.mu.Lock() host := session.host @@ -344,6 +537,78 @@ func (session *Session) current(shareID string) *nativehost.Session { return session.host } +func (session *Session) ensureViewer( + shareID string, + edgeCapacity int, +) (*nativeviewer.Session, error) { + session.mu.Lock() + if session.closed || session.host != nil { + session.mu.Unlock() + return nil, errors.New("native media role is unavailable") + } + if session.viewer != nil { + viewer := session.viewer + session.mu.Unlock() + if viewer.ShareID() != shareID { + return nil, errors.New("native Viewer session identity changed") + } + return viewer, nil + } + session.mu.Unlock() + viewer, err := nativeviewer.Start(session.ctx, nativeviewer.Options{ + ShareID: shareID, EdgeCapacity: edgeCapacity, + PortMapping: session.portMapping, Events: session.viewerEvents, + }) + if err != nil { + return nil, err + } + session.mu.Lock() + if session.closed || session.host != nil || session.viewer != nil { + session.mu.Unlock() + _ = viewer.Close() + return nil, errors.New("native media role is unavailable") + } + session.viewer = viewer + session.mu.Unlock() + return viewer, nil +} + +func (session *Session) currentViewer(shareID string) *nativeviewer.Session { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed || session.viewer == nil || session.viewer.ShareID() != shareID { + return nil + } + return session.viewer +} + +func (session *Session) currentMedia(shareID string) outboundMediaSession { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + if session.host != nil && session.host.ShareID() == shareID { + return session.host + } + if session.viewer != nil && session.viewer.ShareID() == shareID { + return session.viewer + } + return nil +} + +func (session *Session) stopViewer(shareID string) { + session.mu.Lock() + viewer := session.viewer + if viewer == nil || viewer.ShareID() != shareID { + session.mu.Unlock() + return + } + session.viewer = nil + session.mu.Unlock() + _ = viewer.Close() +} + func (session *Session) watchHost(host *nativehost.Session) { err, open := <-host.Done() session.mu.Lock() @@ -366,12 +631,55 @@ func (session *Session) relayEvents() { select { case event := <-session.hostEvents: session.emit(eventMessage(event)) + case event := <-session.viewerEvents: + session.emit(viewerEventMessage(event)) case <-session.ctx.Done(): return } } } +func viewerEventMessage(event nativeviewer.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, + NatTraversalPath: event.NatTraversalPath, + } + 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 edgeStateEvent{eventEnvelope: base, State: event.State} + } +} + func (session *Session) emit(event any) { select { case session.events <- event: @@ -393,9 +701,10 @@ func eventMessage(event nativehost.Event) any { return edgeStateEvent{eventEnvelope: base, State: event.State} case "edge-path": return edgePathEvent{ - eventEnvelope: base, - LocalType: event.LocalType, - RemoteType: event.RemoteType, + eventEnvelope: base, + LocalType: event.LocalType, + RemoteType: event.RemoteType, + NatTraversalPath: event.NatTraversalPath, } case "edge-quality": quality := event.Quality diff --git a/native/client/internal/nativecontrol/session_test.go b/native/client/internal/nativecontrol/session_test.go index dc6c50e..a31d058 100644 --- a/native/client/internal/nativecontrol/session_test.go +++ b/native/client/internal/nativecontrol/session_test.go @@ -31,7 +31,7 @@ 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":8,"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"}`, } { @@ -56,11 +56,11 @@ func TestResponseKeepsTheRequestIdentity(t *testing.T) { 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" { + valid := `{"version":8,"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 media source 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":[]}` + invalid := `{"version":8,"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) } @@ -70,7 +70,7 @@ 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"}}`, + `{"version":8,"id":"request_preview","type":"source-preview","source":{"kind":"display","sourceId":"65537","title":"Display 1"}}`, )) if err != nil { t.Fatal(err) @@ -80,3 +80,53 @@ func TestPreviewFailureReturnsAnAdvisoryResponse(t *testing.T) { t.Fatalf("preview response = %#v", value) } } + +func TestQualitySettingsMapOnceIntoNativeMedia(t *testing.T) { + settings := qualitySettings{ + Resolution: "1440p", MaxFramerate: 60, MaxBitrate: 12_000_000, + DegradationPreference: "maintain-framerate", + ScreenAudioQuality: "very-high", + } + profile := nativeQualityProfile(settings) + if !validQualitySettings(settings) || profile.Video.Width != 2560 || + profile.Video.Height != 1440 || profile.Video.Framerate != 60 || + profile.Video.Bitrate != 12_000_000 || + profile.Video.Preference != "maintain-framerate" || + profile.AudioBitrate != 192_000 { + t.Fatalf("native profile = %+v", profile) + } + settings.Resolution = "2160p" + if validQualitySettings(settings) { + t.Fatal("unsupported resolution was accepted") + } +} + +func TestUpdateShareRequiresTheCurrentStrictProfile(t *testing.T) { + session := New("missing-capture-process", nativecapture.Capabilities{}, false) + t.Cleanup(func() { _ = session.Close() }) + valid := `{"version":8,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"}}` + if _, err := session.Handle(t.Context(), []byte(valid)); err == nil || + err.Error() != "native share does not exist" { + t.Fatalf("valid update stopped at wrong boundary: %v", err) + } + invalid := `{"version":8,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music","extra":true}}` + if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil || + err.Error() != "native update-share request is invalid" { + t.Fatalf("extended update was accepted: %v", err) + } +} + +func TestReplaceShareSourceKeepsOneStrictTargetShape(t *testing.T) { + session := New("missing-capture-process", nativecapture.Capabilities{}, false) + t.Cleanup(func() { _ = session.Close() }) + valid := `{"version":8,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0}` + if _, err := session.Handle(t.Context(), []byte(valid)); err == nil || + err.Error() != "native share does not exist" { + t.Fatalf("valid source replacement stopped at wrong boundary: %v", err) + } + invalid := `{"version":8,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0,"extra":true}` + if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil || + err.Error() != "native replace-share-source request is invalid" { + t.Fatalf("extended source replacement was accepted: %v", err) + } +} diff --git a/native/client/internal/nativecontrol/wire.go b/native/client/internal/nativecontrol/wire.go index 1a0a501..442726e 100644 --- a/native/client/internal/nativecontrol/wire.go +++ b/native/client/internal/nativecontrol/wire.go @@ -27,6 +27,14 @@ type sourcePreviewRequest struct { Source nativecapture.CaptureTarget `json:"source"` } +type qualitySettings struct { + Resolution string `json:"resolution"` + MaxFramerate uint32 `json:"maxFramerate"` + MaxBitrate uint32 `json:"maxBitrate"` + DegradationPreference string `json:"degradationPreference"` + ScreenAudioQuality string `json:"screenAudioQuality,omitempty"` +} + type startShareRequest struct { Version int `json:"version"` ID string `json:"id"` @@ -37,6 +45,27 @@ type startShareRequest struct { AdapterIndex uint32 `json:"adapterIndex"` EncoderIndex uint32 `json:"encoderIndex"` EdgeCapacity int `json:"edgeCapacity"` + Profile qualitySettings `json:"profile"` + Codec string `json:"codec"` +} + +type updateShareRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Profile qualitySettings `json:"profile"` +} + +type replaceShareSourceRequest 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"` } type stopShareRequest struct { @@ -59,15 +88,45 @@ type iceServer struct { } type prepareEdgeRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + SourceConnectionID string `json:"sourceConnectionId,omitempty"` + 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"` + SourceConnectionID string `json:"sourceConnectionId,omitempty"` +} + +type receiveOfferRequest struct { Version int `json:"version"` ID string `json:"id"` Type string `json:"type"` ShareID string `json:"shareId"` ConnectionID string `json:"connectionId"` + EdgeCapacity int `json:"edgeCapacity"` ICEServers []iceServer `json:"iceServers"` + SDP string `json:"sdp"` } -type prepareLocalEdgeRequest struct { +type receiveCandidateRequest 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 closeReceiverRequest struct { Version int `json:"version"` ID string `json:"id"` Type string `json:"type"` @@ -75,6 +134,13 @@ type prepareLocalEdgeRequest struct { ConnectionID string `json:"connectionId"` } +type stopReceiveRequest struct { + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` +} + type edgeAnswerRequest struct { Version int `json:"version"` ID string `json:"id"` @@ -122,6 +188,17 @@ type shareStartedResponse struct { responseEnvelope ShareID string `json:"shareId"` Audio bool `json:"audio"` + Codec string `json:"codec"` +} + +type shareUpdatedResponse struct { + responseEnvelope + ShareID string `json:"shareId"` +} + +type shareSourceReplacedResponse struct { + responseEnvelope + ShareID string `json:"shareId"` } type edgeOfferResponse struct { @@ -131,6 +208,15 @@ type edgeOfferResponse struct { SDP string `json:"sdp"` } +type receiveAnswerResponse struct { + responseEnvelope + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + SDP string `json:"sdp"` + Audio bool `json:"audio"` + Codec string `json:"codec"` +} + type eventEnvelope struct { Version int `json:"version"` Type string `json:"type"` @@ -155,8 +241,9 @@ type edgeStateEvent struct { type edgePathEvent struct { eventEnvelope - LocalType string `json:"localType"` - RemoteType string `json:"remoteType"` + LocalType string `json:"localType"` + RemoteType string `json:"remoteType"` + NatTraversalPath string `json:"natTraversalPath"` } type edgeQualityEvent struct { diff --git a/native/client/internal/nativehost/profile.go b/native/client/internal/nativehost/profile.go new file mode 100644 index 0000000..5b85e0e --- /dev/null +++ b/native/client/internal/nativehost/profile.go @@ -0,0 +1,15 @@ +package nativehost + +import "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + +type QualityProfile struct { + Video nativecapture.VideoProfile + AudioBitrate int +} + +func (profile QualityProfile) Valid() bool { + return profile.Video.Valid() && + (profile.AudioBitrate == 64_000 || + profile.AudioBitrate == 128_000 || + profile.AudioBitrate == 192_000) +} diff --git a/native/client/internal/nativehost/session.go b/native/client/internal/nativehost/session.go index 8c20e97..6b89f7b 100644 --- a/native/client/internal/nativehost/session.go +++ b/native/client/internal/nativehost/session.go @@ -6,11 +6,12 @@ import ( "encoding/json" "errors" "io" + "strings" "sync" "time" + "unicode/utf8" "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" ) @@ -21,6 +22,7 @@ const qualitySamplePeriod = 2 * time.Second type CaptureState struct { State string `json:"state"` HardwareOnly bool `json:"hardwareOnly"` + Codec string `json:"codec"` AdapterIndex *uint32 `json:"adapterIndex,omitempty"` AdapterName string `json:"adapterName,omitempty"` AdapterIdentity string `json:"adapterIdentity,omitempty"` @@ -31,23 +33,26 @@ type CaptureState struct { Width uint32 `json:"width,omitempty"` Height uint32 `json:"height,omitempty"` FPS uint32 `json:"fps,omitempty"` + RestoreToken string `json:"restoreToken,omitempty"` } type Event struct { - Type string - ShareID string - ConnectionID string - Candidate *webrtc.ICECandidateInit - State string - LocalType string - RemoteType string - Quality *mediaedge.QualitySample + Type string + ShareID string + ConnectionID string + Candidate *webrtc.ICECandidateInit + State string + LocalType string + RemoteType string + NatTraversalPath string + Quality *mediaedge.QualitySample } type Options struct { ShareID string CaptureProcess string Video nativecapture.VideoOptions + Profile QualityProfile AudioEnabled bool EdgeCapacity int BindAddress string @@ -56,23 +61,28 @@ type Options struct { } type Session struct { - shareID string - stream *nativecapture.Stream - audioStream *nativecapture.Stream - engine *mediaedge.Engine - source *mediaedge.Source - audioSource *mediaedge.AudioSource - events chan<- Event + shareID string + captureProcess string + videoOptions nativecapture.VideoOptions + profile QualityProfile + edgeCapacity int + 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 + mu sync.Mutex + updateMu sync.Mutex + edges map[string]*mediaedge.Edge + paused bool + closed bool } func Start(parent context.Context, options Options) (*Session, error) { @@ -82,10 +92,14 @@ func Start(parent context.Context, options Options) (*Session, error) { if options.ShareID == "" || len(options.ShareID) > 256 { return nil, errors.New("native share identity is invalid") } + if !options.Profile.Valid() || options.Video.Profile != options.Profile.Video { + return nil, errors.New("native share profile is invalid") + } engine, err := mediaedge.NewEngine(mediaedge.EngineOptions{ BindAddress: options.BindAddress, IncludeLoopback: true, PortMapping: options.PortMapping, + InitialBitrate: int(options.Profile.Video.Bitrate), }) if err != nil { return nil, err @@ -97,41 +111,32 @@ func Start(parent context.Context, options Options) (*Session, error) { } 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) - } + audioStream, _ = startAudioCapture( + 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), + shareID: options.ShareID, + captureProcess: options.CaptureProcess, + videoOptions: options.Video, + profile: options.Profile, + edgeCapacity: options.EdgeCapacity, + 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, + options.EdgeCapacity, options.Profile.AudioBitrate, ) if audioErr != nil { _ = audioStream.Close() @@ -142,6 +147,13 @@ func Start(parent context.Context, options Options) (*Session, error) { } } go session.run() + var timeout <-chan time.Time + var timer *time.Timer + if options.Video.Target.Kind != "picker" { + timer = time.NewTimer(startTimeout) + timeout = timer.C + defer timer.Stop() + } select { case err = <-session.ready: if err != nil { @@ -149,7 +161,7 @@ func Start(parent context.Context, options Options) (*Session, error) { return nil, err } return session, nil - case <-time.After(startTimeout): + case <-timeout: _ = session.Close() return nil, errors.New("native capture did not start in time") case <-parent.Done(): @@ -162,6 +174,15 @@ func (session *Session) ShareID() string { return session.shareID } +func (session *Session) Codec() string { + session.mu.Lock() + defer session.mu.Unlock() + if session.source == nil { + return "" + } + return session.source.Codec() +} + func (session *Session) HasAudio() bool { session.mu.Lock() defer session.mu.Unlock() @@ -212,8 +233,10 @@ func (session *Session) prepareEdge( if pair != nil { session.emit(Event{ Type: "edge-path", ShareID: session.shareID, - ConnectionID: connectionID, - LocalType: pair.Local.String(), RemoteType: pair.Remote.String(), + ConnectionID: connectionID, + LocalType: pair.Local.String(), + RemoteType: pair.Remote.String(), + NatTraversalPath: pair.NatTraversalPath, }) } }, @@ -253,7 +276,9 @@ func (session *Session) AddCandidate( ) error { edge := session.edge(connectionID) if edge == nil { - return errors.New("native media edge does not exist") + // Candidates can arrive after an edge has been retired. They are + // disposable input, not a reason to tear down the share session. + return nil } return edge.AddRemoteCandidate(candidate) } @@ -272,6 +297,188 @@ func (session *Session) SetPaused(paused bool) { } } +func (session *Session) UpdateProfile(profile QualityProfile) error { + if !profile.Valid() { + return errors.New("native share profile is invalid") + } + session.updateMu.Lock() + defer session.updateMu.Unlock() + + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return errors.New("native share is unavailable") + } + if session.profile == profile { + session.mu.Unlock() + return nil + } + previousProfile := session.profile + options := session.videoOptions + options.Profile = profile.Video + session.mu.Unlock() + if profile.Video == previousProfile.Video { + if session.audioSource != nil && profile.AudioBitrate != previousProfile.AudioBitrate { + if err := session.audioSource.SetBitrate(profile.AudioBitrate); err != nil { + return err + } + } + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return errors.New("native share is unavailable") + } + session.profile = profile + session.mu.Unlock() + return nil + } + + replacement, state, err := session.prepareVideo(options) + if err != nil { + return err + } + if session.audioSource != nil && profile.AudioBitrate != previousProfile.AudioBitrate { + if err = session.audioSource.SetBitrate(profile.AudioBitrate); err != nil { + _ = replacement.Close() + return err + } + } + + return session.commitCapture( + options, + profile, + replacement, + state, + nil, + false, + ) +} + +func (session *Session) ReplaceSource( + options nativecapture.VideoOptions, + audioEnabled bool, +) error { + session.updateMu.Lock() + defer session.updateMu.Unlock() + + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return errors.New("native share is unavailable") + } + profile := session.profile + options.Codec = session.videoOptions.Codec + hasAudio := session.audioSource != nil && session.audioStream != nil + session.mu.Unlock() + if audioEnabled != hasAudio { + return errors.New("native source audio availability cannot change while sharing") + } + options.Profile = profile.Video + options.RestoreToken = "" + replacement, state, err := session.prepareVideo(options) + if err != nil { + return err + } + var replacementAudio *nativecapture.Stream + if hasAudio { + replacementAudio, err = startAudioCapture( + session.ctx, + session.captureProcess, + options.Target, + ) + if err != nil { + _ = replacement.Close() + return errors.New("native source audio could not start") + } + } + return session.commitCapture( + options, + profile, + replacement, + state, + replacementAudio, + hasAudio, + ) +} + +func startAudioCapture( + ctx context.Context, + captureProcess string, + target nativecapture.CaptureTarget, +) (*nativecapture.Stream, error) { + if target.Kind == "display" || target.Kind == "picker" { + return nativecapture.StartSystemAudio(ctx, captureProcess) + } + return nativecapture.StartAudio(ctx, captureProcess, target) +} + +func (session *Session) prepareVideo( + options nativecapture.VideoOptions, +) (*nativecapture.Stream, CaptureState, error) { + replacement, err := nativecapture.StartVideo( + session.ctx, + session.captureProcess, + options, + ) + if err != nil { + return nil, CaptureState{}, errors.New("native capture could not start") + } + state, err := waitForCaptureProfile( + session.ctx, + replacement, + options.Profile, + options.Codec, + options.Target.Kind == "picker", + ) + if err != nil { + _ = replacement.Close() + return nil, CaptureState{}, err + } + return replacement, state, nil +} + +func (session *Session) commitCapture( + options nativecapture.VideoOptions, + profile QualityProfile, + replacement *nativecapture.Stream, + state CaptureState, + replacementAudio *nativecapture.Stream, + replaceAudio bool, +) error { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + _ = replacement.Close() + if replacementAudio != nil { + _ = replacementAudio.Close() + } + return errors.New("native share is unavailable") + } + previous := session.stream + previousAudio := session.audioStream + if state.RestoreToken != "" { + options.RestoreToken = state.RestoreToken + } + session.stream = replacement + if replaceAudio { + session.audioStream = replacementAudio + } + session.videoOptions = options + session.profile = profile + session.source.SetFormat(state.Width, state.Height) + session.mu.Unlock() + + session.emit(Event{ + Type: "capture-state", ShareID: session.shareID, State: "active", + }) + _ = replacement.RequestKeyFrame() + _ = previous.Close() + if replaceAudio && previousAudio != nil { + _ = previousAudio.Close() + } + return nil +} + func (session *Session) CloseEdge(connectionID string) { session.mu.Lock() edge := session.edges[connectionID] @@ -287,6 +494,8 @@ func (session *Session) Done() <-chan error { } func (session *Session) Close() error { + session.updateMu.Lock() + defer session.updateMu.Unlock() session.mu.Lock() if session.closed { session.mu.Unlock() @@ -298,17 +507,21 @@ func (session *Session) Close() error { for _, edge := range session.edges { edges = append(edges, edge) } + stream := session.stream + audioStream := session.audioStream 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() + _ = stream.Close() + if audioStream != nil { + _ = audioStream.Close() + } + if session.source != nil { + _ = session.source.Close() } - _ = session.source.Close() if session.audioSource != nil { _ = session.audioSource.Close() } @@ -347,20 +560,29 @@ func (session *Session) run() { } result := <-videoDone session.cancel() - if session.audioStream != nil { - _ = session.audioStream.Close() + session.mu.Lock() + audioStream := session.audioStream + session.mu.Unlock() + if audioStream != nil { + _ = audioStream.Close() } <-audioDone <-qualityDone defer func() { - _ = session.source.Close() + if session.source != nil { + _ = session.source.Close() + } if session.audioSource != nil { _ = session.audioSource.Close() } _ = session.engine.Close() - _ = session.stream.Close() - if session.audioStream != nil { - _ = session.audioStream.Close() + session.mu.Lock() + stream := session.stream + audioStream := session.audioStream + session.mu.Unlock() + _ = stream.Close() + if audioStream != nil { + _ = audioStream.Close() } session.done <- result close(session.done) @@ -376,33 +598,87 @@ func (session *Session) runVideo() error { } return err } - for { - frame, err := session.stream.Read() + current := session.currentStream() + for current != nil { + frame, err := current.Read() if err != nil { + next := session.currentStream() + if next != nil && next != current { + session.source.BeginGeneration() + current = next + continue + } return fail(errors.New("native capture process stopped unexpectedly")) } + if next := session.currentStream(); next != current { + if next != nil { + session.source.BeginGeneration() + current = next + } + continue + } switch frame.Kind { case nativecapture.FrameStatus: status, statusErr := decodeCaptureState(frame.Data) if statusErr != nil { return fail(statusErr) } + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return fail(errors.New("native share stopped")) + } + if session.source == nil { + if session.videoOptions.Codec != "auto" && session.videoOptions.Codec != status.Codec { + session.mu.Unlock() + return fail(errors.New("native capture codec was not applied")) + } + source, sourceErr := session.engine.NewSource(status.Codec, session.edgeCapacity, func() { + if stream := session.currentStream(); stream != nil { + _ = stream.RequestKeyFrame() + } + }) + if sourceErr != nil { + session.mu.Unlock() + return fail(sourceErr) + } + session.source = source + session.videoOptions.Codec = status.Codec + } else if session.source.Codec() != status.Codec { + session.mu.Unlock() + return fail(errors.New("native capture codec changed within a share")) + } + session.mu.Unlock() session.emit(Event{Type: "capture-state", ShareID: session.shareID, State: status.State}) if status.State == "active" { + session.mu.Lock() + profile := session.videoOptions.Profile + if status.RestoreToken != "" { + session.videoOptions.RestoreToken = status.RestoreToken + } + session.mu.Unlock() + if status.Width != profile.Width || status.Height != profile.Height || + status.FPS != profile.Framerate { + return fail(errors.New("native capture profile was not applied")) + } session.source.SetFormat(status.Width, status.Height) } if !ready { ready = true session.ready <- nil } - case nativecapture.FrameH264: + case nativecapture.FrameH264, nativecapture.FrameVP8: + if session.source == nil || + (frame.Kind == nativecapture.FrameH264) != (session.source.Codec() == "h264") { + return fail(errors.New("native video frame does not match its source codec")) + } // 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( + writeErr := session.source.WriteVideo( frame.Data, frame.Timestamp, frame.Duration, @@ -417,6 +693,71 @@ func (session *Session) runVideo() error { return fail(errors.New("native video process emitted an unknown frame")) } } + return fail(errors.New("native capture process stopped unexpectedly")) +} + +func (session *Session) currentStream() *nativecapture.Stream { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + return session.stream +} + +func waitForCaptureProfile( + ctx context.Context, + stream *nativecapture.Stream, + profile nativecapture.VideoProfile, + codec string, + interactive bool, +) (CaptureState, error) { + type result struct { + state CaptureState + err error + } + ready := make(chan result, 1) + go func() { + for { + frame, err := stream.Read() + if err != nil { + ready <- result{err: errors.New("native capture profile did not become ready")} + return + } + if frame.Kind != nativecapture.FrameStatus { + continue + } + state, err := decodeCaptureState(frame.Data) + if err != nil { + ready <- result{err: err} + return + } + if state.State == "active" { + if state.Codec != codec || state.Width != profile.Width || state.Height != profile.Height || + state.FPS != profile.Framerate { + ready <- result{err: errors.New("native capture profile was not applied")} + return + } + ready <- result{state: state} + return + } + } + }() + var timeout <-chan time.Time + var timer *time.Timer + if !interactive { + timer = time.NewTimer(startTimeout) + timeout = timer.C + defer timer.Stop() + } + select { + case value := <-ready: + return value.state, value.err + case <-timeout: + return CaptureState{}, errors.New("native capture profile timed out") + case <-ctx.Done(): + return CaptureState{}, errors.New("native share stopped") + } } func (session *Session) runQuality() { @@ -446,11 +787,21 @@ func (session *Session) runQuality() { } func (session *Session) runAudio() { - for { - frame, err := session.audioStream.Read() - if err != nil || session.ctx.Err() != nil { + current := session.currentAudioStream() + for current != nil { + frame, err := current.Read() + if err != nil { + next := session.currentAudioStream() + if next != nil && next != current { + current = next + continue + } return } + if next := session.currentAudioStream(); next != current { + current = next + continue + } if frame.Kind != nativecapture.FramePCM { return } @@ -466,6 +817,15 @@ func (session *Session) runAudio() { } } +func (session *Session) currentAudioStream() *nativecapture.Stream { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + return session.audioStream +} + func (session *Session) emit(event Event) { if session.events == nil { return @@ -481,19 +841,33 @@ func decodeCaptureState(payload []byte) (CaptureState, error) { 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 { + (state.State != "starting" && state.State != "active") || + (state.Codec != "h264" && state.Codec != "vp8") || + state.HardwareOnly != (state.Codec == "h264") { return CaptureState{}, errors.New("native capture state is invalid") } if state.State == "starting" && - (state.AdapterIndex == nil || state.EncoderIndex == nil || + (state.AdapterIndex == nil || (state.Codec == "h264" && 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) { + ((state.Codec == "h264" && !validH264ProfileLevelID(state.ProfileLevelID)) || + (state.Codec == "vp8" && state.ProfileLevelID != "") || + state.Width == 0 || state.Height == 0 || state.FPS == 0 || + len(state.RestoreToken) > 4096 || !utf8.ValidString(state.RestoreToken) || + strings.ContainsRune(state.RestoreToken, 0)) { return CaptureState{}, errors.New("native capture active state is incomplete") } return state, nil } + +func validH264ProfileLevelID(value string) bool { + switch value { + case "42c01e", "42c01f", "42c020", "42c028", "42c029", "42c02a", "42c032", "42c033": + return true + default: + return false + } +} diff --git a/native/client/internal/nativehost/session_test.go b/native/client/internal/nativehost/session_test.go index 95f9d9b..115d9cf 100644 --- a/native/client/internal/nativehost/session_test.go +++ b/native/client/internal/nativehost/session_test.go @@ -4,27 +4,46 @@ 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"}`, + `{"state":"starting","hardwareOnly":true,"codec":"h264","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}`, + `{"state":"active","hardwareOnly":true,"codec":"h264","profileLevelId":"42c01f","width":1280,"height":720,"fps":30}`, )) if err != nil || active.State != "active" || active.ProfileLevelID != "42c01f" { t.Fatalf("active = %+v, %v", active, err) } + for _, payload := range []string{ + `{"state":"starting","hardwareOnly":false,"codec":"vp8","adapterIndex":0,"adapterName":"GPU","adapterIdentity":"0:1","encoderName":"libvpx VP8","encoderIdentity":"libvpx/v1.17.0"}`, + `{"state":"active","hardwareOnly":false,"codec":"vp8","width":1280,"height":720,"fps":30}`, + `{"state":"active","hardwareOnly":true,"codec":"h264","profileLevelId":"42c01e","width":854,"height":480,"fps":15}`, + `{"state":"active","hardwareOnly":true,"codec":"h264","profileLevelId":"42c033","width":2560,"height":1440,"fps":60,"restoreToken":"portal-token"}`, + } { + if _, err = decodeCaptureState([]byte(payload)); err != nil { + t.Fatalf("valid profile state was rejected: %s: %v", payload, err) + } + } } func TestCaptureStateRejectsUnknownOrUnattributedState(t *testing.T) { for _, payload := range []string{ + `{"state":"active","hardwareOnly":true,"codec":"vp8","width":1280,"height":720,"fps":30}`, + `{"state":"active","hardwareOnly":false,"codec":"vp8","profileLevelId":"42c01f","width":1280,"height":720,"fps":30}`, `{"state":"active","hardwareOnly":false}`, `{"state":"ready","hardwareOnly":true}`, - `{"state":"active","hardwareOnly":true,"extra":true}`, + `{"state":"active","hardwareOnly":true,"codec":"h264","extra":true}`, } { if _, err := decodeCaptureState([]byte(payload)); err == nil { t.Fatalf("invalid state was accepted: %s", payload) } } } + +func TestCandidateForRetiredEdgeIsIgnored(t *testing.T) { + session := &Session{} + if err := session.AddCandidate("retired-edge", nil); err != nil { + t.Fatalf("stale candidate stopped the session: %v", err) + } +} diff --git a/native/client/internal/nativeviewer/session.go b/native/client/internal/nativeviewer/session.go new file mode 100644 index 0000000..aaab69e --- /dev/null +++ b/native/client/internal/nativeviewer/session.go @@ -0,0 +1,357 @@ +package nativeviewer + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge" + "github.com/pion/webrtc/v4" +) + +type Event struct { + Type string + ShareID string + ConnectionID string + Candidate *webrtc.ICECandidateInit + State string + LocalType string + RemoteType string + NatTraversalPath string + Quality *mediaedge.QualitySample +} + +type Options struct { + ShareID string + EdgeCapacity int + PortMapping bool + Events chan<- Event +} + +type sessionEdge struct { + edge *mediaedge.Edge + sourceConnectionID string +} + +// Session owns the native media implementation for one Browser Viewer. Room, +// participant, route, and recovery authority remain in the Browser/server. +type Session struct { + shareID string + edgeCapacity int + engine *mediaedge.Engine + events chan<- Event + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + receivers map[string]*mediaedge.Receiver + edges map[string]sessionEdge + closed bool +} + +func Start(parent context.Context, options Options) (*Session, error) { + if parent == nil { + parent = context.Background() + } + if options.ShareID == "" || len(options.ShareID) > 256 || + options.EdgeCapacity < 1 || options.EdgeCapacity > 3 { + return nil, errors.New("native Viewer session input is invalid") + } + engine, err := mediaedge.NewEngine(mediaedge.EngineOptions{ + IncludeLoopback: true, + PortMapping: options.PortMapping, + }) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(parent) + session := &Session{ + shareID: options.ShareID, edgeCapacity: options.EdgeCapacity, + engine: engine, events: options.Events, ctx: ctx, cancel: cancel, + receivers: make(map[string]*mediaedge.Receiver), + edges: make(map[string]sessionEdge), + } + go session.runQuality() + return session, nil +} + +func (session *Session) ShareID() string { return session.shareID } + +func (session *Session) AcceptOffer( + connectionID string, + offer webrtc.SessionDescription, + iceServers []webrtc.ICEServer, +) (webrtc.SessionDescription, bool, string, error) { + if connectionID == "" || len(connectionID) > 256 { + return webrtc.SessionDescription{}, false, "", errors.New("native Viewer receiver identity is invalid") + } + session.mu.Lock() + closed := session.closed + session.mu.Unlock() + if closed { + return webrtc.SessionDescription{}, false, "", errors.New("native Viewer session is closed") + } + session.CloseReceiver(connectionID) + receiver, answer, err := session.engine.NewReceiver(mediaedge.ReceiverOptions{ + Offer: offer, + ICEServers: iceServers, + EdgeCapacity: session.edgeCapacity, + Events: mediaedge.ReceiverEvents{ + 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(), + NatTraversalPath: pair.NatTraversalPath, + }) + } + }, + }, + }) + if err != nil { + return webrtc.SessionDescription{}, false, "", err + } + session.mu.Lock() + if session.closed || session.receivers[connectionID] != nil { + session.mu.Unlock() + _ = receiver.Close() + return webrtc.SessionDescription{}, false, "", errors.New("native Viewer receiver is unavailable") + } + session.receivers[connectionID] = receiver + session.mu.Unlock() + return answer, receiver.HasAudio(), receiver.Codec(), nil +} + +func (session *Session) AddReceiverCandidate( + connectionID string, + candidate *webrtc.ICECandidateInit, +) error { + receiver := session.receiver(connectionID) + if receiver == nil { + return nil + } + return receiver.AddRemoteCandidate(candidate) +} + +func (session *Session) PrepareLocalEdge( + sourceConnectionID string, + connectionID string, +) (webrtc.SessionDescription, error) { + return session.prepareEdge(sourceConnectionID, connectionID, nil, true) +} + +func (session *Session) PrepareEdge( + sourceConnectionID string, + connectionID string, + iceServers []webrtc.ICEServer, +) (webrtc.SessionDescription, error) { + return session.prepareEdge(sourceConnectionID, connectionID, iceServers, false) +} + +func (session *Session) prepareEdge( + sourceConnectionID string, + connectionID string, + iceServers []webrtc.ICEServer, + local bool, +) (webrtc.SessionDescription, error) { + receiver := session.receiver(sourceConnectionID) + if receiver == nil { + return webrtc.SessionDescription{}, errors.New("native Viewer source does not exist") + } + session.mu.Lock() + if session.closed || session.edges[connectionID].edge != nil { + session.mu.Unlock() + return webrtc.SessionDescription{}, errors.New("native Viewer edge is unavailable") + } + session.mu.Unlock() + edge, err := session.engine.NewEdge(receiver.Source(), mediaedge.EdgeOptions{ + ConnectionID: connectionID, + ICEServers: iceServers, + Audio: receiver.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(), + NatTraversalPath: pair.NatTraversalPath, + }) + } + }, + }, + }) + if err != nil { + return webrtc.SessionDescription{}, err + } + session.mu.Lock() + if session.closed || session.edges[connectionID].edge != nil || + session.receivers[sourceConnectionID] != receiver { + session.mu.Unlock() + _ = edge.Close() + return webrtc.SessionDescription{}, errors.New("native Viewer edge is unavailable") + } + session.edges[connectionID] = sessionEdge{ + edge: edge, sourceConnectionID: sourceConnectionID, + } + 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 Viewer 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 nil + } + return edge.AddRemoteCandidate(candidate) +} + +func (session *Session) CloseReceiver(connectionID string) { + session.mu.Lock() + receiver := session.receivers[connectionID] + delete(session.receivers, connectionID) + edges := make([]*mediaedge.Edge, 0) + for edgeID, owned := range session.edges { + if owned.sourceConnectionID == connectionID { + edges = append(edges, owned.edge) + delete(session.edges, edgeID) + } + } + session.mu.Unlock() + for _, edge := range edges { + _ = edge.Close() + } + if receiver != nil { + _ = receiver.Close() + } +} + +func (session *Session) CloseEdge(connectionID string) { + session.mu.Lock() + edge := session.edges[connectionID].edge + delete(session.edges, connectionID) + session.mu.Unlock() + if edge != nil { + _ = edge.Close() + } +} + +func (session *Session) Close() error { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return nil + } + session.closed = true + receivers := make([]*mediaedge.Receiver, 0, len(session.receivers)) + for _, receiver := range session.receivers { + receivers = append(receivers, receiver) + } + edges := make([]*mediaedge.Edge, 0, len(session.edges)) + for _, owned := range session.edges { + edges = append(edges, owned.edge) + } + session.receivers = make(map[string]*mediaedge.Receiver) + session.edges = make(map[string]sessionEdge) + session.mu.Unlock() + session.cancel() + for _, edge := range edges { + _ = edge.Close() + } + for _, receiver := range receivers { + _ = receiver.Close() + } + return session.engine.Close() +} + +func (session *Session) receiver(connectionID string) *mediaedge.Receiver { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + return session.receivers[connectionID] +} + +func (session *Session) edge(connectionID string) *mediaedge.Edge { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + return session.edges[connectionID].edge +} + +func (session *Session) emit(event Event) { + if session.events == nil { + return + } + select { + case session.events <- event: + case <-session.ctx.Done(): + } +} + +func (session *Session) runQuality() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case now := <-ticker.C: + session.mu.Lock() + edges := make([]*mediaedge.Edge, 0, len(session.edges)) + for _, owned := range session.edges { + edges = append(edges, owned.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 + } + } +} diff --git a/native/client/internal/portmapping/mapping.go b/native/client/internal/portmapping/mapping.go index 78bc867..b366685 100644 --- a/native/client/internal/portmapping/mapping.go +++ b/native/client/internal/portmapping/mapping.go @@ -38,6 +38,7 @@ type Mapping struct { gateway gateway attempted bool mapped bool + externalPort int deleteRequired bool renewAfter time.Time closed bool @@ -54,18 +55,24 @@ func Start(localPort int) *Mapping { return mapping } -func (mapping *Mapping) Prepare() { +func (mapping *Mapping) Prepare() int { <-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 + (mapping.attempted && !mapping.mapped) { + return 0 + } + if mapping.mapped && time.Now().Before(mapping.renewAfter) { + return mapping.externalPort } ctx, cancel := context.WithTimeout(context.Background(), attemptTimeout) defer cancel() mapping.mapPortLocked(ctx) + if !mapping.mapped { + return 0 + } + return mapping.externalPort } func (mapping *Mapping) Close() { @@ -80,6 +87,7 @@ func (mapping *Mapping) Close() { gateway := mapping.gateway deleteRequired := mapping.deleteRequired mapping.mapped = false + mapping.externalPort = 0 mapping.deleteRequired = false mapping.mu.Unlock() if gateway == nil || !deleteRequired { @@ -104,7 +112,6 @@ func (mapping *Mapping) discover(parent context.Context) { return } mapping.gateway = gateway - mapping.mapPortLocked(ctx) } func (mapping *Mapping) mapPortLocked(ctx context.Context) { @@ -114,9 +121,11 @@ func (mapping *Mapping) mapPortLocked(ctx context.Context) { ) if err != nil || externalPort < 1 || externalPort > 65535 { mapping.mapped = false + mapping.externalPort = 0 return } mapping.mapped = true + mapping.externalPort = externalPort 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 index cd46e20..a12e1b2 100644 --- a/native/client/internal/portmapping/mapping_test.go +++ b/native/client/internal/portmapping/mapping_test.go @@ -50,8 +50,12 @@ func TestMappingCreatesOnceAndReleases(t *testing.T) { } mapping := Start(43210) - mapping.Prepare() - mapping.Prepare() + if port := mapping.Prepare(); port != 43211 { + t.Fatalf("mapped port = %d, want 43211", port) + } + if port := mapping.Prepare(); port != 43211 { + t.Fatalf("cached mapped port = %d, want 43211", port) + } mapping.Close() mapping.Close() diff --git a/native/client/platform/darwin/capture/README.md b/native/client/platform/darwin/capture/README.md index e86dbc2..4389939 100644 --- a/native/client/platform/darwin/capture/README.md +++ b/native/client/platform/darwin/capture/README.md @@ -2,9 +2,12 @@ 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. +displays and windows, captures one exact source with the current product profile, +requires a VideoToolbox hardware H.264 encoder, and writes Annex-B Baseline H.264 +through the bounded `SMED` protocol. ScreenCaptureKit application or display +audio is converted to stereo PCM16 for the existing Go Opus boundary. The Go +session applies live profile changes by replacing that capture generation behind +the same Pion source. Build on Apple Silicon macOS 13 or newer: @@ -20,9 +23,11 @@ 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 +Recording permission; audio capture has its corresponding usage declaration. +The implementation follows Apple's ScreenCaptureKit, AudioToolbox, 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 index d8aa56e..97aca0c 100644 --- a/native/client/platform/darwin/capture/build.sh +++ b/native/client/platform/darwin/capture/build.sh @@ -13,6 +13,7 @@ xcrun swiftc \ -parse-as-library \ -swift-version 5 \ -target arm64-apple-macos13.0 \ + -framework AudioToolbox \ -framework CoreMedia \ -framework CoreVideo \ -framework ScreenCaptureKit \ diff --git a/native/client/platform/darwin/capture/main.swift b/native/client/platform/darwin/capture/main.swift index e23b68e..deee3de 100644 --- a/native/client/platform/darwin/capture/main.swift +++ b/native/client/platform/darwin/capture/main.swift @@ -1,3 +1,4 @@ +import AudioToolbox import CoreMedia import CoreVideo import Darwin @@ -5,15 +6,40 @@ import Foundation import ScreenCaptureKit import VideoToolbox -private let captureProtocol = 3 +private let captureProtocol = 4 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 let supportedH264Levels: Set = [ + 0x1e, 0x1f, 0x20, 0x28, 0x29, 0x2a, 0x32, 0x33, +] + +private enum DegradationPreference: String { + case resolution = "maintain-resolution" + case balanced + case framerate = "maintain-framerate" +} + +private struct VideoProfile { + let width: Int + let height: Int + let frameRate: Int32 + let bitrate: Int + let preference: DegradationPreference + + var frameInterval: CMTime { CMTime(value: 1, timescale: frameRate) } + var timestampStep: CMTime { CMTime(value: 1, timescale: 90_000) } +} + +private let defaultVideoProfile = VideoProfile( + width: width, + height: height, + frameRate: frameRate, + bitrate: bitrate, + preference: .balanced +) private struct EncoderProbe: Codable { let index: UInt32 @@ -35,6 +61,7 @@ private struct Probe: Codable { let videoCapture: Bool let processAudio: Bool let systemAudio: Bool + let softwareVP8 = false let adapters: [AdapterProbe] } @@ -65,6 +92,7 @@ private struct CaptureTarget: Codable { private struct StartingStatus: Codable { let state = "starting" + let codec = "h264" let hardwareOnly = true let adapterIndex: UInt32 = 0 let adapterName = "Apple VideoToolbox" @@ -76,11 +104,17 @@ private struct StartingStatus: Codable { private struct ActiveStatus: Codable { let state = "active" + let codec = "h264" let hardwareOnly = true - let profileLevelId = "42c01f" - let width = 1280 - let height = 720 - let fps = 30 + let profileLevelId: String + let width: Int + let height: Int + let fps: Int32 +} + +private struct AudioStatus: Codable { + let state = "active" + let audio = true } private struct CaptureFailure: Error, CustomStringConvertible { @@ -197,8 +231,8 @@ private func probe() throws { platform: "darwin", platformBuild: ProcessInfo.processInfo.operatingSystemVersionString, videoCapture: true, - processAudio: false, - systemAudio: false, + processAudio: true, + systemAudio: true, adapters: adapters )) } @@ -263,12 +297,31 @@ private final class ProtocolWriter { payload: JSONEncoder().encode(status)) } - func writeH264(_ payload: Data, keyFrame: Bool, timestamp: UInt64) throws { + func writeH264( + _ payload: Data, + keyFrame: Bool, + timestamp: UInt64, + duration: UInt64 + ) throws { try write( kind: 2, flags: keyFrame ? 1 : 0, timestamp: timestamp, - duration: frameDuration100ns, + duration: duration, + payload: payload + ) + } + + func writePCM( + _ payload: Data, + timestamp: UInt64, + duration: UInt64 + ) throws { + try write( + kind: 1, + flags: 0, + timestamp: timestamp, + duration: duration, payload: payload ) } @@ -342,8 +395,9 @@ private func h264ParameterSets(_ format: CMFormatDescription) throws -> [Data] { 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") + guard sps.count >= 4, sps[1] == 0x42, sps[2] == 0xc0, + supportedH264Levels.contains(sps[3]) else { + throw CaptureFailure(description: "H.264 profile is outside the product envelope") } return result } @@ -396,6 +450,23 @@ private func annexB(_ sample: CMSampleBuffer, keyFrame: Bool) throws -> Data { return output } +private func h264ProfileLevelID(_ payload: Data) -> String? { + let bytes = [UInt8](payload) + guard bytes.count >= 8 else { return nil } + for index in 0...(bytes.count - 8) where + bytes[index] == 0 && bytes[index + 1] == 0 && + bytes[index + 2] == 0 && bytes[index + 3] == 1 && + bytes[index + 4] & 0x1f == 7 { + return String( + format: "%02x%02x%02x", + bytes[index + 5], + bytes[index + 6], + bytes[index + 7] + ) + } + return nil +} + private final class HardwareEncoder { private let writer: ProtocolWriter private let done: StopSignal @@ -403,10 +474,17 @@ private final class HardwareEncoder { private var session: VTCompressionSession? private var forceKeyFrame = true private var active = false + private var profileLevelId: String? + private let profile: VideoProfile - init(writer: ProtocolWriter, done: StopSignal) throws { + init( + writer: ProtocolWriter, + done: StopSignal, + profile: VideoProfile = defaultVideoProfile + ) throws { self.writer = writer self.done = done + self.profile = profile let specification = [ kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder as String: true, ] as CFDictionary @@ -414,8 +492,8 @@ private final class HardwareEncoder { try require( VTCompressionSessionCreate( allocator: nil, - width: Int32(width), - height: Int32(height), + width: Int32(profile.width), + height: Int32(profile.height), codecType: kCMVideoCodecType_H264, encoderSpecification: specification, imageBufferAttributes: nil, @@ -434,9 +512,15 @@ private final class HardwareEncoder { 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 set(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: profile.bitrate)) + try set(kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: profile.frameRate)) + try set(kVTCompressionPropertyKey_MaxKeyFrameInterval, NSNumber(value: profile.frameRate * 2)) + if profile.preference != .balanced { + try? set( + kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality, + profile.preference == .framerate ? kCFBooleanTrue : kCFBooleanFalse + ) + } try require(VTCompressionSessionPrepareToEncodeFrames(created), "videotoolbox-prepare") } @@ -470,7 +554,7 @@ private final class HardwareEncoder { session, imageBuffer: image, presentationTimeStamp: timestamp, - duration: CMTime(value: 1, timescale: frameRate), + duration: profile.frameInterval, frameProperties: properties, infoFlagsOut: &synchronousFlags ) { [weak self] status, flags, sample in @@ -493,6 +577,9 @@ private final class HardwareEncoder { lock.unlock() if needsFirstKeyFrame { return } let payload = try annexB(sample, keyFrame: keyFrame) + let observedProfileLevelId = keyFrame + ? h264ProfileLevelID(payload) + : nil let seconds = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sample)) guard seconds.isFinite, seconds >= 0 else { throw CaptureFailure(description: "invalid H.264 timestamp") @@ -500,14 +587,29 @@ private final class HardwareEncoder { try writer.writeH264( payload, keyFrame: keyFrame, - timestamp: UInt64(seconds * 10_000_000) + timestamp: UInt64(seconds * 10_000_000), + duration: 10_000_000 / UInt64(profile.frameRate) ) lock.lock() + if let observedProfileLevelId { + profileLevelId = observedProfileLevelId + } let publishActive = !active - active = true + let activeProfileLevelId = profileLevelId + if activeProfileLevelId != nil { + active = true + } lock.unlock() if publishActive { - try writer.writeStatus(ActiveStatus()) + guard let activeProfileLevelId else { + throw CaptureFailure(description: "H.264 key frame has no SPS profile") + } + try writer.writeStatus(ActiveStatus( + profileLevelId: activeProfileLevelId, + width: profile.width, + height: profile.height, + fps: profile.frameRate + )) } } catch { done.signal(error) @@ -618,10 +720,193 @@ private func selfTest() throws { } } +private final class AudioCaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { + private let writer: ProtocolWriter + private let done: StopSignal + private var pending = Data() + private var nextTimestamp: UInt64? + private var active = false + + init(writer: ProtocolWriter, done: StopSignal) { + self.writer = writer + self.done = done + pending.reserveCapacity(960 * 2 * MemoryLayout.size * 2) + } + + private func append(_ sample: Float32) { + let clipped = max(-1, min(1, sample)) + var value = Int16((clipped * Float32(Int16.max)).rounded()).littleEndian + Swift.withUnsafeBytes(of: &value) { pending.append(contentsOf: $0) } + } + + private func sample( + _ buffer: AudioBuffer, + index: Int, + floatingPoint: Bool + ) throws -> Float32 { + guard let data = buffer.mData else { + throw CaptureFailure(description: "audio buffer has no samples") + } + if floatingPoint { + let count = Int(buffer.mDataByteSize) / MemoryLayout.size + guard index >= 0, index < count else { + throw CaptureFailure(description: "audio float buffer is truncated") + } + return data.assumingMemoryBound(to: Float32.self)[index] + } + let count = Int(buffer.mDataByteSize) / MemoryLayout.size + guard index >= 0, index < count else { + throw CaptureFailure(description: "audio integer buffer is truncated") + } + return Float32(data.assumingMemoryBound(to: Int16.self)[index]) / + Float32(Int16.max) + } + + private func append(_ sampleBuffer: CMSampleBuffer) throws { + guard let description = CMSampleBufferGetFormatDescription(sampleBuffer), + let formatPointer = CMAudioFormatDescriptionGetStreamBasicDescription( + description + ) else { + throw CaptureFailure(description: "audio format is unavailable") + } + let format = formatPointer.pointee + let channels = Int(format.mChannelsPerFrame) + let floatingPoint = format.mFormatFlags & + kAudioFormatFlagIsFloat != 0 + let signedInteger = format.mFormatFlags & + kAudioFormatFlagIsSignedInteger != 0 + let nonInterleaved = format.mFormatFlags & + kAudioFormatFlagIsNonInterleaved != 0 + guard format.mFormatID == kAudioFormatLinearPCM, + format.mSampleRate == 48_000, + channels > 0, + format.mFormatFlags & kAudioFormatFlagIsBigEndian == 0, + (floatingPoint && format.mBitsPerChannel == 32) || + (signedInteger && format.mBitsPerChannel == 16) else { + throw CaptureFailure(description: "audio format is outside the PCM contract") + } + + var requiredSize = 0 + try require( + CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( + sampleBuffer, + bufferListSizeNeededOut: &requiredSize, + bufferListOut: nil, + bufferListSize: 0, + blockBufferAllocator: nil, + blockBufferMemoryAllocator: nil, + flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, + blockBufferOut: nil + ), + "audio-buffer-size" + ) + guard requiredSize >= MemoryLayout.size else { + throw CaptureFailure(description: "audio buffer list is invalid") + } + let storage = UnsafeMutableRawPointer.allocate( + byteCount: requiredSize, + alignment: MemoryLayout.alignment + ) + defer { storage.deallocate() } + let list = storage.bindMemory(to: AudioBufferList.self, capacity: 1) + var blockBuffer: CMBlockBuffer? + try require( + CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( + sampleBuffer, + bufferListSizeNeededOut: nil, + bufferListOut: list, + bufferListSize: requiredSize, + blockBufferAllocator: nil, + blockBufferMemoryAllocator: nil, + flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, + blockBufferOut: &blockBuffer + ), + "audio-buffer-list" + ) + let buffers = UnsafeMutableAudioBufferListPointer(list) + let frames = CMSampleBufferGetNumSamples(sampleBuffer) + guard frames > 0, + (!nonInterleaved && buffers.count == 1) || + (nonInterleaved && buffers.count >= channels) else { + throw CaptureFailure(description: "audio channel layout is invalid") + } + if nextTimestamp == nil { + let time = CMTimeGetSeconds( + CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + ) + nextTimestamp = time.isFinite && time >= 0 + ? UInt64(time * 10_000_000) + : 0 + } + for frame in 0.. 1 + ? try sample(buffers[1], index: frame, floatingPoint: floatingPoint) + : left + } else { + left = try sample( + buffers[0], + index: frame * channels, + floatingPoint: floatingPoint + ) + right = channels > 1 + ? try sample( + buffers[0], + index: frame * channels + 1, + floatingPoint: floatingPoint + ) + : left + } + append(left) + append(right) + } + } + + private func flushFrames() throws { + let frameBytes = 960 * 2 * MemoryLayout.size + while pending.count >= frameBytes { + if !active { + try writer.writeStatus(AudioStatus()) + active = true + } + let payload = pending.prefix(frameBytes) + try writer.writePCM( + Data(payload), + timestamp: nextTimestamp ?? 0, + duration: 200_000 + ) + pending.removeFirst(frameBytes) + nextTimestamp = (nextTimestamp ?? 0) + 200_000 + } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of outputType: SCStreamOutputType + ) { + guard outputType == .audio, sampleBuffer.isValid else { return } + do { + try append(sampleBuffer) + try flushFrames() + } catch { + done.signal(error) + } + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + done.signal(error) + } +} + private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { private let encoder: HardwareEncoder private let done: StopSignal private let queue: DispatchQueue + private let profile: VideoProfile private var lastImage: CVImageBuffer? private var lastTimestamp = CMTime.invalid private var lastRecoveryTime = CMTime.invalid @@ -629,11 +914,13 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { init( encoder: HardwareEncoder, done: StopSignal, - queue: DispatchQueue + queue: DispatchQueue, + profile: VideoProfile ) { self.encoder = encoder self.done = done self.queue = queue + self.profile = profile } func requestKeyFrame() { @@ -646,7 +933,7 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { if lastRecoveryTime.isValid, CMTimeCompare( now, - CMTimeAdd(lastRecoveryTime, frameInterval) + CMTimeAdd(lastRecoveryTime, profile.frameInterval) ) < 0 { return } @@ -654,7 +941,7 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { let next = lastTimestamp.isValid ? later( now, - CMTimeAdd(lastTimestamp, timestampStep) + CMTimeAdd(lastTimestamp, profile.timestampStep) ) : now lastTimestamp = next @@ -684,7 +971,7 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { let timestamp = lastTimestamp.isValid ? later( capturedTimestamp, - CMTimeAdd(lastTimestamp, timestampStep) + CMTimeAdd(lastTimestamp, profile.timestampStep) ) : capturedTimestamp lastImage = image @@ -697,20 +984,13 @@ private final class CaptureOutput: NSObject, SCStreamOutput, SCStreamDelegate { } } -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] +private func captureFilter( + kind: String, + sourceID: UInt32, + pid: UInt32, + creationTime: String +) async throws -> SCContentFilter { let content = try await shareableContent() - let filter: SCContentFilter if kind == "window" { guard pid > 0, creationTime != "0", let selected = shareableWindows(content).first(where: { @@ -719,33 +999,85 @@ private func capture(_ arguments: [String]) async throws { })?.0 else { throw CaptureFailure(description: "capture target identity changed") } - filter = SCContentFilter(desktopIndependentWindow: selected) - } else if kind == "display" { + return SCContentFilter(desktopIndependentWindow: selected) + } + 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") + return SCContentFilter(display: selected, excludingWindows: []) } + throw CaptureFailure(description: "capture target kind is unsupported") +} + +private func videoProfile(_ arguments: [String]) throws -> VideoProfile { + guard arguments.count == 23, + arguments[10] == "--width", let width = Int(arguments[11]), + arguments[12] == "--height", let height = Int(arguments[13]), + arguments[14] == "--fps", let frameRate = Int32(arguments[15]), + arguments[16] == "--bitrate", let bitrate = Int(arguments[17]), + arguments[18] == "--preference", + let preference = DegradationPreference(rawValue: arguments[19]), + arguments[20] == "--codec", + ["auto", "h264"].contains(arguments[21]), + arguments[22] == "--protocol-v4" else { + throw CaptureFailure(description: "invalid video profile arguments") + } + let validResolution = + (width == 854 && height == 480) || + (width == 1280 && height == 720) || + (width == 1920 && height == 1080) || + (width == 2560 && height == 1440) + guard validResolution, frameRate >= 15, frameRate <= 60, + bitrate >= 2_000_000, bitrate <= 12_000_000 else { + throw CaptureFailure(description: "video profile is outside the product bounds") + } + return VideoProfile( + width: width, + height: height, + frameRate: frameRate, + bitrate: bitrate, + preference: preference + ) +} + +private func capture(_ arguments: [String]) async throws { + guard arguments.count == 23, + 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" else { + throw CaptureFailure(description: "invalid capture arguments") + } + let profile = try videoProfile(arguments) + let kind = arguments[2] + let creationTime = arguments[5] + let filter = try await captureFilter( + kind: kind, + sourceID: sourceID, + pid: pid, + creationTime: creationTime + ) let writer = ProtocolWriter() let done = StopSignal() - let encoder = try HardwareEncoder(writer: writer, done: done) + let encoder = try HardwareEncoder(writer: writer, done: done, profile: profile) defer { encoder.close() } let captureQueue = DispatchQueue(label: "screener.capture.video") let output = CaptureOutput( encoder: encoder, done: done, - queue: captureQueue + queue: captureQueue, + profile: profile ) let configuration = SCStreamConfiguration() - configuration.width = width - configuration.height = height - configuration.minimumFrameInterval = CMTime(value: 1, timescale: frameRate) + configuration.width = profile.width + configuration.height = profile.height + configuration.minimumFrameInterval = profile.frameInterval configuration.queueDepth = 5 configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange configuration.showsCursor = true @@ -791,6 +1123,70 @@ private func capture(_ arguments: [String]) async throws { encoder.close() } +private func captureAudio(_ arguments: [String]) async throws { + guard arguments.count == 5, + arguments[1] == "--capture-audio", + let pid = UInt32(arguments[3]) else { + throw CaptureFailure(description: "invalid audio capture arguments") + } + let kind = arguments[2] + let creationTime = arguments[4] + let content = try await shareableContent() + let filter: SCContentFilter + if kind == "window" { + guard pid > 0, creationTime != "0", + let selected = shareableWindows(content).first(where: { + $0.1.pid == pid && $0.1.creationTime == creationTime + })?.0 else { + throw CaptureFailure(description: "audio target identity changed") + } + filter = SCContentFilter(desktopIndependentWindow: selected) + } else if kind == "display" { + guard pid == 0, creationTime == "0", + let selected = content.displays.first else { + throw CaptureFailure(description: "audio display is unavailable") + } + filter = SCContentFilter(display: selected, excludingWindows: []) + } else { + throw CaptureFailure(description: "audio target kind is unsupported") + } + let writer = ProtocolWriter() + let done = StopSignal() + let output = AudioCaptureOutput(writer: writer, done: done) + let configuration = SCStreamConfiguration() + configuration.capturesAudio = true + configuration.excludesCurrentProcessAudio = true + configuration.sampleRate = 48_000 + configuration.channelCount = 2 + let queue = DispatchQueue(label: "screener.capture.audio") + let stream = SCStream( + filter: filter, + configuration: configuration, + delegate: output + ) + try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: queue) + + DispatchQueue.global(qos: .userInitiated).async { + while true { + let data = FileHandle.standardInput.readData(ofLength: 64) + if data.isEmpty || data.contains(81) || data.contains(10) || + data.contains(13) { + done.signal() + return + } + } + } + + try await stream.startCapture() + do { + try done.wait() + } catch { + try? await stream.stopCapture() + throw error + } + try await stream.stopCapture() +} + @main private struct ScreenerCapture { static func main() async { @@ -802,6 +1198,8 @@ private struct ScreenerCapture { try selfTest() } else if arguments.count == 2, arguments[1] == "--list" { try await listSources() + } else if arguments.count > 1, arguments[1] == "--capture-audio" { + try await captureAudio(arguments) } else { try await capture(arguments) } diff --git a/native/client/platform/linux/capture/README.md b/native/client/platform/linux/capture/README.md new file mode 100644 index 0000000..37f96ff --- /dev/null +++ b/native/client/platform/linux/capture/README.md @@ -0,0 +1,32 @@ +# Linux Capture Process + +The Linux sidecar keeps the shared Native Client contract while delegating +desktop integration to the standard Linux media stack. XDG Desktop Portal owns +the screen/window choice, PipeWire supplies the selected stream, and GStreamer +selects an installed element classified as a hardware H.264 video encoder. +Screener adds only the current product profile, Annex-B access-unit framing, +key-frame requests, and the bounded `SMED` output protocol. + +The Portal restore token remains in the active share process and lets a live +quality change reacquire the same selected source without inventing a second +source picker. System playback audio uses the PipeWire PulseAudio compatibility +service when its GStreamer source is installed. Per-process audio is not +advertised. + +Build on a Linux desktop with development packages for libportal and GStreamer: + +```sh +sh native/client/platform/linux/capture/build.sh /outside/repository/build +``` + +Runtime requirements are the desktop Portal/PipeWire services, a GStreamer +PipeWire source, and an installed GStreamer hardware H.264 encoder. If any part +is unavailable, the Client reports no Linux Native path and the same Browser +capture choice remains available. + +Primary references: + +- +- +- +- diff --git a/native/client/platform/linux/capture/build.sh b/native/client/platform/linux/capture/build.sh new file mode 100755 index 0000000..12d4fb2 --- /dev/null +++ b/native/client/platform/linux/capture/build.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: build.sh " >&2 + exit 2 +fi + +output=$1 +mkdir -p "$output" +cc \ + -std=c11 \ + -O2 \ + -Wall \ + -Wextra \ + -Werror \ + "$(dirname "$0")/main.c" \ + "$(dirname "$0")/portal.c" \ + $(pkg-config --cflags --libs \ + libportal \ + gstreamer-1.0 \ + gstreamer-app-1.0 \ + gstreamer-video-1.0) \ + -o "$output/screener-client-capture" diff --git a/native/client/platform/linux/capture/main.c b/native/client/platform/linux/capture/main.c new file mode 100644 index 0000000..93157ca --- /dev/null +++ b/native/client/platform/linux/capture/main.c @@ -0,0 +1,808 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "portal.h" + +enum { + kCaptureProtocol = 4, + kMaxPayloadBytes = 4 * 1024 * 1024, + kAudioFrameBytes = 960 * 2 * 2, +}; + +typedef enum { + kPreferResolution, + kPreferBalanced, + kPreferFramerate, +} DegradationPreference; + +typedef struct { + guint width; + guint height; + guint frame_rate; + guint bit_rate; + DegradationPreference preference; +} VideoProfile; + +typedef struct { + char *factory; + char *name; + guint bit_rate_divisor; +} EncoderInfo; + +typedef struct { + GMainLoop *loop; + GstElement *pipeline; + GstElement *encoder; + VideoProfile profile; + const EncoderInfo *encoder_info; + const char *restore_token; + GMutex lock; + char *failure; + gint stopping; + gboolean active; + guint64 last_timestamp; + GByteArray *audio; + guint64 audio_timestamp; +} CaptureRun; + +static gboolean write_all(const void *data, size_t size) { + const uint8_t *next = data; + while (size > 0) { + ssize_t written = write(STDOUT_FILENO, next, size); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) return FALSE; + next += written; + size -= (size_t)written; + } + return TRUE; +} + +static void put_u32_be(uint8_t *output, guint32 value) { + output[0] = (uint8_t)(value >> 24); + output[1] = (uint8_t)(value >> 16); + output[2] = (uint8_t)(value >> 8); + output[3] = (uint8_t)value; +} + +static void put_u64_be(uint8_t *output, guint64 value) { + for (guint index = 0; index < 8; ++index) { + output[index] = (uint8_t)(value >> (56 - index * 8)); + } +} + +static gboolean write_frame(guint8 kind, guint8 flags, guint64 timestamp, + guint64 duration, const guint8 *payload, + gsize size) { + if (payload == NULL || size == 0 || size > kMaxPayloadBytes) return FALSE; + uint8_t header[28] = {'S', 'M', 'E', 'D', 1, kind, flags, 0}; + put_u64_be(header + 8, timestamp); + put_u64_be(header + 16, duration); + put_u32_be(header + 24, (guint32)size); + return write_all(header, sizeof(header)) && write_all(payload, size); +} + +static char *json_string(const char *value) { + GString *result = g_string_new("\""); + const unsigned char *next = (const unsigned char *)(value == NULL ? "" : value); + for (; *next != 0; ++next) { + switch (*next) { + case '\"': + g_string_append(result, "\\\""); + break; + case '\\': + g_string_append(result, "\\\\"); + break; + case '\b': + g_string_append(result, "\\b"); + break; + case '\f': + g_string_append(result, "\\f"); + break; + case '\n': + g_string_append(result, "\\n"); + break; + case '\r': + g_string_append(result, "\\r"); + break; + case '\t': + g_string_append(result, "\\t"); + break; + default: + if (*next < 0x20) { + g_string_append_printf(result, "\\u%04x", *next); + } else { + g_string_append_c(result, (char)*next); + } + } + } + g_string_append_c(result, '\"'); + return g_string_free(result, FALSE); +} + +static gboolean write_status(const char *json) { + return write_frame(3, 0, 0, 0, (const guint8 *)json, strlen(json)); +} + +static void encoder_info_free(gpointer value) { + EncoderInfo *encoder = value; + if (encoder == NULL) return; + g_free(encoder->factory); + g_free(encoder->name); + g_free(encoder); +} + +static gboolean factory_supports_constrained_baseline( + GstElementFactory *factory) { + GstCaps *required = gst_caps_from_string( + "video/x-h264,profile=constrained-baseline"); + gboolean supported = FALSE; + const GList *templates = gst_element_factory_get_static_pad_templates(factory); + for (const GList *item = templates; item != NULL; item = item->next) { + const GstStaticPadTemplate *pad = item->data; + if (pad->direction != GST_PAD_SRC) continue; + GstCaps *caps = gst_static_caps_get((GstStaticCaps *)&pad->static_caps); + supported = gst_caps_can_intersect(caps, required); + gst_caps_unref(caps); + if (supported) break; + } + gst_caps_unref(required); + return supported; +} + +static guint bitrate_divisor(GstElement *encoder) { + GParamSpec *property = + g_object_class_find_property(G_OBJECT_GET_CLASS(encoder), "bitrate"); + if (property == NULL) return 0; + GType type = G_PARAM_SPEC_VALUE_TYPE(property); + if (type != G_TYPE_UINT && type != G_TYPE_INT && type != G_TYPE_UINT64 && + type != G_TYPE_INT64) { + return 0; + } + const char *blurb = g_param_spec_get_blurb(property); + char *description = g_ascii_strdown(blurb == NULL ? "" : blurb, -1); + guint divisor = strstr(description, "kbit") != NULL || + strstr(description, "kbps") != NULL || + strstr(description, "kb/s") != NULL + ? 1000 + : 1; + g_free(description); + return divisor; +} + +static gint compare_encoders(gconstpointer left, gconstpointer right) { + const EncoderInfo *a = *(EncoderInfo *const *)left; + const EncoderInfo *b = *(EncoderInfo *const *)right; + return g_strcmp0(a->factory, b->factory); +} + +static GPtrArray *hardware_encoders(void) { + GPtrArray *result = + g_ptr_array_new_with_free_func(encoder_info_free); + GList *features = gst_registry_get_feature_list( + gst_registry_get(), GST_TYPE_ELEMENT_FACTORY); + for (GList *item = features; item != NULL; item = item->next) { + GstElementFactory *factory = GST_ELEMENT_FACTORY(item->data); + const char *classification = + gst_element_factory_get_metadata(factory, GST_ELEMENT_METADATA_KLASS); + if (classification == NULL || strstr(classification, "Encoder") == NULL || + strstr(classification, "Video") == NULL || + strstr(classification, "Hardware") == NULL || + !factory_supports_constrained_baseline(factory)) { + continue; + } + GstElement *element = gst_element_factory_create(factory, NULL); + if (element == NULL) continue; + guint divisor = bitrate_divisor(element); + gst_object_unref(element); + if (divisor == 0) continue; + const char *factory_name = gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(factory)); + const char *long_name = gst_element_factory_get_metadata( + factory, GST_ELEMENT_METADATA_LONGNAME); + EncoderInfo *encoder = g_new0(EncoderInfo, 1); + encoder->factory = g_strdup(factory_name); + encoder->name = g_strdup(long_name == NULL ? factory_name : long_name); + encoder->bit_rate_divisor = divisor; + g_ptr_array_add(result, encoder); + } + gst_plugin_feature_list_free(features); + g_ptr_array_sort(result, compare_encoders); + return result; +} + +static gboolean has_factory(const char *name) { + GstElementFactory *factory = gst_element_factory_find(name); + if (factory == NULL) return FALSE; + gst_object_unref(factory); + return TRUE; +} + +static gboolean video_stack_available(void) { + const char *required[] = {"pipewiresrc", "queue", "videoconvert", + "videoscale", "videorate", "h264parse", + "appsink"}; + for (guint index = 0; index < G_N_ELEMENTS(required); ++index) { + if (!has_factory(required[index])) return FALSE; + } + return screener_portal_available(); +} + +static gboolean audio_stack_available(void) { + const char *required[] = {"pulsesrc", "queue", "audioconvert", + "audioresample", "appsink"}; + for (guint index = 0; index < G_N_ELEMENTS(required); ++index) { + if (!has_factory(required[index])) return FALSE; + } + return TRUE; +} + +static int write_probe(void) { + GPtrArray *encoders = hardware_encoders(); + struct utsname system = {0}; + uname(&system); + char *build = json_string(system.release[0] == '\0' ? "linux" : system.release); + gboolean video = video_stack_available(); + gboolean audio = audio_stack_available(); + GString *json = g_string_new(NULL); + g_string_append_printf( + json, + "{\"protocol\":%d,\"platform\":\"linux\",\"platformBuild\":%s," + "\"videoCapture\":%s,\"softwareVP8\":false,\"processAudio\":false,\"systemAudio\":%s," + "\"adapters\":[", + kCaptureProtocol, build, video ? "true" : "false", + audio ? "true" : "false"); + g_free(build); + if (encoders->len > 0) { + g_string_append(json, + "{\"index\":0,\"name\":\"GStreamer hardware H.264\"," + "\"identity\":\"gstreamer-hardware-h264\"," + "\"hardwareH264\":["); + for (guint index = 0; index < encoders->len; ++index) { + const EncoderInfo *encoder = g_ptr_array_index(encoders, index); + char *name = json_string(encoder->name); + char *identity = json_string(encoder->factory); + g_string_append_printf( + json, "%s{\"index\":%u,\"name\":%s,\"identity\":%s}", + index == 0 ? "" : ",", index, name, identity); + g_free(name); + g_free(identity); + } + g_string_append(json, "]}"); + } + g_string_append(json, "]}"); + gboolean written = write_all(json->str, json->len); + g_string_free(json, TRUE); + g_ptr_array_unref(encoders); + return written ? 0 : 2; +} + +static int write_sources(void) { + GPtrArray *encoders = hardware_encoders(); + gboolean available = video_stack_available() && encoders->len > 0; + g_ptr_array_unref(encoders); + const char *json = available + ? "[{\"kind\":\"picker\",\"sourceId\":\"1\"," + "\"title\":\"System screen or window\"}]" + : "[]"; + return write_all(json, strlen(json)) ? 0 : 2; +} + +static gboolean parse_uint(const char *value, guint minimum, guint maximum, + guint *output) { + if (value == NULL || value[0] == '\0' || value[0] == '-') return FALSE; + char *end = NULL; + errno = 0; + unsigned long parsed = strtoul(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || parsed < minimum || + parsed > maximum) { + return FALSE; + } + *output = (guint)parsed; + return TRUE; +} + +static gboolean parse_profile(int count, char **values, VideoProfile *profile, + guint *encoder_index) { + if (count != 23 || strcmp(values[1], "--capture-video") != 0 || + strcmp(values[2], "picker") != 0 || strcmp(values[3], "1") != 0 || + strcmp(values[4], "0") != 0 || strcmp(values[5], "0") != 0 || + strcmp(values[6], "--adapter-index") != 0 || + strcmp(values[7], "0") != 0 || strcmp(values[8], "--mft-index") != 0 || + strcmp(values[10], "--width") != 0 || + strcmp(values[12], "--height") != 0 || + strcmp(values[14], "--fps") != 0 || + strcmp(values[16], "--bitrate") != 0 || + strcmp(values[18], "--preference") != 0 || + strcmp(values[20], "--codec") != 0 || + (strcmp(values[21], "auto") != 0 && strcmp(values[21], "h264") != 0) || + strcmp(values[22], "--protocol-v4") != 0 || + !parse_uint(values[9], 0, 63, encoder_index) || + !parse_uint(values[11], 1, 16384, &profile->width) || + !parse_uint(values[13], 1, 16384, &profile->height) || + !parse_uint(values[15], 15, 60, &profile->frame_rate) || + !parse_uint(values[17], 2000000, 12000000, &profile->bit_rate)) { + return FALSE; + } + gboolean resolution = + (profile->width == 854 && profile->height == 480) || + (profile->width == 1280 && profile->height == 720) || + (profile->width == 1920 && profile->height == 1080) || + (profile->width == 2560 && profile->height == 1440); + if (!resolution) return FALSE; + if (strcmp(values[19], "maintain-resolution") == 0) { + profile->preference = kPreferResolution; + } else if (strcmp(values[19], "balanced") == 0) { + profile->preference = kPreferBalanced; + } else if (strcmp(values[19], "maintain-framerate") == 0) { + profile->preference = kPreferFramerate; + } else { + return FALSE; + } + return TRUE; +} + +static gboolean set_numeric_property(GObject *object, const char *name, + guint64 value) { + GParamSpec *property = + g_object_class_find_property(G_OBJECT_GET_CLASS(object), name); + if (property == NULL || !(property->flags & G_PARAM_WRITABLE)) return FALSE; + GType type = G_PARAM_SPEC_VALUE_TYPE(property); + if (type == G_TYPE_UINT) { + g_object_set(object, name, (guint)value, NULL); + } else if (type == G_TYPE_INT) { + g_object_set(object, name, (gint)value, NULL); + } else if (type == G_TYPE_UINT64) { + g_object_set(object, name, value, NULL); + } else if (type == G_TYPE_INT64) { + g_object_set(object, name, (gint64)value, NULL); + } else { + return FALSE; + } + return TRUE; +} + +static void set_optional_boolean(GObject *object, const char *name, + gboolean value) { + GParamSpec *property = + g_object_class_find_property(G_OBJECT_GET_CLASS(object), name); + if (property != NULL && G_PARAM_SPEC_VALUE_TYPE(property) == G_TYPE_BOOLEAN && + (property->flags & G_PARAM_WRITABLE)) { + g_object_set(object, name, value, NULL); + } +} + +static void set_optional_enum(GObject *object, const char *name, + const char *nick) { + GParamSpec *property = + g_object_class_find_property(G_OBJECT_GET_CLASS(object), name); + if (property == NULL || !G_IS_PARAM_SPEC_ENUM(property) || + !(property->flags & G_PARAM_WRITABLE)) { + return; + } + GEnumClass *values = g_type_class_ref(G_PARAM_SPEC_VALUE_TYPE(property)); + GEnumValue *selected = g_enum_get_value_by_nick(values, nick); + if (selected != NULL) g_object_set(object, name, selected->value, NULL); + g_type_class_unref(values); +} + +static gboolean configure_encoder(CaptureRun *run) { + guint64 bitrate = + run->profile.bit_rate / run->encoder_info->bit_rate_divisor; + if (!set_numeric_property(G_OBJECT(run->encoder), "bitrate", bitrate)) { + return FALSE; + } + set_numeric_property(G_OBJECT(run->encoder), "key-int-max", + run->profile.frame_rate * 2); + set_numeric_property(G_OBJECT(run->encoder), "b-frames", 0); + set_numeric_property(G_OBJECT(run->encoder), "ref-frames", 1); + set_optional_boolean(G_OBJECT(run->encoder), "cabac", FALSE); + set_optional_boolean(G_OBJECT(run->encoder), "aud", TRUE); + set_optional_boolean(G_OBJECT(run->encoder), "zerolatency", TRUE); + set_optional_enum(G_OBJECT(run->encoder), "rate-control", "cbr"); + guint usage = run->profile.preference == kPreferResolution + ? 1 + : run->profile.preference == kPreferFramerate ? 7 : 4; + set_numeric_property(G_OBJECT(run->encoder), "target-usage", usage); + return TRUE; +} + +static gboolean supported_level(guint8 level) { + const guint8 levels[] = {0x1e, 0x1f, 0x20, 0x28, + 0x29, 0x2a, 0x32, 0x33}; + for (guint index = 0; index < G_N_ELEMENTS(levels); ++index) { + if (levels[index] == level) return TRUE; + } + return FALSE; +} + +typedef struct { + gboolean sps; + gboolean pps; + gboolean idr; + char profile_level_id[7]; +} NalSummary; + +static gsize start_code(const guint8 *data, gsize size, gsize offset, + gsize *length) { + for (gsize index = offset; index + 3 <= size; ++index) { + if (data[index] != 0 || data[index + 1] != 0) continue; + if (data[index + 2] == 1) { + *length = 3; + return index; + } + if (index + 4 <= size && data[index + 2] == 0 && data[index + 3] == 1) { + *length = 4; + return index; + } + } + return size; +} + +static NalSummary inspect_h264(const guint8 *data, gsize size) { + NalSummary summary = {0}; + gsize prefix = 0; + gsize current = start_code(data, size, 0, &prefix); + while (current < size) { + gsize nal = current + prefix; + gsize next_prefix = 0; + gsize next = start_code(data, size, nal, &next_prefix); + if (nal < size) { + guint8 type = data[nal] & 0x1f; + summary.sps |= type == 7; + summary.pps |= type == 8; + summary.idr |= type == 5; + if (type == 7 && nal + 3 < size && data[nal + 1] == 0x42 && + data[nal + 2] == 0xc0 && supported_level(data[nal + 3])) { + g_snprintf(summary.profile_level_id, sizeof(summary.profile_level_id), + "%02x%02x%02x", data[nal + 1], data[nal + 2], + data[nal + 3]); + } + } + current = next; + prefix = next_prefix; + } + return summary; +} + +static gboolean quit_loop(gpointer data) { + CaptureRun *run = data; + g_main_loop_quit(run->loop); + return G_SOURCE_REMOVE; +} + +static void fail_run(CaptureRun *run, const char *message) { + g_mutex_lock(&run->lock); + if (run->failure == NULL) run->failure = g_strdup(message); + g_mutex_unlock(&run->lock); + g_main_context_invoke(NULL, quit_loop, run); +} + +static gboolean force_key_frame(gpointer data) { + CaptureRun *run = data; + if (run->encoder == NULL) return G_SOURCE_REMOVE; + GstPad *source = gst_element_get_static_pad(run->encoder, "src"); + if (source != NULL) { + gst_pad_send_event( + source, + gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE, TRUE, + 0)); + gst_object_unref(source); + } + return G_SOURCE_REMOVE; +} + +static gpointer control_input(gpointer data) { + CaptureRun *run = data; + guint8 value = 0; + while (read(STDIN_FILENO, &value, 1) == 1) { + if (value == 'K') { + g_main_context_invoke(NULL, force_key_frame, run); + } else if (value == 'Q' || value == '\n' || value == '\r') { + g_atomic_int_set(&run->stopping, TRUE); + g_main_context_invoke(NULL, quit_loop, run); + return NULL; + } + } + g_atomic_int_set(&run->stopping, TRUE); + g_main_context_invoke(NULL, quit_loop, run); + return NULL; +} + +static gboolean bus_message(GstBus *bus, GstMessage *message, gpointer data) { + (void)bus; + CaptureRun *run = data; + if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError *error = NULL; + char *debug = NULL; + gst_message_parse_error(message, &error, &debug); + fail_run(run, error == NULL ? "GStreamer pipeline failed" : error->message); + g_clear_error(&error); + g_free(debug); + } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS && + !g_atomic_int_get(&run->stopping)) { + fail_run(run, "GStreamer pipeline ended unexpectedly"); + } + return G_SOURCE_CONTINUE; +} + +static GstFlowReturn video_sample(GstAppSink *sink, gpointer data) { + CaptureRun *run = data; + GstSample *sample = gst_app_sink_pull_sample(sink); + if (sample == NULL) return GST_FLOW_EOS; + GstBuffer *buffer = gst_sample_get_buffer(sample); + GstMapInfo mapped = GST_MAP_INFO_INIT; + if (buffer == NULL || !gst_buffer_map(buffer, &mapped, GST_MAP_READ) || + mapped.size == 0 || mapped.size > kMaxPayloadBytes) { + if (buffer != NULL && mapped.data != NULL) gst_buffer_unmap(buffer, &mapped); + gst_sample_unref(sample); + fail_run(run, "GStreamer returned an invalid H.264 access unit"); + return GST_FLOW_ERROR; + } + gboolean key_frame = !GST_BUFFER_FLAG_IS_SET(buffer, GST_BUFFER_FLAG_DELTA_UNIT); + NalSummary nal = inspect_h264(mapped.data, mapped.size); + if (!run->active && + (!key_frame || !nal.sps || !nal.pps || !nal.idr || + nal.profile_level_id[0] == '\0')) { + gst_buffer_unmap(buffer, &mapped); + gst_sample_unref(sample); + g_main_context_invoke(NULL, force_key_frame, run); + return GST_FLOW_OK; + } + guint64 duration = GST_BUFFER_DURATION_IS_VALID(buffer) + ? GST_BUFFER_DURATION(buffer) / 100 + : 10000000ULL / run->profile.frame_rate; + guint64 timestamp = GST_BUFFER_PTS_IS_VALID(buffer) + ? GST_BUFFER_PTS(buffer) / 100 + : run->last_timestamp + duration; + if (run->last_timestamp != 0 && timestamp <= run->last_timestamp) { + timestamp = run->last_timestamp + duration; + } + run->last_timestamp = timestamp; + gboolean written = write_frame(2, key_frame ? 1 : 0, timestamp, duration, + mapped.data, mapped.size); + gst_buffer_unmap(buffer, &mapped); + gst_sample_unref(sample); + if (!written) { + fail_run(run, "native media output closed"); + return GST_FLOW_ERROR; + } + if (!run->active) { + char *token = json_string(run->restore_token); + char *status = g_strdup_printf( + "{\"state\":\"active\",\"codec\":\"h264\",\"hardwareOnly\":true," + "\"profileLevelId\":\"%s\",\"width\":%u,\"height\":%u," + "\"fps\":%u,\"restoreToken\":%s}", + nal.profile_level_id, run->profile.width, run->profile.height, + run->profile.frame_rate, token); + g_free(token); + run->active = write_status(status); + g_free(status); + if (!run->active) { + fail_run(run, "native media output closed"); + return GST_FLOW_ERROR; + } + } + return GST_FLOW_OK; +} + +static GstFlowReturn audio_sample(GstAppSink *sink, gpointer data) { + CaptureRun *run = data; + GstSample *sample = gst_app_sink_pull_sample(sink); + if (sample == NULL) return GST_FLOW_EOS; + GstBuffer *buffer = gst_sample_get_buffer(sample); + GstMapInfo mapped = GST_MAP_INFO_INIT; + if (buffer == NULL || !gst_buffer_map(buffer, &mapped, GST_MAP_READ)) { + gst_sample_unref(sample); + fail_run(run, "GStreamer returned invalid PCM audio"); + return GST_FLOW_ERROR; + } + if (run->audio->len == 0 && GST_BUFFER_PTS_IS_VALID(buffer)) { + run->audio_timestamp = GST_BUFFER_PTS(buffer) / 100; + } + if (!run->active) { + run->active = write_status("{\"state\":\"active\",\"audio\":true}"); + if (!run->active) { + gst_buffer_unmap(buffer, &mapped); + gst_sample_unref(sample); + fail_run(run, "native audio output closed"); + return GST_FLOW_ERROR; + } + } + g_byte_array_append(run->audio, mapped.data, mapped.size); + gst_buffer_unmap(buffer, &mapped); + gst_sample_unref(sample); + while (run->audio->len >= kAudioFrameBytes) { + if (!write_frame(1, 0, run->audio_timestamp, 200000, + run->audio->data, kAudioFrameBytes)) { + fail_run(run, "native audio output closed"); + return GST_FLOW_ERROR; + } + run->audio_timestamp += 200000; + g_byte_array_remove_range(run->audio, 0, kAudioFrameBytes); + } + return GST_FLOW_OK; +} + +static int run_pipeline(CaptureRun *run, GCallback sample_callback, + const char *starting_status) { + GstBus *bus = gst_element_get_bus(run->pipeline); + guint bus_watch = gst_bus_add_watch(bus, bus_message, run); + gst_object_unref(bus); + GstElement *output = gst_bin_get_by_name(GST_BIN(run->pipeline), "output"); + if (output == NULL) return 2; + g_signal_connect(output, "new-sample", sample_callback, run); + gst_object_unref(output); + if (starting_status != NULL && !write_status(starting_status)) return 2; + GstStateChangeReturn state = + gst_element_set_state(run->pipeline, GST_STATE_PLAYING); + if (state == GST_STATE_CHANGE_FAILURE) return 2; + if (gst_element_get_state(run->pipeline, NULL, NULL, 5 * GST_SECOND) == + GST_STATE_CHANGE_FAILURE) { + return 2; + } + g_thread_unref(g_thread_new("screener-control", control_input, run)); + g_main_loop_run(run->loop); + gst_element_set_state(run->pipeline, GST_STATE_NULL); + g_source_remove(bus_watch); + g_mutex_lock(&run->lock); + char *failure = g_strdup(run->failure); + g_mutex_unlock(&run->lock); + if (failure != NULL) { + fprintf(stderr, "Screener capture unavailable: %s\n", failure); + g_free(failure); + return 2; + } + return 0; +} + +static int capture_video(int count, char **values) { + VideoProfile profile = {0}; + guint encoder_index = 0; + if (!parse_profile(count, values, &profile, &encoder_index)) return 2; + GPtrArray *encoders = hardware_encoders(); + if (!video_stack_available() || encoder_index >= encoders->len) { + g_ptr_array_unref(encoders); + return 2; + } + const EncoderInfo *encoder_info = g_ptr_array_index(encoders, encoder_index); + GError *error = NULL; + ScreenerPortalCapture portal = {.pipewire_fd = -1}; + if (!screener_portal_capture_open( + g_getenv("SCREENER_XDP_RESTORE_TOKEN"), &portal, &error)) { + fprintf(stderr, "Screener capture unavailable: %s\n", + error == NULL ? "screen selection failed" : error->message); + g_clear_error(&error); + g_ptr_array_unref(encoders); + return 2; + } + + char *pipeline_text = g_strdup_printf( + "pipewiresrc name=source fd=%d target-object=%s do-timestamp=true ! " + "queue max-size-buffers=2 max-size-bytes=0 max-size-time=0 " + "leaky=downstream ! videoconvert ! videoscale ! videorate ! " + "video/x-raw,format=NV12,width=%u,height=%u,framerate=%u/1 ! " + "%s name=encoder ! video/x-h264,profile=constrained-baseline ! " + "h264parse config-interval=-1 ! " + "video/x-h264,stream-format=byte-stream,alignment=au ! " + "appsink name=output emit-signals=true sync=false max-buffers=2 drop=true", + portal.pipewire_fd, portal.target_object, profile.width, profile.height, + profile.frame_rate, encoder_info->factory); + GstElement *pipeline = gst_parse_launch(pipeline_text, &error); + g_free(pipeline_text); + if (pipeline == NULL || error != NULL) { + fprintf(stderr, "Screener capture unavailable: %s\n", + error == NULL ? "video pipeline failed" : error->message); + g_clear_error(&error); + if (pipeline != NULL) gst_object_unref(pipeline); + screener_portal_capture_close(&portal); + g_ptr_array_unref(encoders); + return 2; + } + CaptureRun run = { + .loop = g_main_loop_new(NULL, FALSE), + .pipeline = pipeline, + .profile = profile, + .encoder_info = encoder_info, + .restore_token = portal.restore_token, + }; + g_mutex_init(&run.lock); + run.encoder = gst_bin_get_by_name(GST_BIN(pipeline), "encoder"); + GstElement *source = gst_bin_get_by_name(GST_BIN(pipeline), "source"); + if (source != NULL) { + set_optional_boolean(G_OBJECT(source), "resend-last", TRUE); + set_numeric_property(G_OBJECT(source), "keepalive-time", + MAX(1, 1000 / profile.frame_rate)); + gst_object_unref(source); + } + if (run.encoder == NULL || !configure_encoder(&run)) { + fprintf(stderr, "Screener capture unavailable: hardware encoder is incompatible\n"); + if (run.encoder != NULL) gst_object_unref(run.encoder); + g_main_loop_unref(run.loop); + g_mutex_clear(&run.lock); + gst_object_unref(pipeline); + screener_portal_capture_close(&portal); + g_ptr_array_unref(encoders); + return 2; + } + char *adapter_name = json_string("GStreamer hardware H.264"); + char *encoder_name = json_string(encoder_info->name); + char *encoder_identity = json_string(encoder_info->factory); + char *starting = g_strdup_printf( + "{\"state\":\"starting\",\"codec\":\"h264\",\"hardwareOnly\":true," + "\"adapterIndex\":0,\"adapterName\":%s," + "\"adapterIdentity\":\"gstreamer-hardware-h264\"," + "\"encoderIndex\":%u,\"encoderName\":%s," + "\"encoderIdentity\":%s}", + adapter_name, encoder_index, encoder_name, encoder_identity); + g_free(adapter_name); + g_free(encoder_name); + g_free(encoder_identity); + int result = run_pipeline(&run, G_CALLBACK(video_sample), starting); + g_free(starting); + gst_object_unref(run.encoder); + g_free(run.failure); + g_main_loop_unref(run.loop); + g_mutex_clear(&run.lock); + gst_object_unref(pipeline); + screener_portal_capture_close(&portal); + g_ptr_array_unref(encoders); + return result; +} + +static int capture_audio(int count, char **values) { + if (count != 5 || strcmp(values[1], "--capture-audio") != 0 || + (strcmp(values[2], "picker") != 0 && + strcmp(values[2], "display") != 0) || + strcmp(values[3], "0") != 0 || + strcmp(values[4], "0") != 0 || !audio_stack_available()) { + return 2; + } + GError *error = NULL; + GstElement *pipeline = gst_parse_launch( + "pulsesrc device=@DEFAULT_MONITOR@ do-timestamp=true ! " + "queue max-size-buffers=4 max-size-bytes=0 max-size-time=0 " + "leaky=downstream ! audioconvert ! audioresample ! " + "audio/x-raw,format=S16LE,rate=48000,channels=2,layout=interleaved ! " + "appsink name=output emit-signals=true sync=false max-buffers=4 drop=true", + &error); + if (pipeline == NULL || error != NULL) { + g_clear_error(&error); + if (pipeline != NULL) gst_object_unref(pipeline); + return 2; + } + CaptureRun run = { + .loop = g_main_loop_new(NULL, FALSE), + .pipeline = pipeline, + .audio = g_byte_array_new(), + }; + g_mutex_init(&run.lock); + int result = run_pipeline(&run, G_CALLBACK(audio_sample), NULL); + g_byte_array_unref(run.audio); + g_free(run.failure); + g_main_loop_unref(run.loop); + g_mutex_clear(&run.lock); + gst_object_unref(pipeline); + return result; +} + +int main(int argc, char **argv) { + gst_init(&argc, &argv); + if (argc == 2 && strcmp(argv[1], "--probe") == 0) return write_probe(); + if (argc == 2 && strcmp(argv[1], "--list") == 0) return write_sources(); + if (argc > 1 && strcmp(argv[1], "--capture-video") == 0) { + return capture_video(argc, argv); + } + if (argc > 1 && strcmp(argv[1], "--capture-audio") == 0) { + return capture_audio(argc, argv); + } + fprintf(stderr, "Screener capture unavailable: unsupported command\n"); + return 2; +} diff --git a/native/client/platform/linux/capture/portal.c b/native/client/platform/linux/capture/portal.c new file mode 100644 index 0000000..daaf172 --- /dev/null +++ b/native/client/platform/linux/capture/portal.c @@ -0,0 +1,146 @@ +#include "portal.h" + +#include +#include + +typedef struct { + GMainLoop *loop; + XdpPortal *portal; + XdpSession *session; + GError *error; +} PortalRequest; + +static void finish_request(PortalRequest *request, GError *error) { + if (error != NULL) request->error = error; + g_main_loop_quit(request->loop); +} + +static void session_started(GObject *source, GAsyncResult *result, + gpointer data) { + PortalRequest *request = data; + GError *error = NULL; + if (!xdp_session_start_finish(XDP_SESSION(source), result, &error)) { + finish_request(request, error); + return; + } + finish_request(request, NULL); +} + +static void session_created(GObject *source, GAsyncResult *result, + gpointer data) { + PortalRequest *request = data; + GError *error = NULL; + request->session = xdp_portal_create_screencast_session_finish( + XDP_PORTAL(source), result, &error); + if (request->session == NULL) { + finish_request(request, error); + return; + } + xdp_session_start(request->session, NULL, NULL, session_started, request); +} + +gboolean screener_portal_available(void) { + GError *error = NULL; + GDBusConnection *bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, &error); + if (bus == NULL) { + g_clear_error(&error); + return FALSE; + } + GVariant *reply = g_dbus_connection_call_sync( + bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "NameHasOwner", + g_variant_new("(s)", "org.freedesktop.portal.Desktop"), + G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, 1000, NULL, &error); + g_object_unref(bus); + if (reply == NULL) { + g_clear_error(&error); + return FALSE; + } + gboolean available = FALSE; + g_variant_get(reply, "(b)", &available); + g_variant_unref(reply); + return available; +} + +gboolean screener_portal_capture_open(const char *restore_token, + ScreenerPortalCapture *capture, + GError **error) { + g_return_val_if_fail(capture != NULL, FALSE); + *capture = (ScreenerPortalCapture){.pipewire_fd = -1}; + + capture->portal = xdp_portal_initable_new(error); + if (capture->portal == NULL) return FALSE; + + PortalRequest request = { + .loop = g_main_loop_new(NULL, FALSE), + .portal = capture->portal, + }; + xdp_portal_create_screencast_session( + capture->portal, XDP_OUTPUT_MONITOR | XDP_OUTPUT_WINDOW, + XDP_SCREENCAST_FLAG_NONE, XDP_CURSOR_MODE_EMBEDDED, + XDP_PERSIST_MODE_PERSISTENT, + restore_token != NULL && restore_token[0] != '\0' ? restore_token : NULL, + NULL, session_created, &request); + g_main_loop_run(request.loop); + g_main_loop_unref(request.loop); + if (request.error != NULL) { + g_propagate_error(error, request.error); + screener_portal_capture_close(capture); + return FALSE; + } + capture->session = request.session; + + GVariant *streams = xdp_session_get_streams(capture->session); + if (streams == NULL) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "portal returned no screen-cast streams"); + screener_portal_capture_close(capture); + return FALSE; + } + GVariantIter iterator; + GVariant *properties = NULL; + g_variant_iter_init(&iterator, streams); + gboolean found = g_variant_iter_next(&iterator, "(u@a{sv})", + &capture->node_id, &properties); + if (properties != NULL) { + guint64 serial = 0; + if (g_variant_lookup(properties, "pipewire-serial", "t", &serial) && + serial > 0) { + capture->target_object = g_strdup_printf("%" G_GUINT64_FORMAT, serial); + } + g_variant_unref(properties); + } + g_variant_unref(streams); + if (!found || capture->node_id == 0) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "portal returned an invalid PipeWire stream"); + screener_portal_capture_close(capture); + return FALSE; + } + + capture->pipewire_fd = xdp_session_open_pipewire_remote(capture->session); + if (capture->pipewire_fd < 0) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "portal could not open its PipeWire remote"); + screener_portal_capture_close(capture); + return FALSE; + } + if (capture->target_object == NULL) { + capture->target_object = g_strdup_printf("%u", capture->node_id); + } + capture->restore_token = xdp_session_get_restore_token(capture->session); + return TRUE; +} + +void screener_portal_capture_close(ScreenerPortalCapture *capture) { + if (capture == NULL) return; + if (capture->pipewire_fd >= 0) close(capture->pipewire_fd); + capture->pipewire_fd = -1; + g_clear_pointer(&capture->restore_token, g_free); + g_clear_pointer(&capture->target_object, g_free); + if (capture->session != NULL) { + xdp_session_close(capture->session); + g_clear_object(&capture->session); + } + g_clear_object(&capture->portal); +} diff --git a/native/client/platform/linux/capture/portal.h b/native/client/platform/linux/capture/portal.h new file mode 100644 index 0000000..5b71d86 --- /dev/null +++ b/native/client/platform/linux/capture/portal.h @@ -0,0 +1,22 @@ +#ifndef SCREENER_LINUX_PORTAL_H +#define SCREENER_LINUX_PORTAL_H + +#include +#include + +typedef struct { + XdpPortal *portal; + XdpSession *session; + guint32 node_id; + char *target_object; + int pipewire_fd; + char *restore_token; +} ScreenerPortalCapture; + +gboolean screener_portal_available(void); +gboolean screener_portal_capture_open(const char *restore_token, + ScreenerPortalCapture *capture, + GError **error); +void screener_portal_capture_close(ScreenerPortalCapture *capture); + +#endif diff --git a/native/client/platform/windows/capture/README.md b/native/client/platform/windows/capture/README.md index 76f8f2e..c7b6b37 100644 --- a/native/client/platform/windows/capture/README.md +++ b/native/client/platform/windows/capture/README.md @@ -6,9 +6,14 @@ 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`). +- WGC/D3D11 screen/window video with adapter-bound Media Foundation H.264 or + libvpx VP8 output (`--capture-video --codec auto|h264|vp8`). +- bounded 320x180 BMP source previews (`--preview`), delivered once per target + over the local control connection rather than the media route. + +The video command receives the current product resolution, frame-rate, bitrate, +and quality preference. A replacement process applies live changes while the Go +session retains its Pion source and connections. Video and audio run as separate bounded child processes. A source whose audio loopback cannot be initialized keeps video available and reports audio @@ -16,20 +21,25 @@ 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 +Auto compares target-profile encoding work within a four-second selection +budget; H.264 that meets the target needs no software comparison. The selected +codec remains fixed across profile and source changes. VP8 reads the existing +NV12 surface through one staging texture and uses the same encoded-frame +boundary. The process has no 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 +npm run check:client-capture ``` 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. +The build caches pinned libvpx, NASM and make inputs under `build/client-check`; +only statically linked VP8 code and its notices enter the package. - - diff --git a/native/client/platform/windows/capture/build-libvpx.ps1 b/native/client/platform/windows/capture/build-libvpx.ps1 new file mode 100644 index 0000000..3190bca --- /dev/null +++ b/native/client/platform/windows/capture/build-libvpx.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$OutputDirectory, + [Parameter(Mandatory = $true)] [string]$VisualStudioDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$pinPath = Join-Path $PSScriptRoot 'libvpx-dependencies.json' +$pins = Get-Content -Raw $pinPath | ConvertFrom-Json +$outputPath = [IO.Path]::GetFullPath($OutputDirectory) +$sourcePath = Join-Path $outputPath ('libvpx-' + $pins.libvpx.commit) +$buildPath = Join-Path $outputPath 'build' +$libraryPath = Join-Path $buildPath 'x64\Release\vpxmt.lib' +$stampPath = Join-Path $buildPath 'build-stamp.txt' +$toolset = Get-ChildItem (Join-Path $VisualStudioDirectory 'VC\Tools\MSVC') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 +$stamp = (Get-FileHash $pinPath).Hash + (Get-FileHash $PSCommandPath).Hash + $toolset.Name +if ((Test-Path $libraryPath) -and (Test-Path $stampPath) -and + (Get-Content -Raw $stampPath).Trim() -eq $stamp) { + return @{ Include = $sourcePath; Library = $libraryPath } +} + +New-Item -ItemType Directory -Path $outputPath,$buildPath -Force | Out-Null +function Get-PinnedArchive($pin, [string]$name) { + $archive = Join-Path $outputPath $name + if (!(Test-Path $archive) -or + (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $pin.sha256) { + Invoke-WebRequest -UseBasicParsing -Uri $pin.url -OutFile $archive -TimeoutSec 120 + } + if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $pin.sha256) { + throw "Native codec dependency digest mismatch: $name" + } + return $archive +} + +$sourceArchive = Get-PinnedArchive $pins.libvpx 'libvpx.tar.gz' +& tar -xf $sourceArchive -C $outputPath +if ($LASTEXITCODE -ne 0) { throw 'Could not unpack libvpx source.' } +$nasmArchive = Get-PinnedArchive $pins.nasm 'nasm.zip' +$nasmPath = Join-Path $outputPath ('nasm\nasm-' + $pins.nasm.version) +Expand-Archive -LiteralPath $nasmArchive -DestinationPath (Join-Path $outputPath 'nasm') -Force +$makeArchive = Get-PinnedArchive $pins.make 'make.pkg.tar.zst' +$makeRoot = Join-Path $outputPath 'make' +$makePath = Join-Path $makeRoot 'usr\bin' +New-Item -ItemType Directory -Path $makeRoot -Force | Out-Null +& tar -xf $makeArchive -C $makeRoot usr/bin/make.exe +if ($LASTEXITCODE -ne 0) { throw 'Could not unpack the libvpx build tool.' } + +$gitRoot = Split-Path -Parent (Split-Path -Parent (Get-Command git.exe).Source) +$bashPath = Join-Path $gitRoot 'bin\bash.exe' +if (!(Test-Path $bashPath)) { throw 'Git for Windows Bash is required to build libvpx.' } +$developerCommand = Join-Path $VisualStudioDirectory 'Common7\Tools\VsDevCmd.bat' +$compile = @( + 'set "PATH={0};{1};{2};%PATH%"' -f $nasmPath,$makePath,(Join-Path $gitRoot 'usr\bin') + 'call "{0}" -arch=x64 -host_arch=x64 >nul' -f $developerCommand + 'cd /d "{0}"' -f $buildPath + '"{0}" "{1}" --target=x86_64-win64-vs17 --as=nasm --disable-vp9 --disable-vp8-decoder --disable-examples --disable-unit-tests --disable-tools --disable-docs --disable-webm-io --disable-libyuv --disable-postproc --enable-realtime-only --enable-static-msvcrt' -f $bashPath,((Join-Path $sourcePath 'configure').Replace('\','/')) + 'make.exe -B vpx.vcxproj' + 'msbuild vpx.vcxproj /nologo /t:Rebuild /p:Configuration=Release /p:Platform=x64 /m:4 /v:minimal' +) -join ' && ' +& cmd.exe /d /s /c $compile | Out-Host +if ($LASTEXITCODE -ne 0 -or !(Test-Path $libraryPath)) { + throw 'Native VP8 library compilation failed.' +} +[IO.File]::WriteAllText($stampPath, $stamp + "`n") +return @{ Include = $sourcePath; Library = $libraryPath } diff --git a/native/client/platform/windows/capture/build.ps1 b/native/client/platform/windows/capture/build.ps1 index 06a60e5..4de4c23 100644 --- a/native/client/platform/windows/capture/build.ps1 +++ b/native/client/platform/windows/capture/build.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$OutputDirectory + [string]$OutputDirectory, + [switch]$Check ) Set-StrictMode -Version Latest @@ -26,22 +27,36 @@ $developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' $mainSourcePath = Join-Path $helperDirectory 'main.cpp' $audioSourcePath = Join-Path $helperDirectory 'process_audio.cpp' $targetSourcePath = Join-Path $helperDirectory 'capture_target.cpp' +$vp8SourcePath = Join-Path $helperDirectory 'vp8_encoder.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' +$vp8ObjectPath = Join-Path $outputPath 'vp8-encoder.obj' New-Item -ItemType Directory -Path $outputPath -Force | Out-Null +$vpx = & (Join-Path $helperDirectory 'build-libvpx.ps1') -OutputDirectory (Join-Path $outputPath 'libvpx') -VisualStudioDirectory $installationPath $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 /external:I "{2}" /external:W0 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A "{0}" /Fo:"{1}"' -f $mainSourcePath, $mainObjectPath, $vpx.Include '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 '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 + 'cl.exe /nologo /c /std:c++20 /EHsc /O2 /W4 /WX /external:I "{2}" /external:W0 "{0}" /Fo:"{1}"' -f $vp8SourcePath, $vp8ObjectPath, $vpx.Include + 'link.exe /nologo /LTCG /out:"{0}" "{1}" "{2}" "{3}" "{4}" "{5}" ole32.lib mmdevapi.lib runtimeobject.lib user32.lib gdi32.lib dwmapi.lib shell32.lib mfplat.lib mf.lib mfuuid.lib d3d11.lib dxgi.lib dxguid.lib evr.lib oleaut32.lib windowsapp.lib' -f $executablePath, $mainObjectPath, $audioObjectPath, $targetObjectPath, $vp8ObjectPath, $vpx.Library ) -join ' && ' & cmd.exe /d /s /c $compile if ($LASTEXITCODE -ne 0) { throw "Window-capture helper compilation failed with exit code $LASTEXITCODE." } +if ($Check) { + $geometrySource = Join-Path $helperDirectory 'capture_geometry.test.cpp' + $geometryObject = Join-Path $outputPath 'capture-geometry.test.obj' + $geometryCheck = Join-Path $outputPath 'capture-geometry.test.exe' + $compileCheck = 'call "{0}" -arch=x64 -host_arch=x64 >nul && cl.exe /nologo /std:c++20 /EHsc /W4 /WX "{1}" /Fo:"{2}" /Fe:"{3}" /link user32.lib' -f $developerCommand, $geometrySource, $geometryObject, $geometryCheck + & cmd.exe /d /s /c $compileCheck + if ($LASTEXITCODE -ne 0) { throw 'Capture geometry check compilation failed.' } + & $geometryCheck + if ($LASTEXITCODE -ne 0) { throw 'Capture geometry check failed.' } +} Write-Output $executablePath diff --git a/native/client/platform/windows/capture/capture_geometry.h b/native/client/platform/windows/capture/capture_geometry.h new file mode 100644 index 0000000..66f7368 --- /dev/null +++ b/native/client/platform/windows/capture/capture_geometry.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include + +namespace screener::capture { + +inline SIZE DisplayedFrameSize(SIZE captured, SIZE desktop, SIZE target, + DISPLAYCONFIG_SCALING scaling, + DISPLAYCONFIG_ROTATION rotation) { + if (scaling != DISPLAYCONFIG_SCALING_STRETCHED || + captured.cx != desktop.cx || captured.cy != desktop.cy || + target.cx <= 0 || target.cy <= 0 || + target.cx > 16'384 || target.cy > 16'384) { + return captured; + } + switch (rotation) { + case DISPLAYCONFIG_ROTATION_IDENTITY: + case DISPLAYCONFIG_ROTATION_ROTATE180: + return target; + case DISPLAYCONFIG_ROTATION_ROTATE90: + case DISPLAYCONFIG_ROTATION_ROTATE270: + return {target.cy, target.cx}; + default: + return captured; + } +} + +inline RECT FitFrameRect(SIZE content, SIZE output) { + const double scale = std::min(static_cast(output.cx) / content.cx, + static_cast(output.cy) / content.cy); + const LONG width = std::min(output.cx, std::max( + 2, static_cast(std::llround(content.cx * scale)) & ~1L)); + const LONG height = std::min(output.cy, std::max( + 2, static_cast(std::llround(content.cy * scale)) & ~1L)); + const LONG left = (output.cx - width) / 2; + const LONG top = (output.cy - height) / 2; + return {left, top, left + width, top + height}; +} + +} // namespace screener::capture diff --git a/native/client/platform/windows/capture/capture_geometry.test.cpp b/native/client/platform/windows/capture/capture_geometry.test.cpp new file mode 100644 index 0000000..4bad7be --- /dev/null +++ b/native/client/platform/windows/capture/capture_geometry.test.cpp @@ -0,0 +1,53 @@ +#define NOMINMAX +#include "capture_geometry.h" + +#include +#include + +int main() { + using namespace screener::capture; + const SIZE captured{1280, 960}; + const SIZE monitor{1920, 1080}; + const auto presentation = [&](DISPLAYCONFIG_SCALING scaling, + DISPLAYCONFIG_ROTATION rotation = + DISPLAYCONFIG_ROTATION_IDENTITY) { + return DisplayedFrameSize(captured, captured, monitor, scaling, rotation); + }; + const auto expect_rect = [&](SIZE content, RECT expected) { + const RECT actual = FitFrameRect(content, monitor); + assert(EqualRect(&actual, &expected)); + }; + + expect_rect(presentation(DISPLAYCONFIG_SCALING_STRETCHED), {0, 0, 1920, 1080}); + for (const auto scaling : {DISPLAYCONFIG_SCALING_IDENTITY, + DISPLAYCONFIG_SCALING_CENTERED, + DISPLAYCONFIG_SCALING_ASPECTRATIOCENTEREDMAX, + DISPLAYCONFIG_SCALING_CUSTOM, + DISPLAYCONFIG_SCALING_PREFERRED}) { + expect_rect(presentation(scaling), {240, 0, 1680, 1080}); + } + // A window/texture that is not the desktop raster must not inherit its stretch. + expect_rect(DisplayedFrameSize(captured, monitor, monitor, + DISPLAYCONFIG_SCALING_STRETCHED, + DISPLAYCONFIG_ROTATION_IDENTITY), + {240, 0, 1680, 1080}); + expect_rect(DisplayedFrameSize(monitor, captured, monitor, + DISPLAYCONFIG_SCALING_STRETCHED, + DISPLAYCONFIG_ROTATION_IDENTITY), + {0, 0, 1920, 1080}); + for (const auto rotation : {DISPLAYCONFIG_ROTATION_ROTATE90, + DISPLAYCONFIG_ROTATION_ROTATE270}) { + const SIZE rotated = presentation(DISPLAYCONFIG_SCALING_STRETCHED, rotation); + assert(rotated.cx == 1080 && rotated.cy == 1920); + } + expect_rect(presentation(DISPLAYCONFIG_SCALING_STRETCHED, + DISPLAYCONFIG_ROTATION_ROTATE180), + {0, 0, 1920, 1080}); + expect_rect(DisplayedFrameSize(captured, captured, {}, + DISPLAYCONFIG_SCALING_STRETCHED, + DISPLAYCONFIG_ROTATION_IDENTITY), + {240, 0, 1680, 1080}); + const RECT wide = FitFrameRect({2560, 1080}, monitor); + assert(wide.left == 0 && wide.right == 1920 && wide.top > 0 && wide.bottom < 1080); + std::cout << "Windows capture presentation geometry passed.\n"; +} diff --git a/native/client/platform/windows/capture/capture_target.cpp b/native/client/platform/windows/capture/capture_target.cpp index 33ee49a..cd544b3 100644 --- a/native/client/platform/windows/capture/capture_target.cpp +++ b/native/client/platform/windows/capture/capture_target.cpp @@ -1,11 +1,26 @@ #define NOMINMAX #include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include #include "capture_target.h" +#include "capture_geometry.h" #include #include #include +#include #include #include #include @@ -15,10 +30,73 @@ namespace screener::capture { namespace { +using Microsoft::WRL::ComPtr; + constexpr size_t kMaxSources = 100; -constexpr LONG kPreviewWidth = 160; -constexpr LONG kPreviewHeight = 90; +constexpr LONG kPreviewWidth = 320; +constexpr LONG kPreviewHeight = 180; +constexpr LONG kMaxPreviewSourceDimension = 2'048; constexpr DWORD kBitmapHeaderBytes = 54; +constexpr wchar_t kPreviewWindowClass[] = L"ScreenerPreviewWindow"; + +SIZE ReadDisplayPresentation(const wchar_t* device_name, SIZE captured) { + UINT32 path_count = 0; + UINT32 mode_count = 0; + constexpr UINT32 flags = QDC_ONLY_ACTIVE_PATHS; + if (GetDisplayConfigBufferSizes(flags, &path_count, &mode_count) != + ERROR_SUCCESS) return captured; + std::vector paths(path_count); + std::vector modes(mode_count); + if (QueryDisplayConfig(flags, &path_count, paths.data(), &mode_count, + modes.data(), nullptr) != ERROR_SUCCESS) return captured; + + const DISPLAYCONFIG_PATH_INFO* matched = nullptr; + for (UINT32 index = 0; index < path_count; ++index) { + const auto& path = paths[index]; + DISPLAYCONFIG_SOURCE_DEVICE_NAME name{}; + name.header = {DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, sizeof(name), + path.sourceInfo.adapterId, path.sourceInfo.id}; + if (DisplayConfigGetDeviceInfo(&name.header) != ERROR_SUCCESS) return captured; + if (wcscmp(name.viewGdiDeviceName, device_name) != 0) continue; + // A cloned desktop has no single physical presentation to reproduce. + if (matched != nullptr) return captured; + matched = &path; + } + if (matched == nullptr || + !matched->targetInfo.targetAvailable || + (matched->flags & DISPLAYCONFIG_PATH_SUPPORT_VIRTUAL_MODE) != 0 || + matched->sourceInfo.modeInfoIdx >= mode_count || + matched->targetInfo.modeInfoIdx >= mode_count) return captured; + const auto& source = modes[matched->sourceInfo.modeInfoIdx]; + const auto& target = modes[matched->targetInfo.modeInfoIdx]; + const auto same_adapter = [](LUID first, LUID second) { + return first.HighPart == second.HighPart && first.LowPart == second.LowPart; + }; + if (source.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE || + target.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_TARGET || + source.id != matched->sourceInfo.id || target.id != matched->targetInfo.id || + !same_adapter(source.adapterId, matched->sourceInfo.adapterId) || + !same_adapter(target.adapterId, matched->targetInfo.adapterId)) return captured; + const auto active = target.targetMode.targetVideoSignalInfo.activeSize; + return DisplayedFrameSize( + captured, + {static_cast(source.sourceMode.width), + static_cast(source.sourceMode.height)}, + {static_cast(active.cx), static_cast(active.cy)}, + matched->targetInfo.scaling, matched->targetInfo.rotation); +} + +ATOM EnsurePreviewWindowClass() { + static ATOM atom = [] { + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.lpfnWndProc = DefWindowProcW; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.lpszClassName = kPreviewWindowClass; + return RegisterClassExW(&window_class); + }(); + return atom; +} struct SourceTarget { TargetKind kind; @@ -89,6 +167,17 @@ BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) { if (!IsWindowVisible(window) || GetWindow(window, GW_OWNER) != nullptr) { return TRUE; } + const LONG_PTR extended_style = GetWindowLongPtrW(window, GWL_EXSTYLE); + if ((extended_style & WS_EX_TOOLWINDOW) != 0 || + (extended_style & WS_EX_NOACTIVATE) != 0) { + return TRUE; + } + BOOL cloaked = FALSE; + if (SUCCEEDED(DwmGetWindowAttribute(window, DWMWA_CLOAKED, &cloaked, + sizeof(cloaked))) && + cloaked != FALSE) { + return TRUE; + } const int length = GetWindowTextLengthW(window); if (length <= 0 || length > 512) return TRUE; DWORD pid = 0; @@ -150,9 +239,19 @@ HRESULT SourceRect(TargetKind kind, UINT64 source_id, RECT* rect) { } if (kind == TargetKind::window) { HWND window = reinterpret_cast(static_cast(source_id)); - if (!IsWindow(window) || IsIconic(window) || !GetWindowRect(window, rect)) { + if (!IsWindow(window) || !GetWindowRect(window, rect)) { return HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE); } + // A minimized window's screen rectangle is the taskbar icon. Its normal + // placement is the useful geometry for a one-shot thumbnail request. + WINDOWPLACEMENT placement{}; + placement.length = sizeof(placement); + if (GetWindowPlacement(window, &placement) && + (placement.showCmd == SW_SHOWMINIMIZED || IsIconic(window)) && + placement.rcNormalPosition.right > placement.rcNormalPosition.left && + placement.rcNormalPosition.bottom > placement.rcNormalPosition.top) { + *rect = placement.rcNormalPosition; + } return S_OK; } HMONITOR monitor = @@ -166,22 +265,17 @@ HRESULT SourceRect(TargetKind kind, UINT64 source_id, RECT* rect) { 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); +HRESULT WriteBitmapPreview(HDC source, LONG source_width, LONG source_height, + std::vector* output) { + if (source == nullptr || output == nullptr || source_width <= 0 || + source_height <= 0) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); } - HDC screen = GetDC(nullptr); if (screen == nullptr) return HRESULT_FROM_WIN32(GetLastError()); HDC target = CreateCompatibleDC(screen); if (target == nullptr) { - result = HRESULT_FROM_WIN32(GetLastError()); + const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); ReleaseDC(nullptr, screen); return result; } @@ -196,7 +290,7 @@ HRESULT CapturePreview(TargetKind kind, UINT64 source_id, HBITMAP image = CreateDIBSection(screen, &bitmap, DIB_RGB_COLORS, &pixels, nullptr, 0); if (image == nullptr || pixels == nullptr) { - result = HRESULT_FROM_WIN32(GetLastError()); + const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); if (image != nullptr) DeleteObject(image); DeleteDC(target); ReleaseDC(nullptr, screen); @@ -204,7 +298,8 @@ HRESULT CapturePreview(TargetKind kind, UINT64 source_id, } HGDIOBJ previous = SelectObject(target, image); RECT background{0, 0, kPreviewWidth, kPreviewHeight}; - FillRect(target, &background, static_cast(GetStockObject(BLACK_BRUSH))); + FillRect(target, &background, + static_cast(GetStockObject(BLACK_BRUSH))); const double scale = std::min( static_cast(kPreviewWidth) / source_width, @@ -216,9 +311,9 @@ HRESULT CapturePreview(TargetKind kind, UINT64 source_id, 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)) { + HRESULT result = S_OK; + if (!StretchBlt(target, left, top, width, height, source, 0, 0, + source_width, source_height, SRCCOPY)) { result = HRESULT_FROM_WIN32(GetLastError()); } @@ -253,6 +348,572 @@ HRESULT CapturePreview(TargetKind kind, UINT64 source_id, return result; } +HRESULT WriteBgraPreview(const BYTE* pixels, LONG width, LONG height, + UINT stride, std::vector* output) { + if (pixels == nullptr || output == nullptr || width <= 0 || height <= 0 || + stride < static_cast(width) * 4) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + HDC screen = GetDC(nullptr); + if (screen == nullptr) return HRESULT_FROM_WIN32(GetLastError()); + HDC source = CreateCompatibleDC(screen); + if (source == nullptr) { + const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); + ReleaseDC(nullptr, screen); + return result; + } + BITMAPINFO bitmap{}; + bitmap.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap.bmiHeader.biWidth = width; + bitmap.bmiHeader.biHeight = -height; + bitmap.bmiHeader.biPlanes = 1; + bitmap.bmiHeader.biBitCount = 32; + bitmap.bmiHeader.biCompression = BI_RGB; + void* dib_pixels = nullptr; + HBITMAP image = CreateDIBSection(screen, &bitmap, DIB_RGB_COLORS, + &dib_pixels, nullptr, 0); + if (image == nullptr || dib_pixels == nullptr) { + const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); + if (image != nullptr) DeleteObject(image); + DeleteDC(source); + ReleaseDC(nullptr, screen); + return result; + } + HGDIOBJ previous = SelectObject(source, image); + auto* destination = static_cast(dib_pixels); + for (LONG row = 0; row < height; ++row) { + std::memcpy(destination + static_cast(row) * width * 4, + pixels + static_cast(row) * stride, + static_cast(width) * 4); + } + const HRESULT result = WriteBitmapPreview(source, width, height, output); + SelectObject(source, previous); + DeleteObject(image); + DeleteDC(source); + ReleaseDC(nullptr, screen); + return result; +} + +HRESULT CreateWgcDevice( + ComPtr* device, ComPtr* context, + winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice* capture_device) { + if (device == nullptr || context == nullptr || capture_device == nullptr) { + return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + } + constexpr D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_10_0}; + D3D_FEATURE_LEVEL selected = D3D_FEATURE_LEVEL_10_0; + HRESULT result = D3D11CreateDevice( + nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels, ARRAYSIZE(levels), + D3D11_SDK_VERSION, device->GetAddressOf(), &selected, + context->GetAddressOf()); + if (FAILED(result)) { + result = D3D11CreateDevice( + nullptr, D3D_DRIVER_TYPE_WARP, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels, ARRAYSIZE(levels), + D3D11_SDK_VERSION, device->GetAddressOf(), &selected, + context->GetAddressOf()); + } + if (FAILED(result)) return result; + ComPtr dxgi_device; + result = (*device)->QueryInterface(IID_PPV_ARGS(&dxgi_device)); + if (FAILED(result)) return result; + winrt::com_ptr inspectable; + result = CreateDirect3D11DeviceFromDXGIDevice(dxgi_device.Get(), + inspectable.put()); + if (FAILED(result)) return result; + try { + *capture_device = inspectable.as< + winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>(); + } catch (const winrt::hresult_error& error) { + return error.code(); + } + return S_OK; +} + +HRESULT CreateWgcItem( + TargetKind kind, UINT64 source_id, + winrt::Windows::Graphics::Capture::GraphicsCaptureItem* item) { + if (item == nullptr) return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + using winrt::Windows::Graphics::Capture::GraphicsCaptureItem; + try { + auto interop = winrt::get_activation_factory(); + if (kind == TargetKind::window) { + return interop->CreateForWindow( + reinterpret_cast(static_cast(source_id)), + winrt::guid_of(), winrt::put_abi(*item)); + } + return interop->CreateForMonitor( + reinterpret_cast(static_cast(source_id)), + winrt::guid_of(), winrt::put_abi(*item)); + } catch (const winrt::hresult_error& error) { + return error.code(); + } +} + +HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id, + std::vector* output) { + using namespace winrt::Windows::Graphics::Capture; + using winrt::Windows::Graphics::DirectX::DirectXPixelFormat; + if (output == nullptr) return HRESULT_FROM_WIN32(ERROR_INVALID_PARAMETER); + const HRESULT apartment = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + const bool uninitialize = SUCCEEDED(apartment); + if (FAILED(apartment) && apartment != RPC_E_CHANGED_MODE) return apartment; + try { + if (!GraphicsCaptureSession::IsSupported()) { + if (uninitialize) CoUninitialize(); + return HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED); + } + } catch (const winrt::hresult_error& error) { + if (uninitialize) CoUninitialize(); + return error.code(); + } + + ComPtr device; + ComPtr context; + winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice capture_device{ + nullptr}; + GraphicsCaptureItem item{nullptr}; + Direct3D11CaptureFramePool pool{nullptr}; + GraphicsCaptureSession session{nullptr}; + HANDLE frame_ready = CreateEventW(nullptr, FALSE, FALSE, nullptr); + winrt::event_token frame_token{}; + bool subscribed = false; + HRESULT result = S_OK; + try { + result = CreateWgcDevice(&device, &context, &capture_device); + if (SUCCEEDED(result)) result = CreateWgcItem(kind, source_id, &item); + if (SUCCEEDED(result)) { + auto size = item.Size(); + if (size.Width <= 0 || size.Height <= 0 || frame_ready == nullptr) { + result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + } else { + pool = Direct3D11CaptureFramePool::CreateFreeThreaded( + capture_device, DirectXPixelFormat::B8G8R8A8UIntNormalized, 1, + size); + session = pool.CreateCaptureSession(item); + frame_token = pool.FrameArrived( + [frame_ready](const Direct3D11CaptureFramePool&, + const winrt::Windows::Foundation::IInspectable&) { + if (frame_ready != nullptr) SetEvent(frame_ready); + }); + subscribed = true; + session.StartCapture(); + const DWORD wait = WaitForSingleObject(frame_ready, 1'500); + if (wait != WAIT_OBJECT_0) { + result = wait == WAIT_TIMEOUT + ? HRESULT_FROM_WIN32(ERROR_TIMEOUT) + : HRESULT_FROM_WIN32(GetLastError()); + } else { + Direct3D11CaptureFrame frame{nullptr}; + for (;;) { + Direct3D11CaptureFrame next = pool.TryGetNextFrame(); + if (!next) break; + frame = std::move(next); + } + if (!frame) { + result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + } else { + auto content = frame.ContentSize(); + auto access = frame.Surface().as< + ::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>(); + ComPtr texture; + result = access->GetInterface(IID_PPV_ARGS(&texture)); + D3D11_TEXTURE2D_DESC description{}; + if (SUCCEEDED(result)) texture->GetDesc(&description); + if (SUCCEEDED(result) && + (description.Format != DXGI_FORMAT_B8G8R8A8_UNORM || + content.Width <= 0 || content.Height <= 0)) { + result = HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED); + } + if (SUCCEEDED(result)) { + D3D11_TEXTURE2D_DESC staging = description; + staging.Usage = D3D11_USAGE_STAGING; + staging.BindFlags = 0; + staging.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + staging.MiscFlags = 0; + ComPtr readable; + result = device->CreateTexture2D(&staging, nullptr, &readable); + if (SUCCEEDED(result)) { + context->CopyResource(readable.Get(), texture.Get()); + context->Flush(); + D3D11_MAPPED_SUBRESOURCE mapped{}; + result = context->Map(readable.Get(), 0, D3D11_MAP_READ, 0, + &mapped); + if (SUCCEEDED(result)) { + const LONG width = std::min( + content.Width, static_cast(description.Width)); + const LONG height = std::min( + content.Height, static_cast(description.Height)); + result = WriteBgraPreview( + static_cast(mapped.pData), width, height, + mapped.RowPitch, output); + context->Unmap(readable.Get(), 0); + } + } + } + } + } + } + } + } catch (const winrt::hresult_error& error) { + result = error.code(); + } catch (...) { + result = E_FAIL; + } + if (subscribed) { + try { + pool.FrameArrived(frame_token); + } catch (...) { + } + } + try { + session.Close(); + } catch (...) { + } + try { + pool.Close(); + } catch (...) { + } + // Release all WinRT/D3D wrappers before leaving the COM apartment. Their + // destructors may query the apartment even after Close() succeeds. + session = nullptr; + pool = nullptr; + item = nullptr; + capture_device = nullptr; + context.Reset(); + device.Reset(); + if (frame_ready != nullptr) { + CloseHandle(frame_ready); + } + if (uninitialize) { + CoUninitialize(); + } + return result; +} + +bool BitmapHasContent(const std::vector& bitmap) { + if (bitmap.size() < kBitmapHeaderBytes + 4) return false; + for (size_t index = kBitmapHeaderBytes; index + 3 < bitmap.size(); + index += 16) { + if (bitmap[index] > 8 || bitmap[index + 1] > 8 || bitmap[index + 2] > 8) { + return true; + } + } + return false; +} + +HICON WindowIcon(HWND window, bool* owned) { + if (owned != nullptr) *owned = false; + auto message_icon = [window](WPARAM kind) -> HICON { + DWORD_PTR value = 0; + if (SendMessageTimeoutW(window, WM_GETICON, kind, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &value) != 0) { + return reinterpret_cast(value); + } + return nullptr; + }; + if (HICON icon = message_icon(ICON_BIG); icon != nullptr) return icon; + if (HICON icon = message_icon(ICON_SMALL2); icon != nullptr) return icon; + if (HICON icon = message_icon(ICON_SMALL); icon != nullptr) return icon; + if (HICON icon = reinterpret_cast(GetClassLongPtrW( + window, GCLP_HICON)); + icon != nullptr) { + return icon; + } + if (HICON icon = reinterpret_cast(GetClassLongPtrW( + window, GCLP_HICONSM)); + icon != nullptr) { + return icon; + } + + DWORD pid = 0; + GetWindowThreadProcessId(window, &pid); + if (pid == 0) return nullptr; + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) return nullptr; + std::wstring path(32'768, L'\0'); + DWORD length = static_cast(path.size()); + const BOOL read = QueryFullProcessImageNameW(process, 0, path.data(), &length); + CloseHandle(process); + if (!read || length == 0) return nullptr; + path.resize(length); + HICON large = nullptr; + HICON small_icon = nullptr; + if (ExtractIconExW(path.c_str(), 0, &large, &small_icon, 1) == 0) return nullptr; + HICON icon = large != nullptr ? large : small_icon; + if (icon == nullptr) { + if (large != nullptr) DestroyIcon(large); + if (small_icon != nullptr) DestroyIcon(small_icon); + return nullptr; + } + if (owned != nullptr) *owned = true; + if (icon == large && small_icon != nullptr) { + DestroyIcon(small_icon); + } else if (icon == small_icon && large != nullptr) { + DestroyIcon(large); + } + return icon; +} + +HRESULT CaptureWindowIconPreview(HWND window, std::vector* output) { + HDC screen = GetDC(nullptr); + if (screen == nullptr) return HRESULT_FROM_WIN32(GetLastError()); + HDC source = CreateCompatibleDC(screen); + if (source == nullptr) { + const HRESULT 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) { + const HRESULT result = HRESULT_FROM_WIN32(GetLastError()); + if (image != nullptr) DeleteObject(image); + DeleteDC(source); + ReleaseDC(nullptr, screen); + return result; + } + HGDIOBJ previous = SelectObject(source, image); + RECT area{0, 0, kPreviewWidth, kPreviewHeight}; + FillRect(source, &area, static_cast(GetStockObject(BLACK_BRUSH))); + bool owned = false; + HICON icon = WindowIcon(window, &owned); + const int icon_size = 96; + if (icon != nullptr) { + DrawIconEx(source, (kPreviewWidth - icon_size) / 2, + (kPreviewHeight - icon_size) / 2, icon, icon_size, icon_size, 0, + nullptr, DI_NORMAL); + } else { + Rectangle(source, 104, 48, 216, 132); + MoveToEx(source, 144, 144, nullptr); + LineTo(source, 176, 144); + MoveToEx(source, 176, 144, nullptr); + LineTo(source, 176, 136); + } + const HRESULT result = WriteBitmapPreview(source, kPreviewWidth, + kPreviewHeight, output); + if (owned && icon != nullptr) DestroyIcon(icon); + SelectObject(source, previous); + DeleteObject(image); + DeleteDC(source); + ReleaseDC(nullptr, screen); + return result; +} + +HRESULT CaptureDisplayPreview(UINT64 source_id, std::vector* output) { + RECT source{}; + HRESULT result = SourceRect(TargetKind::display, 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); + } + + std::vector wgc_bitmap; + if (SUCCEEDED(CaptureWithWgc(TargetKind::display, source_id, &wgc_bitmap)) && + BitmapHasContent(wgc_bitmap)) { + *output = std::move(wgc_bitmap); + return S_OK; + } + + HDC screen = GetDC(nullptr); + if (screen == nullptr) return HRESULT_FROM_WIN32(GetLastError()); + HDC source_dc = CreateCompatibleDC(screen); + if (source_dc == nullptr) { + result = HRESULT_FROM_WIN32(GetLastError()); + ReleaseDC(nullptr, screen); + return result; + } + BITMAPINFO bitmap{}; + bitmap.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap.bmiHeader.biWidth = source_width; + bitmap.bmiHeader.biHeight = -source_height; + 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(source_dc); + ReleaseDC(nullptr, screen); + return result; + } + HGDIOBJ previous = SelectObject(source_dc, image); + if (!StretchBlt(source_dc, 0, 0, source_width, source_height, screen, + source.left, source.top, source_width, source_height, + SRCCOPY | CAPTUREBLT)) { + result = HRESULT_FROM_WIN32(GetLastError()); + } else { + result = WriteBitmapPreview(source_dc, source_width, source_height, output); + } + SelectObject(source_dc, previous); + DeleteObject(image); + DeleteDC(source_dc); + ReleaseDC(nullptr, screen); + return result; +} + +HRESULT CaptureWindowPreview(UINT64 source_id, std::vector* output) { + RECT source{}; + HRESULT result = SourceRect(TargetKind::window, source_id, &source); + if (FAILED(result)) return result; + LONG source_width = source.right - source.left; + LONG source_height = source.bottom - source.top; + if (source_width <= 0 || source_height <= 0) { + return HRESULT_FROM_WIN32(ERROR_INVALID_DATA); + } + const double limit = std::min( + 1.0, static_cast(kMaxPreviewSourceDimension) / + std::max(source_width, source_height)); + source_width = std::max(1, static_cast(source_width * limit)); + source_height = std::max(1, static_cast(source_height * limit)); + + HWND window = reinterpret_cast(static_cast(source_id)); + // A minimized window has no current composited frame. Returning a stale + // DWM/PrintWindow fragment is worse than an explicit app identity marker. + if (IsIconic(window)) return CaptureWindowIconPreview(window, output); + + std::vector wgc_bitmap; + if (SUCCEEDED(CaptureWithWgc(TargetKind::window, source_id, &wgc_bitmap)) && + BitmapHasContent(wgc_bitmap)) { + *output = std::move(wgc_bitmap); + return S_OK; + } + + // DWM owns the same per-window thumbnails used by the task switcher. It is + // independent of desktop occlusion (including our picker). Keep this as a + // fallback for window classes WGC declines; minimized windows already took + // the explicit identity path above. + if (EnsurePreviewWindowClass() != 0) { + HWND destination = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, kPreviewWindowClass, L"", + WS_POPUP, -32'768, -32'768, kPreviewWidth, kPreviewHeight, nullptr, + nullptr, GetModuleHandleW(nullptr), nullptr); + if (destination != nullptr) { + HTHUMBNAIL thumbnail = nullptr; + HRESULT thumbnail_result = + DwmRegisterThumbnail(destination, window, &thumbnail); + if (SUCCEEDED(thumbnail_result) && thumbnail != nullptr) { + SIZE source_size{}; + thumbnail_result = DwmQueryThumbnailSourceSize(thumbnail, &source_size); + if (SUCCEEDED(thumbnail_result) && source_size.cx > 0 && + source_size.cy > 0) { + const double scale = std::min( + static_cast(kPreviewWidth) / source_size.cx, + static_cast(kPreviewHeight) / source_size.cy); + const LONG width = std::max( + 1, static_cast(source_size.cx * scale)); + const LONG height = std::max( + 1, static_cast(source_size.cy * scale)); + DWM_THUMBNAIL_PROPERTIES properties{}; + properties.dwFlags = DWM_TNP_VISIBLE | DWM_TNP_RECTDESTINATION | + DWM_TNP_SOURCECLIENTAREAONLY; + properties.fVisible = TRUE; + properties.fSourceClientAreaOnly = FALSE; + properties.rcDestination = { + (kPreviewWidth - width) / 2, (kPreviewHeight - height) / 2, + (kPreviewWidth - width) / 2 + width, + (kPreviewHeight - height) / 2 + height}; + thumbnail_result = DwmUpdateThumbnailProperties( + thumbnail, &properties); + if (SUCCEEDED(thumbnail_result)) { + SetWindowPos(destination, HWND_TOPMOST, -32'768, -32'768, + kPreviewWidth, kPreviewHeight, + SWP_NOACTIVATE | SWP_NOSENDCHANGING); + ShowWindow(destination, SW_SHOWNOACTIVATE); + UpdateWindow(destination); + DwmFlush(); + Sleep(12); + HDC destination_dc = GetDC(destination); + if (destination_dc != nullptr) { + thumbnail_result = WriteBitmapPreview( + destination_dc, kPreviewWidth, kPreviewHeight, output); + ReleaseDC(destination, destination_dc); + } else { + thumbnail_result = HRESULT_FROM_WIN32(GetLastError()); + } + } + } + DwmUnregisterThumbnail(thumbnail); + } + ShowWindow(destination, SW_HIDE); + DestroyWindow(destination); + if (SUCCEEDED(thumbnail_result) && BitmapHasContent(*output)) return S_OK; + } + } + + 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 = source_width; + bitmap.bmiHeader.biHeight = -source_height; + 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, source_width, source_height}; + FillRect(target, &background, + static_cast(GetStockObject(BLACK_BRUSH))); + if (!PrintWindow(window, target, PW_RENDERFULLCONTENT)) { + // A few older window classes only implement the original PrintWindow + // contract; accept that fallback before returning an advisory failure. + if (!PrintWindow(window, target, 0)) { + result = HRESULT_FROM_WIN32(ERROR_PARTIAL_COPY); + } + } + if (SUCCEEDED(result)) { + result = WriteBitmapPreview(target, source_width, source_height, output); + } + SelectObject(target, previous); + DeleteObject(image); + DeleteDC(target); + ReleaseDC(nullptr, screen); + if (SUCCEEDED(result) && BitmapHasContent(*output)) return S_OK; + output->clear(); + return FAILED(result) ? result : HRESULT_FROM_WIN32(ERROR_PARTIAL_COPY); +} + +HRESULT CapturePreview(TargetKind kind, UINT64 source_id, + std::vector* output) { + return kind == TargetKind::window + ? CaptureWindowPreview(source_id, output) + : CaptureDisplayPreview(source_id, output); +} + } // namespace int WriteSourceList() { @@ -337,6 +998,44 @@ HRESULT ValidateWindowTarget(UINT64 source_id, DWORD pid, return ValidateProcessTarget(pid, expected_creation_time); } +SIZE CapturePresentation::Resolve(UINT32 width, UINT32 height, bool key_frame) { + const SIZE captured{static_cast(width), static_cast(height)}; + const HWND window = kind_ == TargetKind::window + ? reinterpret_cast(static_cast(source_id_)) + : nullptr; + const HMONITOR monitor = window != nullptr + ? MonitorFromWindow(window, MONITOR_DEFAULTTONULL) + : reinterpret_cast( + static_cast(source_id_)); + // WGC/CCD use physical pixels; fullscreen eligibility must use them too. + const auto previous_dpi = + SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + MONITORINFOEXW info{}; + info.cbSize = sizeof(info); + bool eligible = previous_dpi != nullptr && GetMonitorInfoW(monitor, &info); + if (eligible && window != nullptr) { + RECT client{}; + POINT origin{}; + eligible = !IsIconic(window) && GetClientRect(window, &client) && + ClientToScreen(window, &origin); + OffsetRect(&client, origin.x, origin.y); + eligible = eligible && EqualRect(&client, &info.rcMonitor); + } + if (previous_dpi != nullptr) SetThreadDpiAwarenessContext(previous_dpi); + if (!eligible) { + monitor_ = nullptr; + return captured; + } + if (key_frame || monitor != monitor_ || !EqualRect(&info.rcMonitor, &bounds_) || + captured.cx != captured_.cx || captured.cy != captured_.cy) { + presentation_ = ReadDisplayPresentation(info.szDevice, captured); + monitor_ = monitor; + bounds_ = info.rcMonitor; + captured_ = captured; + } + return presentation_; +} + HRESULT ValidateDisplayTarget(UINT64 source_id) { if (source_id == 0 || source_id > diff --git a/native/client/platform/windows/capture/capture_target.h b/native/client/platform/windows/capture/capture_target.h index 418e9fd..f14f232 100644 --- a/native/client/platform/windows/capture/capture_target.h +++ b/native/client/platform/windows/capture/capture_target.h @@ -16,4 +16,20 @@ HRESULT ValidateWindowTarget(UINT64 source_id, DWORD pid, UINT64 expected_creation_time); HRESULT ValidateDisplayTarget(UINT64 source_id); +class CapturePresentation final { + public: + CapturePresentation(TargetKind kind, UINT64 source_id) + : kind_(kind), source_id_(source_id) {} + + SIZE Resolve(UINT32 width, UINT32 height, bool key_frame); + + private: + TargetKind kind_; + UINT64 source_id_; + HMONITOR monitor_ = nullptr; + RECT bounds_{}; + SIZE captured_{}; + SIZE presentation_{}; +}; + } // namespace screener::capture diff --git a/native/client/platform/windows/capture/libvpx-dependencies.json b/native/client/platform/windows/capture/libvpx-dependencies.json new file mode 100644 index 0000000..38c0899 --- /dev/null +++ b/native/client/platform/windows/capture/libvpx-dependencies.json @@ -0,0 +1,17 @@ +{ + "libvpx": { + "version": "1.17.0", + "commit": "6df3ec34557879fff673706f4a1d9fbd0f3a6f0e", + "url": "https://github.com/webmproject/libvpx/archive/6df3ec34557879fff673706f4a1d9fbd0f3a6f0e.tar.gz", + "sha256": "073bd6202e4c55c8cb759abbb123aecd4cc8d99f52ba27af790178cfb1108423" + }, + "nasm": { + "version": "2.16.03", + "url": "https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/win64/nasm-2.16.03-win64.zip", + "sha256": "3ee4782247bcb874378d02f7eab4e294a84d3d15f3f6ee2de2f47a46aa7226e6" + }, + "make": { + "url": "https://repo.msys2.org/msys/x86_64/make-4.4.1-2-x86_64.pkg.tar.zst", + "sha256": "2408af61717dae87b00c855b132769a125c708907fc94a46bb16dae076113e5c" + } +} diff --git a/native/client/platform/windows/capture/main.cpp b/native/client/platform/windows/capture/main.cpp index ec30aba..0b856b2 100644 --- a/native/client/platform/windows/capture/main.cpp +++ b/native/client/platform/windows/capture/main.cpp @@ -29,7 +29,11 @@ #include #include "capture_target.h" +#include "capture_geometry.h" #include "process_audio.h" +#ifndef SCREENER_H264_FIXTURE +#include "vp8_encoder.h" +#endif #include #include @@ -43,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -55,12 +60,27 @@ using Microsoft::WRL::ComPtr; +#ifndef ____x_ABI_CWindows_CGraphics_CCapture_CIGraphicsCaptureSession5_INTERFACE_DEFINED__ +#define ____x_ABI_CWindows_CGraphics_CCapture_CIGraphicsCaptureSession5_INTERFACE_DEFINED__ +namespace ABI::Windows::Graphics::Capture { +MIDL_INTERFACE("67C0EA62-1F85-5061-925A-239BE0AC09CB") +IGraphicsCaptureSession5 : public IInspectable { + public: + IFACEMETHOD(get_MinUpdateInterval)( + ABI::Windows::Foundation::TimeSpan* value) = 0; + IFACEMETHOD(put_MinUpdateInterval)( + ABI::Windows::Foundation::TimeSpan value) = 0; +}; +} // namespace ABI::Windows::Graphics::Capture +#endif + namespace { constexpr UINT32 kWidth = 1280; constexpr UINT32 kHeight = 720; constexpr UINT32 kFrameRate = 30; constexpr UINT32 kBitRate = 3'000'000; +constexpr LONGLONG kFastCaptureUpdateInterval100ns = 40'000; constexpr UINT32 kVbvBytes = kBitRate / kFrameRate / 8; constexpr UINT32 kGopFrames = 60; constexpr UINT32 kMaxEventsPerPump = 64; @@ -76,6 +96,50 @@ constexpr DWORD kMaxPdhArrayItems = 16 * 1024; constexpr LONGLONG kFrameDuration100ns = 10'000'000 / kFrameRate; +enum class DegradationPreference { + resolution, + balanced, + framerate, +}; + +struct VideoProfile final { + UINT32 width = kWidth; + UINT32 height = kHeight; + UINT32 frame_rate = kFrameRate; + UINT32 bit_rate = kBitRate; + DegradationPreference preference = DegradationPreference::balanced; + + UINT32 vbv_bytes() const { return bit_rate / frame_rate / 8; } + UINT32 gop_frames() const { return frame_rate * 2; } + LONGLONG frame_duration_100ns() const { + return 10'000'000 / static_cast(frame_rate); + } + UINT32 quality_vs_speed() const { + if (preference == DegradationPreference::resolution) return 100; + if (preference == DegradationPreference::framerate) return 0; + return 50; + } + UINT32 h264_level() const { + const UINT64 macroblocks = + ((static_cast(width) + 15) / 16) * + ((static_cast(height) + 15) / 16); + const UINT64 macroblocks_per_second = macroblocks * frame_rate; + if (macroblocks <= 3'600 && macroblocks_per_second <= 108'000) return 31; + if (macroblocks <= 8'192 && macroblocks_per_second <= 245'760) return 40; + if (macroblocks <= 8'704 && macroblocks_per_second <= 522'240) return 42; + if (macroblocks <= 22'080 && macroblocks_per_second <= 589'824) return 50; + return 51; + } + std::string profile_level_id() const { + std::ostringstream output; + output << "42c0" << std::hex << std::setfill('0') << std::setw(2) + << h264_level(); + return output.str(); + } +}; + +constexpr VideoProfile kDefaultVideoProfile{}; + class GateFailure final : public std::runtime_error { public: GateFailure(std::string stage, std::string detail, HRESULT result = S_OK) @@ -99,6 +163,18 @@ void Check(HRESULT result, const std::string& stage) { } } +void EnableFastCaptureUpdates( + const winrt::Windows::Graphics::Capture::GraphicsCaptureSession& session) { + ComPtr session5; + auto* inspectable = reinterpret_cast(winrt::get_abi(session)); + if (SUCCEEDED(inspectable->QueryInterface(IID_PPV_ARGS(&session5)))) { + // Zero is not an unlimited rate on current Windows. Keep WGC faster than + // every supported profile and let the output cadence own the exact cap. + ABI::Windows::Foundation::TimeSpan interval{kFastCaptureUpdateInterval100ns}; + (void)session5->put_MinUpdateInterval(interval); + } +} + std::wstring Lower(std::wstring value) { std::transform(value.begin(), value.end(), value.begin(), [](wchar_t character) { return std::towlower(character); }); @@ -464,31 +540,47 @@ void SetBool(ICodecAPI* codec, const GUID& key, bool expected, } } -void ConfigureCodec(ICodecAPI* codec) { +void SetOptionalU32(ICodecAPI* codec, const GUID& key, UINT32 value) { + if (codec->IsSupported(&key) != S_OK) return; + VARIANT requested; + VariantInit(&requested); + requested.vt = VT_UI4; + requested.ulVal = value; + (void)codec->SetValue(&key, &requested); + VariantClear(&requested); +} + +void ConfigureCodec( + ICodecAPI* codec, + const VideoProfile& profile = kDefaultVideoProfile) { SetU32(codec, CODECAPI_AVEncCommonRateControlMode, eAVEncCommonRateControlMode_CBR, "codec-cbr"); SetBool(codec, CODECAPI_AVLowLatencyMode, true, "codec-low-latency"); - SetU32(codec, CODECAPI_AVEncCommonMeanBitRate, kBitRate, + SetU32(codec, CODECAPI_AVEncCommonMeanBitRate, profile.bit_rate, "codec-mean-bitrate"); - SetU32(codec, CODECAPI_AVEncCommonBufferSize, kVbvBytes, + SetU32(codec, CODECAPI_AVEncCommonBufferSize, profile.vbv_bytes(), "codec-vbv-bytes"); - SetU32(codec, CODECAPI_AVEncMPVGOPSize, kGopFrames, "codec-gop"); + SetU32(codec, CODECAPI_AVEncMPVGOPSize, profile.gop_frames(), "codec-gop"); + SetOptionalU32(codec, CODECAPI_AVEncCommonQualityVsSpeed, + profile.quality_vs_speed()); RequireProperty(codec, CODECAPI_AVEncVideoForceKeyFrame, "codec-force-keyframe"); } -ComPtr CreateOutputType() { +ComPtr CreateOutputType( + const VideoProfile& profile = kDefaultVideoProfile) { ComPtr type; Check(MFCreateMediaType(&type), "output-type-create"); Check(type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "output-type-major"); Check(type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264), "output-type-subtype"); - Check(type->SetUINT32(MF_MT_AVG_BITRATE, kBitRate), + Check(type->SetUINT32(MF_MT_AVG_BITRATE, profile.bit_rate), "output-type-bitrate"); - Check(MFSetAttributeSize(type.Get(), MF_MT_FRAME_SIZE, kWidth, kHeight), + Check(MFSetAttributeSize(type.Get(), MF_MT_FRAME_SIZE, profile.width, + profile.height), "output-type-size"); - Check(MFSetAttributeRatio(type.Get(), MF_MT_FRAME_RATE, kFrameRate, 1), + Check(MFSetAttributeRatio(type.Get(), MF_MT_FRAME_RATE, profile.frame_rate, 1), "output-type-framerate"); Check(MFSetAttributeRatio(type.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1), "output-type-pixel-aspect"); @@ -497,27 +589,30 @@ ComPtr CreateOutputType() { "output-type-interlace"); Check(type->SetUINT32(MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Base), "output-type-profile"); - Check(type->SetUINT32(MF_MT_MPEG2_LEVEL, 31), "output-type-level"); + Check(type->SetUINT32(MF_MT_MPEG2_LEVEL, profile.h264_level()), + "output-type-level"); return type; } -ComPtr CreateInputType() { +ComPtr CreateInputType( + const VideoProfile& profile = kDefaultVideoProfile) { ComPtr type; Check(MFCreateMediaType(&type), "input-type-create"); Check(type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "input-type-major"); Check(type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "input-type-subtype"); - Check(MFSetAttributeSize(type.Get(), MF_MT_FRAME_SIZE, kWidth, kHeight), + Check(MFSetAttributeSize(type.Get(), MF_MT_FRAME_SIZE, profile.width, + profile.height), "input-type-size"); - Check(MFSetAttributeRatio(type.Get(), MF_MT_FRAME_RATE, kFrameRate, 1), + Check(MFSetAttributeRatio(type.Get(), MF_MT_FRAME_RATE, profile.frame_rate, 1), "input-type-framerate"); Check(MFSetAttributeRatio(type.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1), "input-type-pixel-aspect"); Check(type->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive), "input-type-interlace"); - Check(type->SetUINT32(MF_MT_DEFAULT_STRIDE, kWidth), + Check(type->SetUINT32(MF_MT_DEFAULT_STRIDE, profile.width), "input-type-stride"); return type; } @@ -531,7 +626,9 @@ void RequireTypeU32(IMFAttributes* type, const GUID& key, UINT32 expected, } } -void ValidateMediaTypes(IMFTransform* transform) { +void ValidateMediaTypes( + IMFTransform* transform, + const VideoProfile& profile = kDefaultVideoProfile) { ComPtr output; Check(transform->GetOutputCurrentType(0, &output), "output-type-current"); @@ -540,17 +637,18 @@ void ValidateMediaTypes(IMFTransform* transform) { if (subtype != MFVideoFormat_H264) { Fail("output-type-subtype-weakened", "output is not H264"); } - RequireTypeU32(output.Get(), MF_MT_AVG_BITRATE, kBitRate, + RequireTypeU32(output.Get(), MF_MT_AVG_BITRATE, profile.bit_rate, "output-type-bitrate"); RequireTypeU32(output.Get(), MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Base, "output-type-profile"); - RequireTypeU32(output.Get(), MF_MT_MPEG2_LEVEL, 31, "output-type-level"); + RequireTypeU32(output.Get(), MF_MT_MPEG2_LEVEL, profile.h264_level(), + "output-type-level"); UINT32 width = 0; UINT32 height = 0; Check(MFGetAttributeSize(output.Get(), MF_MT_FRAME_SIZE, &width, &height), "output-type-size-readback"); - if (width != kWidth || height != kHeight) { + if (width != profile.width || height != profile.height) { Fail("output-type-size-weakened", "output dimensions changed"); } UINT32 numerator = 0; @@ -558,50 +656,57 @@ void ValidateMediaTypes(IMFTransform* transform) { Check(MFGetAttributeRatio(output.Get(), MF_MT_FRAME_RATE, &numerator, &denominator), "output-type-framerate-readback"); - if (numerator != kFrameRate || denominator != 1) { + if (numerator != profile.frame_rate || denominator != 1) { Fail("output-type-framerate-weakened", "output frame rate changed"); } } -void ValidateCodecReadback(ICodecAPI* codec) { +void ValidateCodecReadback( + ICodecAPI* codec, + const VideoProfile& profile = kDefaultVideoProfile) { if (ReadU32(codec, CODECAPI_AVEncCommonRateControlMode, "codec-cbr") != eAVEncCommonRateControlMode_CBR || !ReadBool(codec, CODECAPI_AVLowLatencyMode, "codec-low-latency") || ReadU32(codec, CODECAPI_AVEncCommonMeanBitRate, - "codec-mean-bitrate") != kBitRate || + "codec-mean-bitrate") != profile.bit_rate || ReadU32(codec, CODECAPI_AVEncCommonBufferSize, "codec-vbv-bytes") != - kVbvBytes || - ReadU32(codec, CODECAPI_AVEncMPVGOPSize, "codec-gop") != kGopFrames) { + profile.vbv_bytes() || + ReadU32(codec, CODECAPI_AVEncMPVGOPSize, "codec-gop") != + profile.gop_frames()) { Fail("codec-final-readback", "one or more codec properties were weakened"); } } -#ifdef SCREENER_H264_FIXTURE -std::vector SyntheticNv12(UINT32 frame_index) { - std::vector pixels(kWidth * kHeight * 3 / 2); - const UINT32 bar_start = (frame_index * 11) % kWidth; - for (UINT32 y = 0; y < kHeight; ++y) { - for (UINT32 x = 0; x < kWidth; ++x) { - UINT32 distance = (x + kWidth - bar_start) % kWidth; +std::vector SyntheticNv12( + UINT32 frame_index, const VideoProfile& profile = kDefaultVideoProfile) { + const auto width = profile.width; + const auto height = profile.height; + std::vector pixels(width * height * 3 / 2); + const UINT32 bar_start = (frame_index * 11) % width; + for (UINT32 y = 0; y < height; ++y) { + for (UINT32 x = 0; x < width; ++x) { + UINT32 distance = (x + width - bar_start) % width; UINT8 base = static_cast(16 + ((x / 8 + y / 8 + frame_index) % 180)); - pixels[y * kWidth + x] = distance < 96 ? static_cast(235) : base; + pixels[y * width + x] = distance < 96 ? static_cast(235) : base; } } - UINT8* uv = pixels.data() + kWidth * kHeight; - for (UINT32 y = 0; y < kHeight / 2; ++y) { - for (UINT32 x = 0; x < kWidth; x += 2) { - uv[y * kWidth + x] = static_cast(96 + (frame_index % 64)); - uv[y * kWidth + x + 1] = static_cast(160 - (frame_index % 64)); + UINT8* uv = pixels.data() + width * height; + for (UINT32 y = 0; y < height / 2; ++y) { + for (UINT32 x = 0; x < width; x += 2) { + uv[y * width + x] = static_cast(96 + (frame_index % 64)); + uv[y * width + x + 1] = static_cast(160 - (frame_index % 64)); } } return pixels; } -ComPtr CreateInputSample(ID3D11Device* device, UINT32 frame_index) { - std::vector pixels = SyntheticNv12(frame_index); +ComPtr CreateSyntheticTexture( + ID3D11Device* device, UINT32 frame_index, + const VideoProfile& profile = kDefaultVideoProfile) { + std::vector pixels = SyntheticNv12(frame_index, profile); D3D11_TEXTURE2D_DESC description = {}; - description.Width = kWidth; - description.Height = kHeight; + description.Width = profile.width; + description.Height = profile.height; description.MipLevels = 1; description.ArraySize = 1; description.Format = DXGI_FORMAT_NV12; @@ -610,13 +715,18 @@ ComPtr CreateInputSample(ID3D11Device* device, UINT32 frame_index) { D3D11_SUBRESOURCE_DATA initial = {}; initial.pSysMem = pixels.data(); - initial.SysMemPitch = kWidth; + initial.SysMemPitch = profile.width; initial.SysMemSlicePitch = static_cast(pixels.size()); ComPtr texture; Check(device->CreateTexture2D(&description, &initial, &texture), "input-texture-create"); + return texture; +} +#ifdef SCREENER_H264_FIXTURE +ComPtr CreateInputSample(ID3D11Device* device, UINT32 frame_index) { + auto texture = CreateSyntheticTexture(device, frame_index); ComPtr buffer; Check(MFCreateDXGISurfaceBuffer(IID_ID3D11Texture2D, texture.Get(), 0, FALSE, &buffer), @@ -1228,13 +1338,11 @@ UINT ParseIndex(const wchar_t* value, const std::string& stage) { #ifndef SCREENER_H264_FIXTURE constexpr DWORD kMaxProductAccessUnitBytes = 1 * 1024 * 1024; constexpr DWORD kMaxStatusBytes = 4 * 1024; -constexpr UINT64 kEnvelopeFrameDuration100ns = - (static_cast(kFrameDuration100ns) / 10) * 10; - enum class OutputKind : UINT8 { pcm = 1, h264 = 2, status = 3, + vp8 = 4, }; class UniqueHandle final { @@ -1341,19 +1449,45 @@ struct EncodedAccessUnit final { std::vector bytes; }; -class LiveEncoder final { +using EncoderClock = std::chrono::steady_clock; + +void RequireEncoderTime(EncoderClock::time_point deadline) { + if (EncoderClock::now() >= deadline) { + Fail("codec-probe-timeout", "native codec selection exceeded its time budget"); + } +} + +class VideoEncoder { public: - explicit LiveEncoder(SelectedTransform selected) - : selected_(std::move(selected)) { - ConfigureCodec(selected_.codec.Get()); - ComPtr output_type = CreateOutputType(); + VideoEncoder(OutputKind kind, std::string name, std::string identity) + : kind(kind), name(std::move(name)), identity(std::move(identity)) {} + virtual ~VideoEncoder() = default; + virtual EncodedAccessUnit Encode(ID3D11Texture2D* texture, + UINT64 timestamp100ns, + bool force_key_frame, + EncoderClock::time_point deadline = + EncoderClock::time_point::max()) = 0; + const OutputKind kind; + const std::string name; + const std::string identity; +}; + +class LiveEncoder final : public VideoEncoder { + public: + explicit LiveEncoder( + SelectedTransform selected, + VideoProfile profile = kDefaultVideoProfile) + : VideoEncoder(OutputKind::h264, selected.name, selected.clsid), + selected_(std::move(selected)), profile_(profile) { + ConfigureCodec(selected_.codec.Get(), profile_); + ComPtr output_type = CreateOutputType(profile_); Check(selected_.transform->SetOutputType(0, output_type.Get(), 0), "mft-set-output-type"); - ComPtr input_type = CreateInputType(); + ComPtr input_type = CreateInputType(profile_); Check(selected_.transform->SetInputType(0, input_type.Get(), 0), "mft-set-input-type"); - ValidateMediaTypes(selected_.transform.Get()); - ValidateCodecReadback(selected_.codec.Get()); + ValidateMediaTypes(selected_.transform.Get(), profile_); + ValidateCodecReadback(selected_.codec.Get(), profile_); Check(selected_.transform->GetOutputStreamInfo(0, &output_info_), "mft-output-stream-info"); Check(selected_.transform->ProcessMessage( @@ -1368,27 +1502,27 @@ class LiveEncoder final { LiveEncoder(const LiveEncoder&) = delete; LiveEncoder& operator=(const LiveEncoder&) = delete; - ~LiveEncoder() { + ~LiveEncoder() override { if (started_) { selected_.transform->ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); selected_.transform->ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0); } } - const SelectedTransform& selected() const { return selected_; } - EncodedAccessUnit Encode(ID3D11Texture2D* texture, UINT64 timestamp100ns, - bool force_key_frame) { - WaitForInput(); + bool force_key_frame, + EncoderClock::time_point probe_deadline) override { + WaitForInput(probe_deadline); if (force_key_frame) ForceKeyFrame(selected_.codec.Get()); ComPtr sample = CreateSurfaceSample( - texture, static_cast(timestamp100ns), kFrameDuration100ns); + texture, static_cast(timestamp100ns), + profile_.frame_duration_100ns()); Check(selected_.transform->ProcessInput(0, sample.Get(), 0), "mft-process-input"); --input_requests_; - const auto deadline = std::chrono::steady_clock::now() + - std::chrono::seconds(2); + const auto deadline = std::min(probe_deadline, + EncoderClock::now() + std::chrono::seconds(2)); while (std::chrono::steady_clock::now() < deadline) { MediaEventType type = NextEvent(deadline); if (type == METransformNeedInput) { @@ -1410,8 +1544,8 @@ class LiveEncoder final { Fail("bitstream-annexb", "live output is not Annex-B H264"); } if (nal.profile_level_id) { - if (*nal.profile_level_id != "42c01f") { - Fail("bitstream-profile", "hardware MFT did not emit profile-level-id 42c01f"); + if (*nal.profile_level_id != profile_.profile_level_id()) { + Fail("bitstream-profile", "hardware MFT changed the requested H.264 profile level"); } if (profile_level_id_ && *profile_level_id_ != *nal.profile_level_id) { Fail("bitstream-profile-change", "hardware MFT changed SPS profile"); @@ -1425,6 +1559,7 @@ class LiveEncoder final { if (!profile_level_id_) { Fail("bitstream-sps", "first live access unit did not carry an SPS"); } + RequireEncoderTime(probe_deadline); return EncodedAccessUnit{timestamp100ns, nal.idr, std::move(bytes)}; } Fail("mft-output-timeout", "hardware MFT did not produce live output in time"); @@ -1452,9 +1587,9 @@ class LiveEncoder final { Fail("mft-event-timeout", "hardware MFT did not request input in time"); } - void WaitForInput() { - const auto deadline = std::chrono::steady_clock::now() + - std::chrono::seconds(2); + void WaitForInput(EncoderClock::time_point probe_deadline) { + const auto deadline = std::min(probe_deadline, + EncoderClock::now() + std::chrono::seconds(2)); while (input_requests_ == 0) { MediaEventType type = NextEvent(deadline); if (type == METransformNeedInput) { @@ -1466,15 +1601,68 @@ class LiveEncoder final { } SelectedTransform selected_; + VideoProfile profile_; MFT_OUTPUT_STREAM_INFO output_info_ = {}; UINT32 input_requests_ = 0; std::optional profile_level_id_; bool started_ = false; }; +class SoftwareVp8Encoder final : public VideoEncoder { + public: + SoftwareVp8Encoder(ID3D11Device* device, const VideoProfile& profile) + : VideoEncoder(OutputKind::vp8, "libvpx VP8", + std::string("libvpx/") + screener::capture::Vp8Encoder::Version()), + encoder_(profile.width, profile.height, profile.frame_rate, profile.bit_rate) { + device->GetImmediateContext(&context_); + D3D11_TEXTURE2D_DESC description{}; + description.Width = profile.width; + description.Height = profile.height; + description.MipLevels = 1; + description.ArraySize = 1; + description.Format = DXGI_FORMAT_NV12; + description.SampleDesc.Count = 1; + description.Usage = D3D11_USAGE_STAGING; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + Check(device->CreateTexture2D(&description, nullptr, &staging_), + "vp8-readback-texture"); + } + + EncodedAccessUnit Encode(ID3D11Texture2D* texture, UINT64 timestamp100ns, + bool force_key_frame, + EncoderClock::time_point deadline) override { + RequireEncoderTime(deadline); + context_->CopyResource(staging_.Get(), texture); + D3D11_MAPPED_SUBRESOURCE mapped{}; + Check(context_->Map(staging_.Get(), 0, D3D11_MAP_READ, 0, &mapped), + "vp8-readback-map"); + screener::capture::Vp8Frame frame; + try { + RequireEncoderTime(deadline); + frame = encoder_.Encode(static_cast(mapped.pData), + mapped.RowPitch, timestamp100ns, + force_key_frame); + } catch (...) { + context_->Unmap(staging_.Get(), 0); + throw; + } + context_->Unmap(staging_.Get(), 0); + RequireEncoderTime(deadline); + return {timestamp100ns, frame.key_frame, std::move(frame.bytes)}; + } + + private: + screener::capture::Vp8Encoder encoder_; + ComPtr context_; + ComPtr staging_; +}; + class FrameConverter final { public: - explicit FrameConverter(ID3D11Device* device) : device_(device) { + explicit FrameConverter( + ID3D11Device* device, + VideoProfile profile = kDefaultVideoProfile) + : device_(device), profile_(profile) { Check(device_->QueryInterface(IID_PPV_ARGS(&video_device_)), "video-processor-device"); ComPtr context; @@ -1483,7 +1671,7 @@ class FrameConverter final { } ComPtr Convert(ID3D11Texture2D* source, UINT32 width, - UINT32 height) { + UINT32 height, SIZE presentation) { if (source == nullptr || width == 0 || height == 0 || width > 16'384 || height > 16'384) { Fail("capture-size", "captured window dimensions are invalid"); @@ -1493,8 +1681,8 @@ class FrameConverter final { } D3D11_TEXTURE2D_DESC output_description = {}; - output_description.Width = kWidth; - output_description.Height = kHeight; + output_description.Width = profile_.width; + output_description.Height = profile_.height; output_description.MipLevels = 1; output_description.ArraySize = 1; output_description.Format = DXGI_FORMAT_NV12; @@ -1525,19 +1713,11 @@ class FrameConverter final { RECT source_rect = {0, 0, static_cast(width), static_cast(height)}; - double scale = std::min(static_cast(kWidth) / width, - static_cast(kHeight) / height); - LONG target_width = std::max( - 2, static_cast(std::llround(width * scale)) & ~1L); - LONG target_height = std::max( - 2, static_cast(std::llround(height * scale)) & ~1L); - target_width = std::min(target_width, kWidth); - target_height = std::min(target_height, kHeight); - LONG left = (static_cast(kWidth) - target_width) / 2; - LONG top = (static_cast(kHeight) - target_height) / 2; - RECT target_rect = {left, top, left + target_width, top + target_height}; - RECT output_rect = {0, 0, static_cast(kWidth), - static_cast(kHeight)}; + RECT target_rect = screener::capture::FitFrameRect( + presentation, {static_cast(profile_.width), + static_cast(profile_.height)}); + RECT output_rect = {0, 0, static_cast(profile_.width), + static_cast(profile_.height)}; D3D11_VIDEO_COLOR background = {}; background.RGBA.A = 1.0f; @@ -1566,12 +1746,12 @@ class FrameConverter final { void Configure(UINT32 width, UINT32 height) { D3D11_VIDEO_PROCESSOR_CONTENT_DESC description = {}; description.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; - description.InputFrameRate = {kFrameRate, 1}; + description.InputFrameRate = {profile_.frame_rate, 1}; description.InputWidth = width; description.InputHeight = height; - description.OutputFrameRate = {kFrameRate, 1}; - description.OutputWidth = kWidth; - description.OutputHeight = kHeight; + description.OutputFrameRate = {profile_.frame_rate, 1}; + description.OutputWidth = profile_.width; + description.OutputHeight = profile_.height; description.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; ComPtr enumerator; Check(video_device_->CreateVideoProcessorEnumerator(&description, @@ -1587,6 +1767,7 @@ class FrameConverter final { } ComPtr device_; + VideoProfile profile_; ComPtr video_device_; ComPtr video_context_; ComPtr enumerator_; @@ -1736,8 +1917,34 @@ struct ProductArguments final { UINT64 source_id = 0; UINT adapter_index = 0; UINT mft_index = 0; + std::string codec = "auto"; + VideoProfile profile; }; +DegradationPreference ParseDegradationPreference(const wchar_t* value) { + const std::wstring preference(value); + if (preference == L"maintain-resolution") { + return DegradationPreference::resolution; + } + if (preference == L"balanced") return DegradationPreference::balanced; + if (preference == L"maintain-framerate") { + return DegradationPreference::framerate; + } + Fail("argument-preference", "degradation preference is invalid"); +} + +void ValidateVideoProfile(const VideoProfile& profile) { + const bool valid_resolution = + (profile.width == 854 && profile.height == 480) || + (profile.width == 1280 && profile.height == 720) || + (profile.width == 1920 && profile.height == 1080) || + (profile.width == 2560 && profile.height == 1440); + if (!valid_resolution || profile.frame_rate < 15 || profile.frame_rate > 60 || + profile.bit_rate < 2'000'000 || profile.bit_rate > 12'000'000) { + Fail("argument-profile", "video profile is outside the product bounds"); + } +} + ProductArguments ParseProductArguments(int count, wchar_t** values) { ProductArguments arguments; if (count == 2 && std::wstring(values[1]) == L"--list") return arguments; @@ -1761,15 +1968,32 @@ ProductArguments ParseProductArguments(int count, wchar_t** values) { if (count == 5 && std::wstring(values[1]) == L"--capture-audio") { arguments.mode = ProductArguments::Mode::audio; arguments.target_kind = ParseTargetKind(values[2]); - } else if (count == 11 && std::wstring(values[1]) == L"--capture-video" && + } else if (count == 23 && 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") { + std::wstring(values[10]) == L"--width" && + std::wstring(values[12]) == L"--height" && + std::wstring(values[14]) == L"--fps" && + std::wstring(values[16]) == L"--bitrate" && + std::wstring(values[18]) == L"--preference" && + std::wstring(values[20]) == L"--codec" && + std::wstring(values[22]) == L"--protocol-v4") { 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"); + arguments.profile.width = ParseIndex(values[11], "argument-width"); + arguments.profile.height = ParseIndex(values[13], "argument-height"); + arguments.profile.frame_rate = ParseIndex(values[15], "argument-fps"); + arguments.profile.bit_rate = ParseIndex(values[17], "argument-bitrate"); + arguments.profile.preference = ParseDegradationPreference(values[19]); + const std::wstring codec(values[21]); + if (codec != L"auto" && codec != L"h264" && codec != L"vp8") { + Fail("argument-codec", "video codec is unsupported"); + } + arguments.codec = NarrowAscii(codec); + ValidateVideoProfile(arguments.profile); } else { Fail("arguments", "unsupported or incomplete command-line argument"); } @@ -1854,9 +2078,10 @@ void WriteCapabilityProbe() { std::vector adapters = EnumerateAdapters(); std::ostringstream output; - output << "{\"protocol\":3,\"platform\":\"windows\",\"platformBuild\":" + output << "{\"protocol\":4,\"platform\":\"windows\",\"platformBuild\":" << JSONString(std::to_string(build)) << ",\"videoCapture\":" << (window_capture ? "true" : "false") + << ",\"softwareVP8\":true" << ",\"processAudio\":" << (process_audio ? "true" : "false") << ",\"systemAudio\":" << (system_audio ? "true" : "false") @@ -1893,6 +2118,69 @@ void WriteCapabilityProbe() { } } +double MeasureEncoderWork(VideoEncoder& encoder, ID3D11Device* device, + const VideoProfile& profile, + EncoderClock::time_point deadline) { + const UINT32 warmup_frames = (profile.frame_rate + 1) / 2; + const UINT32 sample_frames = profile.frame_rate; + std::chrono::duration measured{}; + for (UINT32 frame = 0; frame < warmup_frames + sample_frames; ++frame) { + RequireEncoderTime(deadline); + auto texture = CreateSyntheticTexture(device, frame, profile); + const auto start = EncoderClock::now(); + auto encoded = encoder.Encode(texture.Get(), + (static_cast(frame) + 1) * profile.frame_duration_100ns(), + frame == 0, deadline); + if (encoded.bytes.empty() || encoded.bytes.size() > kMaxProductAccessUnitBytes) { + Fail("codec-probe-output", "encoder produced an invalid probe frame"); + } + if (frame >= warmup_frames) measured += EncoderClock::now() - start; + } + return measured.count() / sample_frames; +} + +std::unique_ptr SelectVideoEncoder( + const ProductArguments& arguments, const Adapter& adapter, + const DeviceContext& device) { + const auto create = [&](bool hardware) -> std::unique_ptr { + if (!hardware) { + return std::make_unique(device.device.Get(), arguments.profile); + } + ActivationList activations = EnumerateHardwareEncoders(adapter); + auto selected = ActivateTransform(activations, arguments.mft_index, device.manager.Get()); + return std::make_unique(std::move(selected), arguments.profile); + }; + if (arguments.codec != "auto") return create(arguments.codec == "h264"); + + const auto began = EncoderClock::now(); + const auto budget = std::chrono::seconds(4); + const auto deadline = began + budget; + std::optional hardware_work; + try { + auto hardware = create(true); + hardware_work = MeasureEncoderWork(*hardware, device.device.Get(), + arguments.profile, began + budget / 2); + if (*hardware_work <= 1.0 / arguments.profile.frame_rate) return hardware; + } catch (const std::exception&) { + // No media has been published; a failed hardware probe may try software. + } + RequireEncoderTime(deadline); + std::unique_ptr software; + try { + software = create(false); + const double software_work = MeasureEncoderWork(*software, device.device.Get(), + arguments.profile, deadline); + if (!hardware_work || software_work <= *hardware_work) return software; + } catch (const std::exception&) { + if (!hardware_work) throw; + } + software.reset(); + RequireEncoderTime(deadline); + auto hardware = create(true); + RequireEncoderTime(deadline); + return hardware; +} + void RunVideoCapture(const ProductArguments& arguments) { const bool window_target = arguments.target_kind == screener::capture::TargetKind::window; @@ -1907,11 +2195,12 @@ void RunVideoCapture(const ProductArguments& arguments) { std::vector adapters = EnumerateAdapters(); const Adapter& adapter = SelectAdapter(adapters, arguments.adapter_index); DeviceContext device = CreateDevice(adapter); - ActivationList activations = EnumerateHardwareEncoders(adapter); - SelectedTransform selected = ActivateTransform( - activations, arguments.mft_index, device.manager.Get()); - LiveEncoder encoder(std::move(selected)); - FrameConverter converter(device.device.Get()); + auto encoder = SelectVideoEncoder(arguments, adapter, device); + const bool hardware = encoder->kind == OutputKind::h264; + const char* codec = hardware ? "h264" : "vp8"; + FrameConverter converter(device.device.Get(), arguments.profile); + screener::capture::CapturePresentation presentation(arguments.target_kind, + arguments.source_id); ProtocolWriter writer; using namespace winrt::Windows::Graphics::Capture; @@ -1949,6 +2238,7 @@ void RunVideoCapture(const ProductArguments& arguments) { capture_device, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, initial_size); GraphicsCaptureSession capture_session = pool.CreateCaptureSession(item); + EnableFastCaptureUpdates(capture_session); UniqueHandle shutdown(CreateEventW(nullptr, TRUE, FALSE, nullptr)); UniqueHandle frame_ready(CreateEventW(nullptr, FALSE, FALSE, nullptr)); @@ -1989,25 +2279,63 @@ void RunVideoCapture(const ProductArguments& arguments) { }; try { + const UINT64 frame_duration = static_cast( + arguments.profile.frame_duration_100ns()); + const UINT64 envelope_frame_duration = (frame_duration / 10) * 10; std::ostringstream starting; - starting << "{\"state\":\"starting\",\"hardwareOnly\":true," - << "\"adapterIndex\":" << adapter.index + starting << "{\"state\":\"starting\",\"hardwareOnly\":" + << (hardware ? "true" : "false") << ",\"codec\":" << JSONString(codec) + << ",\"adapterIndex\":" << adapter.index << ",\"adapterName\":" << JSONString(NarrowAscii(adapter.description.Description)) << ",\"adapterIdentity\":" << JSONString(LuidString(adapter.description.AdapterLuid)) - << ",\"encoderIndex\":" << arguments.mft_index << ",\"encoderName\":" - << JSONString(encoder.selected().name) + << JSONString(encoder->name) << ",\"encoderIdentity\":" - << JSONString(encoder.selected().clsid) << '}'; + << JSONString(encoder->identity); + if (hardware) starting << ",\"encoderIndex\":" << arguments.mft_index; + starting << '}'; Check(writer.WriteStatus(starting.str()), "capture-status-starting"); capture_session.StartCapture(); UINT64 previous_timestamp = 0; + UINT64 next_output_timestamp = 0; UINT64 encoded_frames = 0; bool active_status_written = false; auto pool_size = initial_size; + ComPtr latest_nv12; + UINT64 latest_timestamp = 0; + bool pending_key_frame = false; + auto write_encoded = [&](ID3D11Texture2D* texture, UINT64 timestamp, + bool force_key_frame) { + EncodedAccessUnit access_unit = + encoder->Encode(texture, timestamp, force_key_frame); + if (access_unit.bytes.size() > kMaxProductAccessUnitBytes) { + Fail("output-buffer-bounds", "live video access unit exceeded one MiB"); + } + UINT64 wire_timestamp = (access_unit.timestamp100ns / 10) * 10; + Check(writer.Write(encoder->kind, access_unit.key_frame ? 1 : 0, + wire_timestamp, envelope_frame_duration, + access_unit.bytes.data(), + static_cast(access_unit.bytes.size())), + "capture-video-output"); + previous_timestamp = timestamp; + ++encoded_frames; + if (!active_status_written) { + std::ostringstream active; + active << "{\"state\":\"active\",\"hardwareOnly\":" + << (hardware ? "true" : "false") << ",\"codec\":" << JSONString(codec); + if (hardware) { + active << ",\"profileLevelId\":" << JSONString(arguments.profile.profile_level_id()); + } + active << ",\"width\":" << arguments.profile.width << ",\"height\":" + << arguments.profile.height << ",\"fps\":" + << arguments.profile.frame_rate << '}'; + Check(writer.WriteStatus(active.str()), "capture-status-active"); + active_status_written = true; + } + }; const HANDLE window_waits[] = { process.get(), shutdown.get(), frame_ready.get()}; const HANDLE display_waits[] = {shutdown.get(), frame_ready.get()}; @@ -2029,7 +2357,23 @@ void RunVideoCapture(const ProductArguments& arguments) { Fail("capture-stopped", "source capture stopped"); } if (wait == WAIT_TIMEOUT) { - Fail("capture-frame-timeout", "selected window produced no frame in five seconds"); + // A quiet source is not a capture-end signal. The item/process wait + // handles remain the terminal signals; a pending keyframe can reuse + // the latest converted image. + ControlSignal control = ConsumeControlSignal(); + if (control.stop) { + cleanup(); + return; + } + pending_key_frame = pending_key_frame || control.key_frame; + if (pending_key_frame && latest_nv12) { + const UINT64 timestamp = previous_timestamp == 0 + ? latest_timestamp + : previous_timestamp + frame_duration; + write_encoded(latest_nv12.Get(), timestamp, true); + pending_key_frame = false; + } + continue; } if (wait != frame_index) { Check(HRESULT_FROM_WIN32(GetLastError()), "capture-wait"); @@ -2048,9 +2392,9 @@ void RunVideoCapture(const ProductArguments& arguments) { } UINT64 timestamp = static_cast(signed_timestamp); if (previous_timestamp != 0 && timestamp <= previous_timestamp) continue; - if (previous_timestamp != 0 && - timestamp - previous_timestamp < - static_cast(kFrameDuration100ns) * 19 / 20) { + const UINT64 timing_tolerance = frame_duration / 20; + if (next_output_timestamp != 0 && timestamp < next_output_timestamp && + next_output_timestamp - timestamp > timing_tolerance) { continue; } auto content_size = latest.ContentSize(); @@ -2059,6 +2403,8 @@ void RunVideoCapture(const ProductArguments& arguments) { content_size.Height != pool_size.Height) { latest.Close(); latest = nullptr; + latest_nv12 = nullptr; + latest_timestamp = 0; pool.Recreate(capture_device, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, content_size); @@ -2073,35 +2419,31 @@ void RunVideoCapture(const ProductArguments& arguments) { UINT32 content_height = std::min( source_description.Height, static_cast(content_size.Height)); ComPtr nv12 = converter.Convert( - source.Get(), content_width, content_height); + source.Get(), content_width, content_height, + presentation.Resolve(content_width, content_height, + encoded_frames % arguments.profile.gop_frames() == 0)); + if (next_output_timestamp == 0) { + next_output_timestamp = timestamp + frame_duration; + } else { + const UINT64 elapsed = timestamp > next_output_timestamp + ? timestamp - next_output_timestamp + : 0; + next_output_timestamp += + (elapsed / frame_duration + 1) * frame_duration; + } + latest_nv12 = nv12; + latest_timestamp = timestamp; ControlSignal control = ConsumeControlSignal(); if (control.stop) { cleanup(); return; } - bool key_frame = encoded_frames == 0 || encoded_frames % kGopFrames == 0 || - control.key_frame; - EncodedAccessUnit access_unit = encoder.Encode(nv12.Get(), timestamp, - key_frame); - if (access_unit.bytes.size() > kMaxProductAccessUnitBytes) { - Fail("output-buffer-bounds", "live H264 access unit exceeded one MiB"); - } - UINT64 wire_timestamp = (access_unit.timestamp100ns / 10) * 10; - Check(writer.Write(OutputKind::h264, access_unit.key_frame ? 1 : 0, - wire_timestamp, kEnvelopeFrameDuration100ns, - access_unit.bytes.data(), - static_cast(access_unit.bytes.size())), - "capture-video-output"); - previous_timestamp = timestamp; - ++encoded_frames; - if (!active_status_written) { - std::string active = - "{\"state\":\"active\",\"hardwareOnly\":true," - "\"profileLevelId\":\"42c01f\",\"width\":1280," - "\"height\":720,\"fps\":30}"; - Check(writer.WriteStatus(active), "capture-status-active"); - active_status_written = true; - } + pending_key_frame = pending_key_frame || control.key_frame; + bool key_frame = encoded_frames == 0 || + encoded_frames % arguments.profile.gop_frames() == 0 || + pending_key_frame; + write_encoded(nv12.Get(), timestamp, key_frame); + pending_key_frame = false; } } catch (...) { cleanup(); @@ -2263,6 +2605,12 @@ int wmain(int argc, wchar_t** argv) { << std::setw(8) << static_cast(error.result()) << '\n'; } return 2; + } catch (const winrt::hresult_error& error) { + std::cerr << "result=window-capture-failed\n" + << "stage=windows-runtime\n" + << "hresult=0x" << std::hex << std::setfill('0') + << std::setw(8) << static_cast(error.code()) << '\n'; + return 2; } catch (const std::exception& error) { std::cerr << "result=window-capture-failed\n" << "stage=unclassified\n" diff --git a/native/client/platform/windows/capture/vp8_encoder.cpp b/native/client/platform/windows/capture/vp8_encoder.cpp new file mode 100644 index 0000000..8a0a8dc --- /dev/null +++ b/native/client/platform/windows/capture/vp8_encoder.cpp @@ -0,0 +1,98 @@ +#include "vp8_encoder.h" + +#include +#include +#include +#include +#include + +#include + +namespace screener::capture { +namespace { + +void CheckVpx(vpx_codec_err_t result) { + if (result != VPX_CODEC_OK) { + throw std::runtime_error(std::string("VP8 encoder: ") + + vpx_codec_err_to_string(result)); + } +} + +} // namespace + +Vp8Encoder::Vp8Encoder(uint32_t width, uint32_t height, uint32_t frame_rate, + uint32_t bit_rate) + : width_(width), height_(height), duration100ns_(0) { + if (width == 0 || height == 0 || (width & 1) || (height & 1) || + frame_rate == 0 || bit_rate < 1'000) { + throw std::runtime_error("VP8 encoder profile is invalid"); + } + duration100ns_ = 10'000'000 / frame_rate; + vpx_codec_enc_cfg_t config{}; + CheckVpx(vpx_codec_enc_config_default(vpx_codec_vp8_cx(), &config, 0)); + config.g_w = width; + config.g_h = height; + config.g_timebase = {1, 10'000'000}; + config.g_threads = std::min(4u, std::max(1u, std::thread::hardware_concurrency())); + config.g_lag_in_frames = 0; + config.rc_end_usage = VPX_CBR; + config.rc_target_bitrate = bit_rate / 1'000; + config.rc_dropframe_thresh = 0; + // Use WebRTC's low-latency VP8 CBR buffer settings. + config.rc_undershoot_pct = 100; + config.rc_overshoot_pct = 15; + config.rc_buf_initial_sz = 500; + config.rc_buf_optimal_sz = 600; + config.rc_buf_sz = 1000; + config.kf_max_dist = frame_rate * 2; + CheckVpx(vpx_codec_enc_init(&codec_, vpx_codec_vp8_cx(), &config, 0)); + const auto speed_result = vpx_codec_control(&codec_, VP8E_SET_CPUUSED, 8); + if (speed_result != VPX_CODEC_OK) { + vpx_codec_destroy(&codec_); + CheckVpx(speed_result); + } +} + +Vp8Encoder::~Vp8Encoder() { vpx_codec_destroy(&codec_); } + +const char* Vp8Encoder::Version() { return vpx_codec_version_str(); } + +Vp8Frame Vp8Encoder::Encode(uint8_t* nv12, size_t row_pitch, + uint64_t timestamp100ns, bool key_frame) { + if (nv12 == nullptr || row_pitch < width_ || + row_pitch > static_cast(std::numeric_limits::max()) || + timestamp100ns > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("VP8 encoder input is invalid"); + } + vpx_image_t image{}; + if (!vpx_img_wrap(&image, VPX_IMG_FMT_NV12, width_, height_, 1, nv12)) { + throw std::runtime_error("VP8 encoder could not wrap NV12 input"); + } + for (int plane = VPX_PLANE_Y; plane <= VPX_PLANE_V; ++plane) { + image.stride[plane] = static_cast(row_pitch); + } + image.planes[VPX_PLANE_U] = nv12 + row_pitch * height_; + image.planes[VPX_PLANE_V] = image.planes[VPX_PLANE_U] + 1; + CheckVpx(vpx_codec_encode(&codec_, &image, + static_cast(timestamp100ns), + duration100ns_, key_frame ? VPX_EFLAG_FORCE_KF : 0, + VPX_DL_REALTIME)); + Vp8Frame frame; + vpx_codec_iter_t iterator = nullptr; + while (const auto* packet = vpx_codec_get_cx_data(&codec_, &iterator)) { + if (packet->kind != VPX_CODEC_CX_FRAME_PKT) continue; + if (!frame.bytes.empty() || packet->data.frame.pts != + static_cast(timestamp100ns)) { + throw std::runtime_error("VP8 encoder changed output order"); + } + const auto* bytes = static_cast(packet->data.frame.buf); + frame.bytes.assign(bytes, bytes + packet->data.frame.sz); + frame.key_frame = (packet->data.frame.flags & VPX_FRAME_IS_KEY) != 0; + } + if (frame.bytes.empty() || (key_frame && !frame.key_frame)) { + throw std::runtime_error("VP8 encoder did not produce the requested frame"); + } + return frame; +} + +} // namespace screener::capture diff --git a/native/client/platform/windows/capture/vp8_encoder.h b/native/client/platform/windows/capture/vp8_encoder.h new file mode 100644 index 0000000..bf3b28f --- /dev/null +++ b/native/client/platform/windows/capture/vp8_encoder.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include + +namespace screener::capture { + +struct Vp8Frame final { + bool key_frame = false; + std::vector bytes; +}; + +class Vp8Encoder final { + public: + Vp8Encoder(uint32_t width, uint32_t height, uint32_t frame_rate, + uint32_t bit_rate); + ~Vp8Encoder(); + Vp8Encoder(const Vp8Encoder&) = delete; + Vp8Encoder& operator=(const Vp8Encoder&) = delete; + + Vp8Frame Encode(uint8_t* nv12, size_t row_pitch, uint64_t timestamp100ns, + bool key_frame); + static const char* Version(); + + private: + vpx_codec_ctx_t codec_{}; + uint32_t width_; + uint32_t height_; + unsigned long duration100ns_; +}; + +} // namespace screener::capture diff --git a/package-lock.json b/package-lock.json index f3b4b65..1c9e510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "screener", "version": "0.1.0", + "license": "MIT", "dependencies": { "livekit-server-sdk": "2.18.0", "sirv": "3.0.2", diff --git a/package.json b/package.json index 84a7977..1357ff3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "screener", "version": "0.1.0", + "license": "MIT", "private": true, "type": "module", "engines": { @@ -8,9 +9,9 @@ }, "packageManager": "npm@11.17.0", "scripts": { - "dev": "tsx watch src/server/index.ts", + "dev": "node scripts/package-licenses.mjs public/third-party-licenses.txt && tsx watch src/server/index.ts", "build": "npm run build:client && npm run build:server", - "build:client": "vite build", + "build:client": "node scripts/package-licenses.mjs public/third-party-licenses.txt && 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", @@ -19,6 +20,7 @@ "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-browser-fanout": "tsx scripts/client-browser-fanout-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", diff --git a/scripts/assemble-client.mjs b/scripts/assemble-client.mjs index 87183c4..28eb5ae 100644 --- a/scripts/assemble-client.mjs +++ b/scripts/assemble-client.mjs @@ -22,7 +22,9 @@ 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"; +import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs"; +import { writeClientPlatformAssets } from "./client-icons.mjs"; +import { writeClientLicenseNotices } from "./package-licenses.mjs"; function fail(message) { throw new Error(message); @@ -259,6 +261,21 @@ try { CGO_ENABLED: "0", }); chmodSync(clientPath, 0o755); + writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, + packagedTunnel ? CLOUDFLARED_VERSION : null); + const platformAssets = writeClientPlatformAssets({ + packageRoot, + target, + revision, + iconPath: join( + repositoryRoot, + "native", + "client", + "cmd", + "screener-client", + "screener.ico", + ), + }); writeFileSync(join(packageRoot, "REVISION"), `${revision}\n`, "ascii"); cpSync(packageRoot, outputRoot, { @@ -278,6 +295,7 @@ try { publicTunnel: packagedTunnel ? `runtime/tunnel/${target.tunnelName}` : null, + platformAssets, app: "app", })}\n`); } finally { diff --git a/scripts/browser-gate-harness.ts b/scripts/browser-gate-harness.ts index 414a55f..4d0d729 100644 --- a/scripts/browser-gate-harness.ts +++ b/scripts/browser-gate-harness.ts @@ -117,7 +117,10 @@ export class CdpConnection { if (!pending) return; this.pending.delete(message.id); if (message.error) { - pending.reject(new Error("CDP command failed")); + const detail = typeof message.error === "object" && "message" in message.error + ? String(message.error.message) + : "unknown protocol error"; + pending.reject(new Error(`CDP command failed: ${detail}`)); } else { pending.resolve(message.result ?? {}); } diff --git a/scripts/check-client.mjs b/scripts/check-client.mjs index 27379e9..53a40b0 100644 --- a/scripts/check-client.mjs +++ b/scripts/check-client.mjs @@ -106,7 +106,7 @@ function runClientTests(go) { } function checkPlatformCapture() { - if (process.platform !== "win32" && process.platform !== "darwin") { + if (!["win32", "darwin", "linux"].includes(process.platform)) { if (mode === "--capture-only") { throw new Error("No native capture check exists for this platform"); } @@ -123,8 +123,16 @@ function checkPlatformCapture() { "v1.0", "powershell.exe", ); + const modernPowerShell = join( + process.env.ProgramFiles || "C:\\Program Files", + "PowerShell", + "7", + "pwsh.exe", + ); const powershell = process.env.SCREENER_POWERSHELL?.trim() || - (existsSync(systemPowerShell) ? systemPowerShell : "pwsh"); + (existsSync(modernPowerShell) + ? modernPowerShell + : (existsSync(systemPowerShell) ? systemPowerShell : "pwsh")); run(powershell, [ "-NoProfile", "-ExecutionPolicy", @@ -133,14 +141,21 @@ function checkPlatformCapture() { join(clientRoot, "platform", "windows", "capture", "build.ps1"), "-OutputDirectory", buildRoot, + "-Check", ]); executable = join(buildRoot, "screener-client-capture.exe"); - } else { + } else if (process.platform === "darwin") { run("sh", [ join(clientRoot, "platform", "darwin", "capture", "build.sh"), buildRoot, ]); executable = join(buildRoot, "screener-client-capture"); + } else { + run("sh", [ + join(clientRoot, "platform", "linux", "capture", "build.sh"), + buildRoot, + ]); + executable = join(buildRoot, "screener-client-capture"); } if (!existsSync(executable)) { throw new Error("Native capture build did not produce its executable"); @@ -150,12 +165,13 @@ function checkPlatformCapture() { } const raw = run(executable, ["--probe"], { capture: true }); const probe = JSON.parse(raw); - const expectedPlatform = process.platform === "win32" ? "windows" : "darwin"; + const expectedPlatform = process.platform === "win32" ? "windows" : process.platform; if ( - probe?.protocol !== 3 || + probe?.protocol !== 4 || probe.platform !== expectedPlatform || typeof probe.platformBuild !== "string" || typeof probe.videoCapture !== "boolean" || + typeof probe.softwareVP8 !== "boolean" || typeof probe.processAudio !== "boolean" || typeof probe.systemAudio !== "boolean" || !Array.isArray(probe.adapters) || @@ -173,16 +189,17 @@ function checkPlatformCapture() { ) { 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) || + !["window", "display", "picker"].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)) || + (target.kind === "picker" && (target?.pid !== undefined || target?.creationTime !== undefined)) )) { throw new Error("Windows capture process returned an invalid source list"); diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 024f259..d789eb6 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -32,8 +32,8 @@ const claudeWrapper = readFileSync(resolve(root, "CLAUDE.md"), "utf8").replace( /^@AGENTS\.md\s*(?:\r?\n)?/u, "", ); -const stsInstructions = readFileSync( - resolve(root, ".agents/skills/stop-that-shit/SKILL.md"), +const ponytailInstructions = readFileSync( + resolve(root, ".agents/skills/ponytail/SKILL.md"), "utf8", ); const topLevelContextBudgets = [ @@ -62,8 +62,8 @@ const topLevelContextBudgets = [ hard: { chars: hermesMinimumContextChars }, }, { - name: "Required stop-that-shit skill", - text: stsInstructions, + name: "Required ponytail skill", + text: ponytailInstructions, recommended: { lines: claudeRecommendedLines, chars: Math.floor(hermesMinimumContextChars * contextWarningRatio), diff --git a/scripts/client-browser-fanout-gate.ts b/scripts/client-browser-fanout-gate.ts new file mode 100644 index 0000000..fa98327 --- /dev/null +++ b/scripts/client-browser-fanout-gate.ts @@ -0,0 +1,247 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createScreenerServer, type ScreenerServer } from "../src/server/app"; +import { createLocalServerConfig } from "../src/server/local-config"; +import { + CdpConnection, cleanupRun, createPage, evaluate, reservePort, + waitForSample, waitForVersion, type PageHandle, +} from "./browser-gate-harness"; +import { decodeClientEndpoint } from "./client-gate-endpoint"; + +const ROOT = resolve(import.meta.dirname, ".."); +const initialAudio = process.env.SCREENER_CLIENT_GATE_AUDIO !== "false"; +const probe = String.raw`(() => { + localStorage.setItem('screener:ui-lang', 'en'); + localStorage.setItem('screener:ui-mode', 'text'); + const pcs = [], requests = [], sockets = [], errors = []; + window.addEventListener('unhandledrejection', (e) => errors.push(String(e.reason))); + const codecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences; + RTCRtpTransceiver.prototype.setCodecPreferences = function(value) { + try { return codecPreferences.call(this,value); } + catch (error) { errors.push(String(error)); throw error; } + }; + const PC = RTCPeerConnection, WS = WebSocket; + window.RTCPeerConnection = class extends PC { constructor(...args) { super(...args); pcs.push(this); } }; + window.WebSocket = class extends WS { + constructor(...args) { super(...args); if (String(args[0]).includes('/control')) { + sockets.push(this); + } } + send(raw) { if (this.url.includes('/control')) { const value = JSON.parse(raw); requests.push(value.type); } super.send(raw); } + }; + let audio = ${initialAudio}, captures = 0; + navigator.mediaDevices.getDisplayMedia = async () => { + captures++; + const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; + const context = canvas.getContext('2d'); let frame = 0; + const draw = () => { context.fillStyle = '#14242a'; context.fillRect(0,0,1920,1080); + context.fillStyle = '#68dab3'; context.fillRect((frame++ * 17)%1800,100,120,400); + context.fillStyle = '#ffffff'; context.font = '80px sans-serif'; context.fillText(String(frame),100,700); }; + draw(); const timer = setInterval(draw, 1000/30); + const stream = canvas.captureStream(30); + const track = stream.getVideoTracks()[0]; const stop = track.stop.bind(track); + track.stop = () => { clearInterval(timer); stop(); }; + if (audio) { + const ac = new AudioContext(), osc = ac.createOscillator(), dest = ac.createMediaStreamDestination(); + osc.frequency.value = 440; osc.connect(dest); osc.start(); await ac.resume(); + const sound = dest.stream.getAudioTracks()[0], stopSound = sound.stop.bind(sound); + let soundStopped=false; + sound.stop = () => { if (soundStopped) return; soundStopped=true; osc.stop(); void ac.close(); stopSound(); }; + stream.addTrack(sound); + } + return stream; + }; + window.fanoutGate = { + audio(value) { audio = value; }, + breakIngress() { const pc = pcs.find((p) => p.connectionState === 'connected' && p.getSenders().some((s) => s.track?.kind === 'video')); + pc?.close(); }, + async sample() { + let outbound = 0, encoded = 0, received = 0; + const videoStats=[], feedback=[]; + for (const pc of pcs) { if (pc.connectionState === 'closed') continue; + const stats = await pc.getStats(); stats.forEach((s) => { + if (s.type==='remote-inbound-rtp') feedback.push({kind:s.kind,lost:s.packetsLost,fraction:s.fractionLost,rtt:s.roundTripTime,reports:s.reportsReceived}); + if (s.type==='candidate-pair' && s.nominated) feedback.push({rtt:s.currentRoundTripTime,bwe:s.availableOutgoingBitrate,sent:s.bytesSent,received:s.bytesReceived}); + if (s.type === 'outbound-rtp' && s.kind === 'video' && s.framesEncoded > 0) { outbound++; encoded += s.framesEncoded; + const source=stats.get(s.mediaSourceId); + videoStats.push({width:s.frameWidth,height:s.frameHeight,fps:s.framesPerSecond,reason:s.qualityLimitationReason, + sourceFps:source?.framesPerSecond,target:s.targetBitrate,encoder:s.encoderImplementation}); } + if (s.type === 'inbound-rtp' && s.kind === 'video') { + received += s.framesDecoded || 0; + feedback.push({inbound:true,fps:s.framesPerSecond,receivedFrames:s.framesReceived,decodedFrames:s.framesDecoded, + dropped:s.framesDropped,lost:s.packetsLost,received:s.packetsReceived,nack:s.nackCount,pli:s.pliCount, + decodeTime:s.totalDecodeTime,buffer:s.jitterBufferDelay,bufferEmitted:s.jitterBufferEmittedCount}); + } + }); } + const video = document.querySelector('video'); + return { outbound, encoded, received, captures, videoStats, feedback, width: video?.videoWidth || 0, + requests, errors, peers: pcs.map((p) => ({state:p.connectionState, tracks:p.getSenders().map((s)=>s.track?.kind)})), + frames: video?.getVideoPlaybackQuality().totalVideoFrames || 0, + sharing: Boolean(document.querySelector('button[aria-label="Stop sharing"]')), + captureLive: Boolean(video?.srcObject?.getVideoTracks()[0]?.readyState === 'live'), + capturesEnabled: video?.srcObject?.getVideoTracks()[0]?.enabled, + receiveRequests: requests.filter((r) => r === 'receive-offer').length, + edgeRequests: requests.filter((r) => r === 'prepare-edge').length, + controlOpen: sockets.some((s) => s.readyState === WS.OPEN), + codecSelected: [...document.querySelectorAll('button.lr-chip.is-selected')].map((b) => b.textContent), + audio: video?.srcObject?.getAudioTracks().length || 0, + buttons: [...document.querySelectorAll('.lr-host-share-slot button')].map((b) => b.getAttribute('aria-label')) }; + } + }; +})()`; + +async function main(): Promise { + if (process.env.SCREENER_CLIENT_BROWSER_FANOUT_GATE !== "true") throw new Error("Browser fanout gate was not enabled"); + const chromePath = process.env.CHROME_PATH; + if (!chromePath) throw new Error("CHROME_PATH is required"); + const profile = await mkdtemp(join(tmpdir(), "screener-client-media-")); + const appPort = await reservePort(), debugPort = await reservePort(); + const origin = `http://localhost:${appPort}`; + let client: ChildProcessWithoutNullStreams | null = null; + let relayClient: ChildProcessWithoutNullStreams | null = null; + let chrome: ChildProcessWithoutNullStreams | null = null; + let cdp: CdpConnection | null = null; + let server: ScreenerServer | null = null; + let clientPort = 0; + let relayPort = 0; + const pages: PageHandle[] = []; + const checks: Record = {}; + let stage = "startup", error: string | null = null; + try { + server = await createScreenerServer({ + config: createLocalServerConfig({ port: appPort, publicAddress: "192.0.2.1" }), + frontend: { mode: "static", directory: join(ROOT, "dist/client") }, + }); + await server.listen(appPort, "127.0.0.1"); + client = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT, "build/client-check/screener-client.exe"), [ + "--site", origin, "--config", join(profile, "client.json"), + "--capture-process", join(ROOT, "build/client-check/screener-client-capture.exe"), + ], { windowsHide: true, stdio: "pipe", env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" } }); + client.stderr.resume(); + let output = ""; + client.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + if (!clientPort && output.includes("\n")) clientPort = decodeClientEndpoint(output.split("\n")[0]!).port; + }); + await waitForSample(async () => clientPort, (value) => value > 0, 15000); + chrome = spawn(chromePath, [ + `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, "--headless=new", + "--no-first-run", "--no-default-browser-check", "--no-proxy-server", + "--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows", "about:blank", + ], { windowsHide: true, stdio: "pipe" }); + chrome.stdout.resume(); chrome.stderr.resume(); + const version = await waitForVersion(debugPort, chrome); + checks.browser = version.Browser; + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10000); + await cdp.call("Browser.setPermission", { origin, permission: {name:"loopback-network"}, setting:"granted" }, undefined, Date.now()+5000); + const host = await createPage(cdp, `${origin}/#screener-client=1`, probe); + pages.push(host); + const read = async (page: PageHandle) => { + const sample = await evaluate(cdp!, page, "fanoutGate.sample()", Date.now()+5000); + if (page === host) checks.lastHost = sample; + return sample; + }; + const click = async (page: PageHandle, selector: string) => { + await waitForSample((deadline) => evaluate(cdp!, page, + `Boolean(document.querySelector(${JSON.stringify(selector)}))`, deadline), Boolean, 10000); + await evaluate(cdp!, page, `document.querySelector(${JSON.stringify(selector)}).click()`, Date.now()+5000); + }; + stage = "share"; + await click(host, 'button[aria-controls="host-advanced-door"]'); + stage = "share-codec"; + await click(host, 'button[aria-label^="H264"]'); + stage = "share-picker"; + await click(host, 'button.lr-tv-big.is-action'); + stage = "share-browser-capture"; + await click(host, '.lr-source-option.is-browser'); + stage = "share-ingress"; + await waitForSample(() => read(host), (s) => s.sharing && s.receiveRequests === 1 && s.outbound === 1, 30000); + let invite = await evaluate(cdp, host, "document.querySelector('.lr-invite-url').value", Date.now()+5000); + const url = new URL(invite); url.host = `localhost:${appPort}`; invite = url.toString(); + const relayOrigin = `http://127.0.0.1:${appPort}`; + relayClient = spawn(process.env.SCREENER_CLIENT_EXE || join(ROOT,"build/client-check/screener-client.exe"), [ + "--site", relayOrigin, "--config", join(profile,"relay-client.json"), + "--capture-process", join(ROOT,"build/client-check/screener-client-capture.exe"), + ], { windowsHide:true, stdio:"pipe", env:{...process.env,SCREENER_CLIENT_GATE_NO_BROWSER:"true"} }); + relayClient.stderr.resume(); let relayOutput=""; + relayClient.stdout.on("data", (chunk:Buffer) => { + relayOutput+=chunk.toString(); + if (!relayPort && relayOutput.includes("\n")) relayPort=decodeClientEndpoint(relayOutput.split("\n")[0]!).port; + }); + await waitForSample(async()=>relayPort,(value)=>value>0,15000); + const viewers: PageHandle[] = []; + for (let index=0; index<2; index++) { + stage = `viewer-${index+1}`; + const context = await cdp.call<{browserContextId: string}>("Target.createBrowserContext", {}, undefined, Date.now()+5000); + await cdp.call("Browser.setPermission", { origin:index===0?relayOrigin:origin, permission: {name:"loopback-network"}, setting:"granted", browserContextId: context.browserContextId }, undefined, Date.now()+5000); + const viewerURL = index===0 ? invite.replace(origin,relayOrigin)+"&screener-client=1" : invite; + const viewer = await createPage(cdp, viewerURL, probe, true, context.browserContextId); + viewers.push(viewer); + pages.push(viewer); + await waitForSample(() => read(viewer), (s) => s.width > 0 && s.frames >= 30, 30000); + } + stage = "shared-encode"; + await waitForSample(() => read(viewers[0]!), (s) => s.width===1920 && s.outbound===0, 30000); + await waitForSample(() => read(viewers[1]!), (s) => s.width===1920 && s.outbound===0, 30000); + checks.host = await read(host); + checks.viewers = await Promise.all(viewers.map(read)); + if ((checks.host as any).outbound !== 1 || !(checks.viewers as any[]).every((v) => v.outbound === 0)) { + throw new Error("Shared encode count did not match the Native fanout"); + } + const beforeSteady = await Promise.all(viewers.map(read)); + const steadyStart = Date.now(); + await waitForSample(async () => Date.now()-steadyStart, (elapsed) => elapsed>=5000, 6000); + const afterSteady = await Promise.all(viewers.map(read)); + checks.viewerFps = afterSteady.map((sample,index) => (sample.received-beforeSteady[index].received)*1000/(Date.now()-steadyStart)); + if (!(checks.viewerFps as number[]).every((fps) => fps>=27)) throw new Error("Viewer delivery did not sustain the 30 fps source"); + stage = "quality"; + await click(host, 'button[aria-label="720p"]'); + await waitForSample(() => read(viewers[0]!), (s) => s.width === 1280, 20000); + checks.liveQuality = true; + stage = "pause-resume"; + await click(host, 'button[aria-label="Pause sharing"]'); + await waitForSample(() => read(host), (s) => s.capturesEnabled === false, 5000); + await click(host, 'button[aria-label="Resume sharing"]'); + const before = await read(viewers[0]!); + await waitForSample(() => read(viewers[0]!), (s) => s.frames >= before.frames+20, 10000); + checks.pauseResume = true; + stage = "source-audio-change"; + await evaluate(cdp, host, `fanoutGate.audio(${!initialAudio})`, Date.now()+5000); + await click(host, 'button[aria-label="Switch source"]'); + await waitForSample(() => read(host), (s) => s.receiveRequests === 2 && s.captures === 2 && s.sharing, 15000); + const beforeSource = await read(viewers[1]!); + await waitForSample(() => read(viewers[1]!), (s) => s.width > 0 && s.frames >= beforeSource.frames+20 && s.audio===Number(!initialAudio), 25000); + checks.sourceAudioChange = true; + stage = "client-exit"; + client.stdin.write("\n"); + await waitForSample(() => read(host), (s) => !s.controlOpen && s.sharing && s.captureLive && s.outbound === 2, 25000); + for (const viewer of viewers) { + const prior = await read(viewer); + await waitForSample(() => read(viewer), (s) => s.width > 0 && s.frames >= prior.frames+15, 30000); + } + checks.clientExitRecovery = true; + checks.finalHost = await read(host); + if ((checks.finalHost as any).errors.length > 0) throw new Error("Browser reported an unhandled media error"); + await mkdir(join(ROOT,"build/client-check"), {recursive:true}); + const screenshot = await cdp.call<{data:string}>("Page.captureScreenshot", {format:"png"}, host.sessionId, Date.now()+5000); + await writeFile(join(ROOT,"build/client-check/browser-fanout.png"), Buffer.from(screenshot.data,"base64")); + await click(host, 'button[aria-label="Stop sharing"]'); + stage = "done"; + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + if (cdp) checks.atFailure = await Promise.all(pages.map((page) => + evaluate(cdp!,page,"fanoutGate.sample()",Date.now()+5000).catch(()=>null))); + } finally { + const relayCleanup = await cleanupRun({cdp:null,native:relayClient,chrome:null,server:null,profile:null,ports:relayPort?[relayPort]:[]}); + const cleanup = await cleanupRun({cdp, native:client, chrome, server, profile, ports:[appPort,debugPort,...(clientPort?[clientPort]:[])]}); + const result = {passed: error===null && Object.values(cleanup).every(Boolean) && relayCleanup.nativeExited && relayCleanup.portsClosed, stage, checks, error, cleanup}; + await mkdir(join(ROOT,"build/client-check"),{recursive:true}); + await writeFile(join(ROOT,"build/client-check/browser-fanout.json"), JSON.stringify(result,null,2)); + process.stdout.write(JSON.stringify(result)+"\n"); + if (!result.passed) process.exitCode=1; + } +} + +await main(); diff --git a/scripts/client-icons.mjs b/scripts/client-icons.mjs new file mode 100644 index 0000000..36322ac --- /dev/null +++ b/scripts/client-icons.mjs @@ -0,0 +1,177 @@ +import { + chmodSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function fail(message) { + throw new Error(`Client icon assets are invalid: ${message}`); +} + +function readPngEntries(iconPath) { + const icon = readFileSync(iconPath); + if ( + icon.length < 6 || + icon.readUInt16LE(0) !== 0 || + icon.readUInt16LE(2) !== 1 + ) { + fail("ICO header"); + } + const count = icon.readUInt16LE(4); + const entries = new Map(); + for (let index = 0; index < count; index += 1) { + const offset = 6 + index * 16; + if (offset + 16 > icon.length) fail("ICO directory"); + const width = icon[offset] || 256; + const height = icon[offset + 1] || 256; + const length = icon.readUInt32LE(offset + 8); + const dataOffset = icon.readUInt32LE(offset + 12); + const data = icon.subarray(dataOffset, dataOffset + length); + if ( + data.length !== length || + !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) + ) { + fail(`PNG payload ${width}x${height}`); + } + if (width !== height) fail(`non-square source ${width}x${height}`); + entries.set(width, data); + } + return entries; +} + +function requireEntry(entries, size) { + const data = entries.get(size); + if (!data) fail(`missing ${size}px source`); + return data; +} + +function writeIcns(entries, output) { + const kinds = [ + [16, "icp4"], + [32, "icp5"], + [64, "icp6"], + [128, "ic07"], + [256, "ic08"], + ]; + const chunks = kinds.map(([size, kind]) => { + const data = requireEntry(entries, size); + const header = Buffer.alloc(8); + header.write(kind, 0, 4, "ascii"); + header.writeUInt32BE(data.length + 8, 4); + return Buffer.concat([header, data]); + }); + const result = Buffer.alloc( + 8 + chunks.reduce((total, chunk) => total + chunk.length, 0), + ); + result.write("icns", 0, 4, "ascii"); + result.writeUInt32BE(result.length, 4); + let offset = 8; + for (const chunk of chunks) { + chunk.copy(result, offset); + offset += chunk.length; + } + writeFileSync(output, result); +} + +function writeLinuxAssets(packageRoot, entries) { + const iconDirectory = join( + packageRoot, + "share", + "icons", + "hicolor", + "256x256", + "apps", + ); + const applicationDirectory = join(packageRoot, "share", "applications"); + mkdirSync(iconDirectory, { recursive: true }); + mkdirSync(applicationDirectory, { recursive: true }); + writeFileSync( + join(iconDirectory, "screener-client.png"), + requireEntry(entries, 256), + ); + writeFileSync( + join(applicationDirectory, "screener-client.desktop"), + [ + "[Desktop Entry]", + "Version=1.0", + "Type=Application", + "Name=Screener Client", + "Exec=screener-client", + "TryExec=screener-client", + "Icon=screener-client", + "Terminal=false", + "Categories=Network;Utility;", + "StartupNotify=true", + "", + ].join("\n"), + "utf8", + ); +} + +function writeMacAssets(packageRoot, revision, entries) { + const bundleRoot = join(packageRoot, "Screener Client.app"); + const contents = join(bundleRoot, "Contents"); + const macos = join(contents, "MacOS"); + const resources = join(contents, "Resources"); + mkdirSync(macos, { recursive: true }); + mkdirSync(resources, { recursive: true }); + writeIcns(entries, join(resources, "screener.icns")); + writeFileSync( + join(contents, "Info.plist"), + [ + '', + '', + '', + "CFBundleDevelopmentRegionen", + "CFBundleDisplayNameScreener Client", + "CFBundleExecutableLauncher", + "CFBundleIconFilescreener.icns", + "CFBundleIdentifiericu.bonfire.screener.client", + "CFBundleInfoDictionaryVersion6.0", + "CFBundleNameScreener Client", + "CFBundlePackageTypeAPPL", + "CFBundleShortVersionString1.0", + "NSScreenCaptureUsageDescriptionShare a screen or application selected by you.", + "NSAudioCaptureUsageDescriptionShare sound from the selected screen or application.", + `CFBundleVersion${revision}`, + "", + "", + ].join("\n"), + "utf8", + ); + const launcher = join(macos, "Launcher"); + writeFileSync( + launcher, + [ + "#!/bin/sh", + "set -eu", + 'base=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)', + 'exec "$base/../../../screener-client" "$@"', + "", + ].join("\n"), + "utf8", + ); + chmodSync(launcher, 0o755); +} + +export function writeClientPlatformAssets({ + packageRoot, + target, + revision, + iconPath, +}) { + const entries = readPngEntries(iconPath); + if (target.goos === "linux") writeLinuxAssets(packageRoot, entries); + if (target.goos === "darwin") writeMacAssets(packageRoot, revision, entries); + return target.goos === "linux" + ? "share/applications/screener-client.desktop" + : target.goos === "darwin" + ? "Screener Client.app" + : null; +} diff --git a/scripts/client-link-gate.ts b/scripts/client-link-gate.ts index c769cfa..c9203c8 100644 --- a/scripts/client-link-gate.ts +++ b/scripts/client-link-gate.ts @@ -150,12 +150,12 @@ async function createInvitation(port: number, password: string): Promise 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"); + if (!access.ok) throw new Error("Local access authentication failed"); const room = await fetch(`${origin}/api/rooms`, { method: "POST", headers: { "Content-Type": "application/json", - Cookie: cookie, + ...(cookie ? { Cookie: cookie } : {}), Origin: origin, }, body: JSON.stringify({ codeEntryPolicy: "open" }), @@ -250,8 +250,8 @@ async function main(): Promise { windowsHide: true, }); const output = new LineCapture(client); - const passwordLine = await output.wait( - (line) => line.startsWith("Local access password: "), + const accessLine = await output.wait( + (line) => line.startsWith("Local access password: ") || line === "Local access: open", ); const originLine = await output.wait( (line) => line.startsWith("Public invitation origin: "), @@ -260,7 +260,9 @@ async function main(): Promise { result.linkCreated = /^https:\/\/[a-z0-9-]+\.trycloudflare\.com$/.test(publicOrigin); const invitation = await createInvitation( port, - passwordLine.slice("Local access password: ".length), + accessLine.startsWith("Local access password: ") + ? accessLine.slice("Local access password: ".length) + : "", ); const invite = new URL(invitation); result.invitationUsesLink = invite.origin === publicOrigin && /^#v=/.test(invite.hash); diff --git a/scripts/client-local-gate.ts b/scripts/client-local-gate.ts index ae621a7..eafad5d 100644 --- a/scripts/client-local-gate.ts +++ b/scripts/client-local-gate.ts @@ -49,7 +49,8 @@ const LOCAL_PAGE_STATE = `fetch('/api/site-access') .then((response) => response.json()) .catch(() => null) .then((access) => ({ - bootstrapPresent: location.hash.startsWith('#client-access='), + bootstrapPresent: ['client-access', 'screener-client'] + .some((key) => new URLSearchParams(location.hash.slice(1)).has(key)), access, hostReady: Boolean(document.querySelector('.lr-host-personal-controls')), }))`; @@ -151,6 +152,11 @@ async function main(): Promise { if (typeof config.localAccessPassword !== "string") { throw new Error("Client access bootstrap is unavailable"); } + const localAccessPassword = config.localAccessPassword; + const bootstrap = new URLSearchParams({ + ...(localAccessPassword ? { "client-access": localAccessPassword } : {}), + "screener-client": "1", + }).toString(); browser = spawn(browserPath, [ `--remote-debugging-port=${debugPort}`, @@ -171,11 +177,11 @@ async function main(): Promise { cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); const page = await createPage( cdp, - `http://localhost:${appPort}/#client-access=${config.localAccessPassword}`, + `http://localhost:${appPort}/#${bootstrap}`, ); const readState = (deadline: number) => evaluate(cdp!, page, LOCAL_PAGE_STATE, deadline).then((value) => { - report.bootstrapAuthenticated = value.access?.required === true && + report.bootstrapAuthenticated = value.access?.required === Boolean(localAccessPassword) && value.access.authenticated; report.bootstrapRemoved = !value.bootstrapPresent; report.hostReady = value.hostReady; diff --git a/scripts/client-media-gate.ts b/scripts/client-media-gate.ts index 085fac7..35fa85f 100644 --- a/scripts/client-media-gate.ts +++ b/scripts/client-media-gate.ts @@ -75,6 +75,8 @@ interface MediaEvidence { localType: string | null; remoteType: string | null; nativeCandidateTypes: string[]; + liveProfileUpdated: boolean; + pausedProfileUpdated: boolean; error: string | null; } @@ -293,6 +295,8 @@ async function browserMediaGate(input: { localType: null, remoteType: null, nativeCandidateTypes: [], + liveProfileUpdated: false, + pausedProfileUpdated: false, error: null, }; try { @@ -302,7 +306,7 @@ async function browserMediaGate(input: { } as RequestInit); const health = await healthResponse.json(); if ( - health.protocol !== 5 || + health.protocol !== 7 || health.service !== "screener-client" || health.instanceToken !== input.endpoint.instanceToken || health.nativeMedia?.video !== true || @@ -316,7 +320,7 @@ async function browserMediaGate(input: { socket = new WebSocket( "ws://127.0.0.1:" + input.endpoint.port + "/control", - ["screener-client-v5." + input.endpoint.instanceToken], + ["screener-client-v8." + input.endpoint.instanceToken], ); await new Promise((resolveOpen, rejectOpen) => { const timer = window.setTimeout( @@ -357,7 +361,7 @@ async function browserMediaGate(input: { 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!.send(JSON.stringify({ version: 8, id, type, ...fields })); }); }; socket.onmessage = (event) => { @@ -431,11 +435,19 @@ async function browserMediaGate(input: { const connectionId = "edge_gate_0001"; const started = await request("start-share", { shareId, + codec: "h264", source: target, audio: result.audioAvailable, adapterIndex: adapter.index, encoderIndex: encoder.index, edgeCapacity: 2, + profile: { + resolution: "720p", + maxFramerate: 30, + maxBitrate: 3_000_000, + degradationPreference: "balanced", + screenAudioQuality: "music", + }, }); if (started.shareId !== shareId || started.audio !== result.audioAvailable) { throw new Error("Native share audio capability was not reported consistently"); @@ -574,6 +586,70 @@ async function browserMediaGate(input: { ) { throw new Error("Native H264 did not reach decoded Browser video"); } + + const videos = [...edges.values()].map((edge) => edge.video); + const framesBeforeLiveUpdate = Math.min( + ...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames), + ); + const liveUpdate = await request("update-share", { + shareId, + profile: { + resolution: "1440p", + maxFramerate: 60, + maxBitrate: 12_000_000, + degradationPreference: "maintain-framerate", + screenAudioQuality: "very-high", + }, + }); + const liveDeadline = performance.now() + 15_000; + while (performance.now() < liveDeadline) { + const frames = Math.min( + ...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames), + ); + result.liveProfileUpdated = liveUpdate.type === "share-updated" && + videos.every((video) => video.videoWidth === 2560 && video.videoHeight === 1440) && + frames >= framesBeforeLiveUpdate + 10; + if (result.liveProfileUpdated) break; + await new Promise((resolveWait) => window.setTimeout(resolveWait, 100)); + } + if (!result.liveProfileUpdated) { + throw new Error("Native live profile update did not reach both Viewers"); + } + + await request("pause-share", { shareId, paused: true }); + const pausedUpdate = await request("update-share", { + shareId, + profile: { + resolution: "480p", + maxFramerate: 15, + maxBitrate: 2_000_000, + degradationPreference: "maintain-resolution", + screenAudioQuality: "saver", + }, + }); + const framesBeforeResume = Math.min( + ...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames), + ); + await request("pause-share", { shareId, paused: false }); + const resumeDeadline = performance.now() + 15_000; + while (performance.now() < resumeDeadline) { + const frames = Math.min( + ...videos.map((video) => video.getVideoPlaybackQuality().totalVideoFrames), + ); + result.pausedProfileUpdated = pausedUpdate.type === "share-updated" && + videos.every((video) => video.videoWidth === 854 && video.videoHeight === 480) && + frames >= framesBeforeResume + 10; + if (result.pausedProfileUpdated) { + result.frames = frames; + result.width = 854; + result.height = 480; + break; + } + await new Promise((resolveWait) => window.setTimeout(resolveWait, 100)); + } + if (!result.pausedProfileUpdated) { + throw new Error("Native paused profile update did not resume both Viewers"); + } } catch (error) { result.error = error instanceof Error ? error.message : String(error); } finally { @@ -630,6 +706,8 @@ async function main(): Promise { localType: null, remoteType: null, nativeCandidateTypes: [], + liveProfileUpdated: false, + pausedProfileUpdated: false, error: "not run", audioPackets: 0, edgeAudioPackets: [], @@ -663,7 +741,7 @@ async function main(): Promise { 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) { + if (probe.protocol !== 4 || !adapter || !encoder) { throw new Error("No hardware H264 capture path is available"); } @@ -732,7 +810,19 @@ async function main(): Promise { String(adapter.index), "--mft-index", String(encoder.index), - "--protocol-v3", + "--width", + "1280", + "--height", + "720", + "--fps", + "30", + "--bitrate", + "3000000", + "--preference", + "balanced", + "--codec", + "h264", + "--protocol-v4", ], { stdio: "pipe", windowsHide: true }); capture.stderr.resume(); observeCapture(capture, evidence); @@ -811,7 +901,7 @@ async function main(): Promise { sourceKind, stunUrl: GATE_STUN_URL, }) + "))((target) => target)", - Date.now() + 35_000, + Date.now() + 70_000, ); if (media.error) throw new Error(media.error); } catch (caught) { @@ -833,7 +923,8 @@ async function main(): Promise { media.connectedEdges === 2 && media.edgeFrames.length === 2 && (!media.audioAvailable || (media.audioPackets > 10 && media.audioEnergy > 0)) && - media.width === 1280 && media.height === 720 && + media.liveProfileUpdated && media.pausedProfileUpdated && + media.width === 854 && media.height === 480 && media.nativeCandidateTypes.includes("srflx") && cleanup.browserExited && cleanup.nativeExited && cleanup.serverClosed && cleanup.portsClosed && cleanup.profileRemoved; diff --git a/scripts/client-native-host-gate.ts b/scripts/client-native-host-gate.ts index d4cff0c..db906df 100644 --- a/scripts/client-native-host-gate.ts +++ b/scripts/client-native-host-gate.ts @@ -31,11 +31,44 @@ const BUILD_ROOT = join(ROOT, "build", "client-check"); export const SOURCE_TITLE = "Screener Native Gate Source"; type GateMode = "local" | "cross-nat" | "one-link"; +type VideoCodec = "h264" | "vp8"; + +const CODEC_PROBE = `(() => { + const peers = [], BrowserPeer = RTCPeerConnection; + window.RTCPeerConnection = class extends BrowserPeer { + constructor(...args) { super(...args); peers.push(this); } + }; + window.__screenerGateVideoCodec = async () => { + const trackId = document.querySelector('video')?.srcObject?.getVideoTracks()[0]?.id; + if (!trackId) return null; + for (const peer of peers) { + if (peer.connectionState !== 'connected') continue; + const report = await peer.getStats(); + for (const row of report.values()) { + if (row.type !== 'inbound-rtp' || row.kind !== 'video' || + row.trackIdentifier !== trackId || !(row.framesDecoded > 0)) continue; + const mime = report.get(row.codecId)?.mimeType?.toLowerCase(); + if (mime === 'video/h264' || mime === 'video/vp8') return mime.slice(6); + } + } + return null; + }; +})()`; interface GateResult { passed: boolean; + requestedCodec: VideoCodec | "auto"; + actualCodec: VideoCodec | null; + codecPreserved: boolean; hostNativeActive: boolean; hostInvite: boolean; + preSharePreferenceEnabled: boolean; + qualityControlsEnabled: boolean; + livePresetChanges: number; + liveQualityChanged: boolean; + pausedQualityChanged: boolean; + mediaObjectPreserved: boolean; + nativeSourceChanged: boolean; nativeQualityEvidence: boolean; viewerConnected: boolean; viewerFrames: number; @@ -51,6 +84,7 @@ interface GateResult { sourceFailureEndedShare: boolean | null; replacementViewerConnected: boolean | null; replacementViewerFrames: number | null; + clientCrashEndedShare: boolean | null; mode: GateMode; cleanup: Awaited>; error: string | null; @@ -132,6 +166,12 @@ async function selectNativeSource( kind: "window" | "display", title?: string, ): Promise { + await evaluate( + cdp, + page, + `document.querySelector('button[data-source-tab="${kind}"]')?.click()`, + Date.now() + 5_000, + ); const selector = kind === "display" ? "button[data-native-source^='display:']" : "button[data-native-source]"; @@ -156,6 +196,49 @@ async function selectNativeSource( ); } +async function clickHostControl( + cdp: CdpConnection, + page: PageHandle, + buttonExpression: string, +): Promise { + await cdp.call("Page.bringToFront", {}, page.sessionId, Date.now() + 5_000); + const point = await evaluate<{ x: number; y: number }>( + cdp, + page, + `new Promise((resolve) => { + const button = ${buttonExpression}; + if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') + throw new Error('Host control is unavailable'); + button.scrollIntoView({block: 'center'}); + requestAnimationFrame(() => requestAnimationFrame(() => { + const rect = button.getBoundingClientRect(); + resolve({x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}); + })); + })`, + Date.now() + 5_000, + ); + for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) { + await cdp.call("Input.dispatchMouseEvent", { + type, ...point, button: "left", clickCount: 1, + }, page.sessionId, Date.now() + 5_000); + } +} + +async function readVideoCodec(cdp: CdpConnection, page: PageHandle): Promise { + const codec = await waitForValue( + (deadline) => evaluate(cdp, page, "window.__screenerGateVideoCodec()", deadline), + (value) => value === "vp8" || value === "h264", + 5_000, + ); + if (codec === null) throw new Error("No decoded video codec was observed"); + return codec; +} + +async function assertVideoCodec(cdp: CdpConnection, page: PageHandle, expected: VideoCodec): Promise { + const codec = await readVideoCodec(cdp, page); + if (codec !== expected) throw new Error(`Native codec changed from ${expected} to ${codec}`); +} + function remoteOptions(): RemoteGateOptions { const host = process.env.SCREENER_REMOTE_HOST?.trim(); const user = process.env.SCREENER_REMOTE_USER?.trim() || "root"; @@ -422,14 +505,18 @@ async function readClientEndpoint( // Informational lines are printed after the endpoint. } const passwordLine = lines.find((entry) => entry.startsWith("Local access password: ")); + const openAccessLine = lines.find((entry) => entry === "Local access: open"); const publicOriginLine = lines.find((entry) => entry.startsWith("Public invitation origin: ") ); - if (endpoint && passwordLine && (!requirePublicOrigin || publicOriginLine)) { + if (endpoint && (passwordLine || openAccessLine) && + (!requirePublicOrigin || publicOriginLine)) { child.stdout.off("data", onData); resolveEndpoint({ endpoint, - password: passwordLine.slice("Local access password: ".length), + password: passwordLine + ? passwordLine.slice("Local access password: ".length) + : "", publicOrigin: publicOriginLine?.slice("Public invitation origin: ".length) ?? null, }); @@ -460,10 +547,19 @@ async function main(): Promise { throw new Error("Cross-NAT and one-link gate modes are mutually exclusive"); } const mode: GateMode = linkMedia ? "one-link" : crossNat ? "cross-nat" : "local"; + const crashGate = + process.env.SCREENER_CLIENT_NATIVE_HOST_CRASH_GATE === "true"; + if (crashGate && mode !== "local") { + throw new Error("Client crash gate requires local mode"); + } const sourceKind: "window" | "display" = process.env.SCREENER_CLIENT_NATIVE_HOST_SOURCE === "display" ? "display" : "window"; + const requestedCodec = process.env.SCREENER_CLIENT_NATIVE_HOST_CODEC?.trim() || "auto"; + if (requestedCodec !== "auto" && requestedCodec !== "h264" && requestedCodec !== "vp8") { + throw new Error("SCREENER_CLIENT_NATIVE_HOST_CODEC must be auto, h264, or vp8"); + } const remote = mode === "local" ? null : remoteOptions(); const chromePath = process.env.CHROME_PATH?.trim(); if (!chromePath) throw new Error("CHROME_PATH is required"); @@ -497,8 +593,18 @@ async function main(): Promise { let stage = "setup"; const result: GateResult = { passed: false, + requestedCodec, + actualCodec: null, + codecPreserved: false, hostNativeActive: false, hostInvite: false, + preSharePreferenceEnabled: false, + qualityControlsEnabled: false, + livePresetChanges: 0, + liveQualityChanged: false, + pausedQualityChanged: false, + mediaObjectPreserved: false, + nativeSourceChanged: false, nativeQualityEvidence: false, viewerConnected: false, viewerFrames: 0, @@ -514,6 +620,7 @@ async function main(): Promise { sourceFailureEndedShare: mode === "local" ? false : null, replacementViewerConnected: mode === "local" ? false : null, replacementViewerFrames: mode === "local" ? 0 : null, + clientCrashEndedShare: crashGate ? false : null, mode, cleanup: { browserExited: false, @@ -638,12 +745,15 @@ async function main(): Promise { stage = "host-cdp"; const version = await waitForVersion(debugPort, chrome); cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000); + const hostBootstrap = new URLSearchParams({ + ...(clientInfo.password ? { "client-access": clientInfo.password } : {}), + "screener-client": "1", + }).toString(); stage = "host-page"; const host = await createPage( cdp, - "http://localhost:" + appPort + "/#client-access=" + clientInfo.password + - "&screener-client=1", - undefined, + "http://localhost:" + appPort + "/#" + hostBootstrap, + CODEC_PROBE, true, ); await waitForValue( @@ -656,6 +766,37 @@ async function main(): Promise { Boolean, 15_000, ); + stage = "pre-share-quality-controls"; + await evaluate( + cdp, + host, + `document.querySelector('button[aria-controls="host-advanced-door"]')?.click()`, + Date.now() + 5_000, + ); + result.preSharePreferenceEnabled = await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `(() => { + const preference = document.querySelector('button[name="degradationPreference"][value="maintain-resolution"]'); + return Boolean(preference && !preference.disabled); + })()`, + deadline, + ), + Boolean, + 5_000, + ); + stage = "pre-share-codec"; + await clickHostControl(cdp, host, + `([...document.querySelectorAll('button.lr-chip')].find((button) => + button.textContent?.trim() === ${JSON.stringify(requestedCodec.toUpperCase())}))`, + ); + await evaluate( + cdp, + host, + `document.querySelector('button[aria-controls="host-advanced-door"]')?.click()`, + Date.now() + 5_000, + ); stage = "host-click"; await evaluate( cdp, @@ -700,6 +841,12 @@ async function main(): Promise { } result.hostNativeActive = hostState.native; result.hostInvite = hostState.invite !== null; + stage = "host-codec"; + const actualCodec = await readVideoCodec(cdp, host); + result.actualCodec = actualCodec; + if (requestedCodec !== "auto" && actualCodec !== requestedCodec) { + throw new Error(`Native ${requestedCodec} selection produced ${actualCodec}`); + } 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); @@ -739,7 +886,7 @@ async function main(): Promise { const viewerURL = new URL(hostState.invite); viewerURL.hostname = "localhost"; stage = "viewer-page"; - const viewer = await createPage(cdp, viewerURL.toString(), undefined, true); + const viewer = await createPage(cdp, viewerURL.toString(), CODEC_PROBE, true); stage = "viewer-media"; const viewerState = await waitForValue( (deadline) => evaluate<{ @@ -757,21 +904,14 @@ async function main(): Promise { deadline, ), (state) => state.connected && state.frames >= 30 && - state.width === 1280 && state.height === 720, + state.width === 1920 && state.height === 1080, 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 assertVideoCodec(cdp, viewer, actualCodec); await evaluate( cdp, viewer, @@ -783,7 +923,303 @@ async function main(): Promise { })()`, Date.now() + 5_000, ); - if (sourceKind === "window") { + stage = "native-quality-presets"; + for (const [index, width, height] of [[0, 1280, 720], [1, 1920, 1080]] as const) { + await clickHostControl(cdp, host, `document.querySelectorAll('button.lr-tile')[${index}]`); + await waitForValue( + (deadline) => evaluate( + cdp!, + viewer, + `(() => { const video = document.querySelector('video'); return Boolean( + video && video.videoWidth === ${width} && video.videoHeight === ${height} + ); })()`, + deadline, + ), + Boolean, + 20_000, + ); + await assertVideoCodec(cdp, viewer, actualCodec); + result.livePresetChanges += 1; + } + stage = "native-quality-controls"; + await evaluate( + cdp, + host, + `document.querySelector('button[aria-controls="host-advanced-door"]')?.click()`, + Date.now() + 5_000, + ); + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `Boolean(document.querySelector('button.lr-chip[aria-label="1440p"]'))`, + deadline, + ), + Boolean, + 5_000, + ); + result.qualityControlsEnabled = await evaluate( + cdp, + host, + `(() => { + const resolution = document.querySelector('button.lr-chip[aria-label="1440p"]'); + const preference = document.querySelector('button[name="degradationPreference"][value="maintain-framerate"]'); + const sliders = [...document.querySelectorAll('.lr-slider input[type="range"]')]; + return Boolean( + resolution && preference && !resolution.disabled && !preference.disabled && + sliders.length === 2 && sliders.every((slider) => !slider.disabled) + ); + })()`, + Date.now() + 5_000, + ); + if (!result.qualityControlsEnabled) { + throw new Error("Native quality controls remained disabled"); + } + await evaluate( + cdp, + host, + `document.querySelector('button.lr-chip[aria-label="1440p"]')?.click()`, + Date.now() + 5_000, + ); + result.liveQualityChanged = await waitForValue( + (deadline) => evaluate( + cdp!, + viewer, + `(() => { const video = document.querySelector('video'); return Boolean( + video && video.videoWidth === 2560 && video.videoHeight === 1440 && + video.getVideoPlaybackQuality().totalVideoFrames >= ${viewerState.frames + 10} + ); })()`, + deadline, + ), + Boolean, + 20_000, + ); + if (!result.liveQualityChanged) { + throw new Error("Native quality change did not reach the Viewer"); + } + result.viewerWidth = 2560; + result.viewerHeight = 1440; + await assertVideoCodec(cdp, viewer, actualCodec); + const framesBeforePreference = await evaluate( + cdp, + viewer, + `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, + Date.now() + 5_000, + ); + await evaluate( + cdp, + host, + `document.querySelector('button[name="degradationPreference"][value="maintain-framerate"]')?.click()`, + Date.now() + 5_000, + ); + await waitForValue( + (deadline) => evaluate( + cdp!, + viewer, + `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, + deadline, + ), + (frames) => frames >= framesBeforePreference + 10, + 15_000, + ); + stage = "native-pause-ready"; + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `Boolean([...document.querySelectorAll('button')].find((candidate) => + /^(Pause sharing|暂停分享)$/.test(candidate.getAttribute('aria-label') || '') && + !candidate.disabled && candidate.getAttribute('aria-disabled') !== 'true' + ))`, + deadline, + ), + Boolean, + 10_000, + ); + await evaluate( + cdp, + host, + `(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => + /^(Pause sharing|暂停分享)$/.test(candidate.getAttribute('aria-label') || '') + ); + button?.click(); + })()`, + Date.now() + 5_000, + ); + stage = "native-pause-applied"; + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `Boolean([...document.querySelectorAll('button')].find((candidate) => + /^(Resume sharing|恢复分享)$/.test(candidate.getAttribute('aria-label') || '') + ))`, + deadline, + ), + Boolean, + 5_000, + ); + await evaluate( + cdp, + host, + `document.querySelector('button.lr-chip[aria-label="480p"]')?.click()`, + Date.now() + 5_000, + ); + stage = "native-paused-profile"; + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `(() => { + const resolution = document.querySelector('button.lr-chip[aria-label="480p"]'); + const resume = [...document.querySelectorAll('button')].find((candidate) => + /^(Resume sharing|恢复分享)$/.test(candidate.getAttribute('aria-label') || '') + ); + return Boolean( + resolution?.getAttribute('aria-pressed') === 'true' && resume && + !resume.disabled && resume.getAttribute('aria-disabled') !== 'true' + ); + })()`, + deadline, + ), + Boolean, + 10_000, + ); + const framesBeforeResume = await evaluate( + cdp, + viewer, + `document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`, + Date.now() + 5_000, + ); + await evaluate( + cdp, + host, + `(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => + /^(Resume sharing|恢复分享)$/.test(candidate.getAttribute('aria-label') || '') + ); + button?.click(); + })()`, + Date.now() + 5_000, + ); + stage = "native-resumed-profile"; + const resumed = await waitForValue( + (deadline) => evaluate<{ + frames: number; + width: number; + height: number; + sameMedia: boolean; + }>( + cdp!, + viewer, + `(() => { + const video = document.querySelector('video'); + return { + frames: video?.getVideoPlaybackQuality().totalVideoFrames ?? 0, + width: video?.videoWidth ?? 0, + height: video?.videoHeight ?? 0, + sameMedia: Boolean(video && video.srcObject === window.__screenerGateMedia), + }; + })()`, + deadline, + ), + (value) => value.frames >= framesBeforeResume + 10 && + value.width === 854 && value.height === 480, + 20_000, + ); + result.pausedQualityChanged = resumed.width === 854 && resumed.height === 480; + result.mediaObjectPreserved = resumed.sameMedia; + result.viewerWidth = resumed.width; + result.viewerHeight = resumed.height; + await assertVideoCodec(cdp, viewer, actualCodec); + stage = "native-source-picker"; + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `Boolean([...document.querySelectorAll('button')].find((candidate) => + /^(Switch source|切换来源)$/.test(candidate.getAttribute('aria-label') || '') && + !candidate.disabled && candidate.getAttribute('aria-disabled') !== 'true' + ))`, + deadline, + ), + Boolean, + 10_000, + ); + await evaluate( + cdp, + host, + `(() => { + const button = [...document.querySelectorAll('button')].find((candidate) => + /^(Switch source|切换来源)$/.test(candidate.getAttribute('aria-label') || '') + ); + button?.click(); + })()`, + Date.now() + 5_000, + ); + await waitForValue( + (deadline) => evaluate( + cdp!, + host, + `Boolean(document.querySelector('[role="dialog"] button[data-native-source]'))`, + deadline, + ), + Boolean, + 10_000, + ); + const framesBeforeSourceChange = resumed.frames; + await evaluate( + cdp, + host, + `document.querySelector('[role="dialog"] button[data-native-source]')?.click()`, + Date.now() + 5_000, + ); + stage = "native-source-replaced"; + result.nativeSourceChanged = await waitForValue( + (deadline) => evaluate( + cdp!, + viewer, + `(() => { + const video = document.querySelector('video'); + return Boolean( + video && video.srcObject === window.__screenerGateMedia && + video.videoWidth === 854 && video.videoHeight === 480 && + video.getVideoPlaybackQuality().totalVideoFrames >= ${framesBeforeSourceChange + 10} + ); + })()`, + deadline, + ), + Boolean, + 20_000, + ); + stage = "native-quality-evidence"; + await assertVideoCodec(cdp, viewer, actualCodec); + result.codecPreserved = true; + result.nativeQualityEvidence = await waitForValue( + async () => /"event":"sender-quality-evidence"[^\n]*"state":"(healthy|degraded)"/ + .test(clientDiagnostics), + Boolean, + 12_000, + ); + + if (crashGate) { + stage = "client-crash"; + if (!client || !(await stopChild(client))) { + throw new Error("Client did not terminate for crash gate"); + } + client = null; + result.clientCrashEndedShare = await waitForValue( + (deadline) => evaluate( + cdp!, + host, + "Boolean(document.querySelector('button.lr-tv-big.is-action'))", + deadline, + ), + Boolean, + 5_000, + ); + } else if (sourceKind === "window") { stage = "source-failure"; if (!sourceChrome || !sourceCdp || !(await closeSourceBrowser(sourceChrome, sourceCdp))) { @@ -842,7 +1278,7 @@ async function main(): Promise { const video = document.querySelector('video'); return Boolean(video && video.srcObject && video.srcObject !== window.__screenerGateMedia && - video.videoWidth === 1280 && video.videoHeight === 720); + video.videoWidth === 854 && video.videoHeight === 480); })()`, deadline, ), @@ -910,19 +1346,27 @@ async function main(): Promise { result.cleanup.portsClosed && sourceCleanup.portsClosed; } } - result.passed = result.error === null && result.hostNativeActive && + result.passed = result.error === null && result.actualCodec !== null && result.preSharePreferenceEnabled && + 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.qualityControlsEnabled && result.liveQualityChanged && + result.livePresetChanges === 2 && + result.codecPreserved && + result.pausedQualityChanged && result.mediaObjectPreserved && + result.nativeSourceChanged && + result.viewerWidth === 854 && result.viewerHeight === 480 && result.nativeQualityEvidence && - (sourceKind === "display" || - (result.sourceFailureEndedShare === true && - result.replacementViewerConnected === true && - (result.replacementViewerFrames ?? 0) >= 30))) && + (crashGate + ? result.clientCrashEndedShare === true + : 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; diff --git a/scripts/client-native-sfu-gate.ts b/scripts/client-native-sfu-gate.ts index 405b2ff..716d32c 100644 --- a/scripts/client-native-sfu-gate.ts +++ b/scripts/client-native-sfu-gate.ts @@ -172,6 +172,7 @@ async function main(): Promise { viewerFrames: 0, viewerWidth: 0, viewerHeight: 0, + profileUpdated: false, cleanup: false, error: null as string | null, stage: "setup", @@ -327,6 +328,37 @@ async function main(): Promise { result.viewerFrames = viewer.frames ?? 0; result.viewerWidth = viewer.width ?? 0; result.viewerHeight = viewer.height ?? 0; + result.stage = "profile-update"; + const updated = await evaluate( + cdp, + hostPage, + `(async () => { + const gate = await import('/scripts/client-native-sfu-page.ts'); + return await gate.updateNativeSfuHost({ + resolution: '480p', + maxFramerate: 15, + maxBitrate: 2000000, + degradationPreference: 'maintain-resolution', + screenAudioQuality: 'saver', + }); + })()`, + Date.now() + 20_000, + ); + const updatedViewer = await evaluate<{ + frames: number; + width: number; + height: number; + }>( + cdp, + viewerPage, + `import('/scripts/client-native-sfu-page.ts').then((gate) => + gate.waitForNativeSfuViewer(854, 480))`, + Date.now() + 25_000, + ); + result.profileUpdated = updated && updatedViewer.frames >= 15; + result.viewerFrames = updatedViewer.frames; + result.viewerWidth = updatedViewer.width; + result.viewerHeight = updatedViewer.height; } catch (error) { result.error = error instanceof Error ? error.message : String(error); } finally { @@ -381,8 +413,8 @@ async function main(): Promise { sourceCleanup.profileRemoved && sourceCleanup.portsClosed; } result.passed = result.error === null && result.hostPublished && - result.viewerFrames >= 30 && result.viewerWidth === 1280 && - result.viewerHeight === 720 && result.cleanup; + result.profileUpdated && result.viewerFrames >= 15 && + result.viewerWidth === 854 && result.viewerHeight === 480 && result.cleanup; process.stdout.write(`${JSON.stringify(result)}\n`); if (!result.passed) process.exitCode = 1; } diff --git a/scripts/client-native-sfu-page.ts b/scripts/client-native-sfu-page.ts index a733ee1..ee3c67e 100644 --- a/scripts/client-native-sfu-page.ts +++ b/scripts/client-native-sfu-page.ts @@ -1,4 +1,7 @@ -import { DEFAULT_QUALITY_SETTINGS } from "../src/shared/protocol"; +import { + DEFAULT_QUALITY_SETTINGS, + type QualitySettings, +} 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"; @@ -10,6 +13,7 @@ let hostResources: { shareId: string; } | null = null; let viewerRoom: import("livekit-client").Room | null = null; +let viewerVideo: HTMLVideoElement | null = null; export async function startNativeSfuHost(input: { url: string; @@ -35,12 +39,14 @@ export async function startNativeSfuHost(input: { throw new Error("native capture path is unavailable"); } const started = await client.startShare({ + codec: "h264", shareId, source: target, audio: true, adapterIndex: adapter.index, encoderIndex: encoder.index, edgeCapacity: 3, + profile: DEFAULT_QUALITY_SETTINGS, }); bridge = new NativeMediaBridge( shareId, @@ -88,6 +94,7 @@ export async function startNativeSfuViewer(input: { if (track.kind !== sdk.Track.Kind.Video) return; publication.setVideoQuality(sdk.VideoQuality.HIGH); const video = track.attach() as HTMLVideoElement; + viewerVideo = video; video.muted = true; video.autoplay = true; document.body.append(video); @@ -95,7 +102,7 @@ export async function startNativeSfuViewer(input: { let frames = 0; const next = () => { video.requestVideoFrameCallback(() => { - if (video.videoWidth === 1280 && video.videoHeight === 720) { + if (video.videoWidth === 1920 && video.videoHeight === 1080) { frames += 1; } else { frames = 0; @@ -126,9 +133,48 @@ export async function startNativeSfuViewer(input: { } } +export async function updateNativeSfuHost( + profile: QualitySettings, +): Promise { + const host = hostResources; + if (!host) throw new Error("native SFU Host is unavailable"); + await host.client.updateShare(host.shareId, profile); + return await host.publisher.updateProfile(profile); +} + +export async function waitForNativeSfuViewer( + width: number, + height: number, +): Promise<{ frames: number; width: number; height: number }> { + const video = viewerVideo; + if (!video) throw new Error("native SFU Viewer is unavailable"); + return await new Promise((resolve, reject) => { + let frames = 0; + const timer = window.setTimeout( + () => reject(new Error("native SFU Viewer profile timed out")), + 20_000, + ); + const next = () => { + video.requestVideoFrameCallback(() => { + frames = video.videoWidth === width && video.videoHeight === height + ? frames + 1 + : 0; + if (frames < 15) { + next(); + return; + } + window.clearTimeout(timer); + resolve({ frames, width: video.videoWidth, height: video.videoHeight }); + }); + }; + next(); + }); +} + export async function stopNativeSfuGate(): Promise { const viewer = viewerRoom; viewerRoom = null; + viewerVideo = null; if (viewer) await viewer.disconnect(false).catch(() => undefined); const host = hostResources; hostResources = null; diff --git a/scripts/client-package-targets.mjs b/scripts/client-package-targets.mjs index 8e62b1d..47b1acc 100644 --- a/scripts/client-package-targets.mjs +++ b/scripts/client-package-targets.mjs @@ -1,3 +1,5 @@ +export const CLOUDFLARED_VERSION = "2026.8.3"; + export const CLIENT_PACKAGE_TARGETS = [ { id: "windows-amd64", @@ -22,7 +24,7 @@ export const CLIENT_PACKAGE_TARGETS = [ clientName: "screener-client", nodeName: "node", tunnelName: "cloudflared", - captureName: null, + captureName: "screener-client-capture", tunnelAsset: "cloudflared-linux-amd64", tunnelSha256: "f29324fe934d1e100617484c78deef803c4dc2cd351d645bbde42e96b4fccc5e", tunnelArchive: false, diff --git a/scripts/package-app-release.mjs b/scripts/package-app-release.mjs index 1f5d3e2..9322f33 100644 --- a/scripts/package-app-release.mjs +++ b/scripts/package-app-release.mjs @@ -80,6 +80,7 @@ function normalizedRelative(root, path) { function runtimePathAllowed(path) { return ( path === "REVISION" || + path === "LICENSE" || path === "package.json" || path === "package-lock.json" || path.startsWith("dist/client/") || @@ -156,6 +157,7 @@ function validateArchiveEntries(entries) { } if ( entry !== "REVISION" && + entry !== "LICENSE" && entry !== "package.json" && entry !== "package-lock.json" && entry !== "dist" && @@ -181,7 +183,7 @@ if (existsSync(outputRoot)) fail("Output directory must not already exist"); const revision = assertCleanRevision(repositoryRoot); -for (const required of ["package.json", "package-lock.json"]) { +for (const required of ["LICENSE", "package.json", "package-lock.json"]) { if (!existsSync(join(repositoryRoot, required))) fail(`Missing release input: ${required}`); } buildRuntime(repositoryRoot); @@ -198,6 +200,7 @@ const verifyRoot = join(temporaryRoot, "verify"); try { mkdirSync(runtimeRoot, { recursive: true }); for (const source of [ + join(repositoryRoot, "LICENSE"), join(repositoryRoot, "package.json"), join(repositoryRoot, "package-lock.json"), ...regularFiles(join(repositoryRoot, "dist")), diff --git a/scripts/package-client-candidate.mjs b/scripts/package-client-candidate.mjs index 72b3b29..423b620 100644 --- a/scripts/package-client-candidate.mjs +++ b/scripts/package-client-candidate.mjs @@ -20,9 +20,7 @@ import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; -import { clientPackageTarget } from "./client-package-targets.mjs"; - -const cloudflaredVersion = "2026.8.3"; +import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs"; function fail(message) { throw new Error(message); @@ -168,7 +166,7 @@ async function verifyLocalPackage(root, target, temporaryRoot) { child.stdin.on("error", () => undefined); child.stdout.on("data", (chunk) => { stdout = `${stdout}${chunk.toString()}`.slice(-2_048); - if (stdout.includes("Local access password: ")) { + if (stdout.includes("Local access password: ") || stdout.includes("Local access: open")) { clientReady = true; stdout = ""; } @@ -236,6 +234,13 @@ async function verifyPackage( ) { const packagedRevision = readFileSync(join(root, "REVISION"), "ascii").trim(); if (packagedRevision !== revision) fail("Client package revision mismatch"); + for (const file of ["LICENSE", "THIRD-PARTY-NOTICES.txt", "app/LICENSE", + "app/dist/client/third-party-licenses.txt", "runtime/node/LICENSE", + "runtime/tunnel/THIRD-PARTY-NOTICES.txt"]) { + if (!existsSync(join(root, file)) || readFileSync(join(root, file)).length === 0) { + fail(`Client package license text is missing: ${file}`); + } + } const node = join(root, "runtime", "node", target.nodeName); const client = join(root, target.clientName); @@ -245,15 +250,62 @@ async function verifyPackage( } run(client, ["--help"], root); run(tunnel, ["--version"], root); + verifyPlatformAssets(root, target); if (target.captureName) { const capture = join(root, "runtime", "native", target.captureName); const probe = JSON.parse(run(capture, ["--probe"], root)); - if (probe?.protocol !== 3) fail("Packaged native capture probe is invalid"); + if (probe?.protocol !== 4) fail("Packaged native capture probe is invalid"); } await verifyLocalPackage(root, target, temporaryRoot); } +function verifyPlatformAssets(root, target) { + if (target.goos === "linux") { + const desktop = join(root, "share", "applications", "screener-client.desktop"); + const icon = join( + root, + "share", + "icons", + "hicolor", + "256x256", + "apps", + "screener-client.png", + ); + if (!existsSync(desktop) || !existsSync(icon)) { + fail("Linux Client icon assets are missing"); + } + if (!readFileSync(desktop, "utf8").includes("Icon=screener-client\n")) { + fail("Linux desktop entry does not name its icon"); + } + if (!readFileSync(icon).subarray(0, 8).equals(Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]))) { + fail("Linux Client icon is not PNG"); + } + } + if (target.goos === "darwin") { + const bundle = join(root, "Screener Client.app"); + const launcher = join(bundle, "Contents", "MacOS", "Launcher"); + const plist = join(bundle, "Contents", "Info.plist"); + const icon = join(bundle, "Contents", "Resources", "screener.icns"); + if (!existsSync(launcher) || !existsSync(plist) || !existsSync(icon)) { + fail("macOS Client app icon assets are missing"); + } + if (!readFileSync(icon).subarray(0, 4).equals(Buffer.from("icns"))) { + fail("macOS Client icon is not ICNS"); + } + const plistText = readFileSync(plist, "utf8"); + if ( + !plistText.includes("NSScreenCaptureUsageDescription") || + !plistText.includes("NSAudioCaptureUsageDescription") + ) { + fail("macOS Client capture usage descriptions are missing"); + } + if (process.platform === "darwin") run(launcher, ["--help"], root); + } +} + if (process.argv.length !== 5) { fail( "Usage: node scripts/package-client-candidate.mjs ", @@ -281,7 +333,7 @@ mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 }); try { const tunnelDownload = join(temporaryRoot, target.tunnelAsset); await download( - `https://github.com/cloudflare/cloudflared/releases/download/${cloudflaredVersion}/${target.tunnelAsset}`, + `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${target.tunnelAsset}`, tunnelDownload, ); if (sha256(tunnelDownload) !== target.tunnelSha256) { diff --git a/scripts/package-licenses.mjs b/scripts/package-licenses.mjs new file mode 100644 index 0000000..455e9fb --- /dev/null +++ b/scripts/package-licenses.mjs @@ -0,0 +1,118 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pinned = JSON.parse(readFileSync(join(root, "licenses", "upstream.json"), "utf8")); +const licenseName = /^(licen[cs]e|copying|notice|copyright|authors)([._-]|$)/i; +const section = (label, body) => `\n===== ${label} =====\n\n${body.trim()}\n`; + +function pinnedNotice(key) { + const entry = pinned[key]; + if (!entry) throw new Error(`Missing pinned license text for ${key}`); + const bytes = readFileSync(join(root, "licenses", entry.file)); + if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) { + throw new Error(`Pinned license text changed for ${key}`); + } + return `Source: ${entry.source}\n\n${bytes.toString("utf8")}`; +} + +function noticesIn(directory) { + return readdirSync(directory, { withFileTypes: true }) + .filter((file) => file.isFile() && licenseName.test(file.name)) + .map((file) => file.name).sort(); +} + +export function writeWebLicenseNotices(repositoryRoot, outputFile) { + const packagePath = join(repositoryRoot, "package.json"); + const pkg = JSON.parse(readFileSync(packagePath, "utf8")); + const lock = JSON.parse(readFileSync(join(repositoryRoot, "package-lock.json"), "utf8")); + const queue = [...Object.keys(pkg.dependencies), "react", "react-dom", "livekit-client", "sdp-transform"] + .map((name) => [name, packagePath]); + const visited = new Set(); + const entries = new Map(); + for (const [name, parent] of queue) { + const paths = createRequire(parent).resolve.paths("screener-license-lookup"); + const directory = paths.map((path) => join(path, name)) + .find((path) => existsSync(join(path, "package.json"))); + if (!directory) throw new Error(`Missing installed runtime dependency ${name}`); + if (visited.has(directory)) continue; + visited.add(directory); + const path = join(directory, "package.json"); + const dependency = JSON.parse(readFileSync(path, "utf8")); + const installed = relative(repositoryRoot, directory).replaceAll("\\", "/"); + if (lock.packages[installed]?.version !== dependency.version) { + throw new Error(`Runtime dependency differs from lockfile: ${name}`); + } + const key = `${dependency.name}@${dependency.version}`; + const files = noticesIn(directory); + const body = files.length + ? files.map((file) => section(file, readFileSync(join(directory, file), "utf8"))).join("") + : pinnedNotice(key); + entries.set(key, section(`${key} (${dependency.license ?? "see license text"})`, body)); + for (const child of Object.keys(dependency.dependencies ?? {})) queue.push([child, path]); + } + mkdirSync(dirname(outputFile), { recursive: true }); + writeFileSync(outputFile, "Third-party software notices\n" + + "Screener's MIT license does not replace the following component licenses.\n" + + [...entries].sort(([a], [b]) => a.localeCompare(b)).map(([, body]) => body).join("")); +} + +export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target, tunnelVersion) { + copyFileSync(join(repositoryRoot, "LICENSE"), join(packageRoot, "LICENSE")); + const nodeVersion = readFileSync(join(repositoryRoot, ".node-version"), "ascii").trim(); + writeFileSync(join(packageRoot, "runtime", "node", "LICENSE"), pinnedNotice(`node@${nodeVersion}`)); + if (tunnelVersion) { + writeFileSync(join(packageRoot, "runtime", "tunnel", "THIRD-PARTY-NOTICES.txt"), + pinnedNotice(`cloudflared@${tunnelVersion}`)); + } + const cwd = join(repositoryRoot, "native", "client"); + const options = { cwd, encoding: "utf8", windowsHide: true, + env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: "0" } }; + const runGo = (args) => execFileSync(goCommand, args, options).trim(); + const goroot = runGo(["env", "GOROOT"]); + const template = '{{if .Module}}{{if not .Module.Main}}[{{printf "%q" .Module.Path}},{{printf "%q" .Module.Version}},{{printf "%q" .Module.Dir}},{{printf "%q" .Dir}}]{{end}}{{end}}'; + const packages = runGo(["list", "-deps", "-f", template, "./cmd/screener-client"]) + .split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + const entries = new Map(); + for (const [name, version, directory, packageDirectory] of packages) { + const key = `${name}@${version}`; + const files = entries.get(key) ?? new Map(); + if (!noticesIn(directory).length) throw new Error(`Missing Go module license: ${key}`); + for (let current = packageDirectory; ; current = dirname(current)) { + for (const file of noticesIn(current)) { + const fullPath = join(current, file); + files.set(relative(directory, fullPath).replaceAll("\\", "/"), readFileSync(fullPath, "utf8")); + } + if (current === directory) break; + if (current === dirname(current)) throw new Error(`Go package leaves module: ${key}`); + } + entries.set(key, files); + } + let text = "Native Client third-party software notices\n" + + "Web and Server dependencies: app/dist/client/third-party-licenses.txt\n" + + "Node runtime: runtime/node/LICENSE\n" + + (tunnelVersion ? "Cloudflared: runtime/tunnel/THIRD-PARTY-NOTICES.txt\n" : "") + + section(runGo(["version"]), readFileSync(join(goroot, "LICENSE"), "utf8")); + if (target.goos === "windows" && + existsSync(join(packageRoot, "runtime", "native", target.captureName))) { + const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "client", + "platform", "windows", "capture", "libvpx-dependencies.json"), "utf8")); + const name = `libvpx@${dependencies.libvpx.version}`; + text += section(name, pinnedNotice(name)); + } + for (const [name, files] of [...entries].sort(([a], [b]) => a.localeCompare(b))) { + for (const [file, body] of [...files].sort(([a], [b]) => a.localeCompare(b))) { + text += section(`${name}/${file}`, body); + } + } + writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 3) throw new Error("Usage: node scripts/package-licenses.mjs "); + writeWebLicenseNotices(root, resolve(process.argv[2])); +} diff --git a/scripts/required-project-paths.txt b/scripts/required-project-paths.txt index 7306b80..93acf34 100644 --- a/scripts/required-project-paths.txt +++ b/scripts/required-project-paths.txt @@ -1,6 +1,7 @@ AGENTS.md CLAUDE.md CONTRIBUTING.md +LICENSE README.md .gitattributes .gitignore @@ -11,7 +12,9 @@ package-lock.json tsconfig.json .codex/config.toml .codex/README.md -.agents/skills/stop-that-shit/SKILL.md +.agents/skills/ponytail/SKILL.md +.agents/skills/ponytail/LICENSE +.agents/skills/ponytail/UPSTREAM.md .githooks/pre-commit .github/pull_request_template.md .github/workflows/repository-hygiene.yml @@ -31,10 +34,13 @@ docs/reference/configuration.md scripts/package-app-release.mjs scripts/release-update.mjs scripts/assemble-client.mjs +scripts/client-icons.mjs scripts/package-client-candidate.mjs scripts/check-client.mjs scripts/client-native-host-gate.ts native/client/cmd/screener-peer-gate/main.go +native/client/cmd/screener-client/screener.ico +native/client/cmd/screener-client/screener_windows_amd64.syso deploy/release-app.sh deploy/check-release.sh docs/adr/0001-p2p-first-media-topology.md diff --git a/src/client/App.tsx b/src/client/App.tsx index 52b875f..ec6f60c 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -27,7 +27,9 @@ import { useCopy } from "./ui/copy"; const appRoute = parseAppRoute(window.location.pathname); const clientLaunchBootstrap = - appRoute.kind === "host" ? takeClientLaunchBootstrap() : null; + appRoute.kind === "host" || appRoute.kind === "viewer" + ? takeClientLaunchBootstrap() + : null; const clientAccessBootstrap = clientLaunchBootstrap?.accessToken ?? null; const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null; const hostPageModule = @@ -72,6 +74,17 @@ function readableError(error: unknown, t: (key: "gate.connectFailed") => string) } export function App() { + const { lang, vis } = useCopy(); + useEffect(() => { + if (appRoute.kind !== "client" && !clientLaunchBootstrap?.launchedByClient) return; + const controller = new AbortController(); + void import("./native/client") + .then(({ notifyNativePresentation }) => + notifyNativePresentation(vis ? "vis" : lang, controller.signal)) + .catch(() => undefined); + return () => controller.abort(); + }, [lang, vis]); + return ( }> @@ -85,7 +98,10 @@ function AppRoute() { } if (appRoute.kind === "viewer" && viewerRoute) { return viewerRoute.viewerGrant ? ( - + ) : ( ); @@ -280,7 +296,12 @@ function SiteAccessGate({ if (access.kind === "ready") { if (surface === "viewer" && viewerRoute) { - return ; + return ( + + ); } if (surface === "join") { return ; diff --git a/src/client/components/living/CaptureSourcePicker.tsx b/src/client/components/living/CaptureSourcePicker.tsx index e4d7fe8..8aed582 100644 --- a/src/client/components/living/CaptureSourcePicker.tsx +++ b/src/client/components/living/CaptureSourcePicker.tsx @@ -1,9 +1,18 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { NativeCaptureTarget } from "../../native/wire"; import { nativeCaptureTargetKey } from "../../native/capture-selection"; import { useCopy } from "../../ui/copy"; import { Glyph } from "../../ui/icons"; +import { ComicTooltip } from "./ComicTooltip"; + +const SOURCE_TABS = ["browser", "window", "display"] as const; +type SourceTab = (typeof SOURCE_TABS)[number]; +const SOURCE_ICONS = { + browser: "globe", + window: "switchSource", + display: "tv", +}; export type NativeSourceList = | { kind: "loading" } @@ -20,16 +29,30 @@ export function CaptureSourcePicker({ onBrowser, onNative, onPreview, + onRefresh, onCancel, + browserAvailable = true, + initialAudio = true, + audioLocked = false, }: { nativeSources: NativeSourceList; onBrowser: () => void; onNative: (target: NativeCaptureTarget, audio: boolean) => void; - onPreview: (target: NativeCaptureTarget) => Promise; + onPreview: ( + target: NativeCaptureTarget, + signal?: AbortSignal, + ) => Promise; + onRefresh: () => void; onCancel: () => void; + browserAvailable?: boolean; + initialAudio?: boolean; + audioLocked?: boolean; }) { const { vis, t } = useCopy(); - const [shareAudio, setShareAudio] = useState(true); + const pickerId = useId(); + const [tab, setTab] = useState("window"); + const [shareAudio, setShareAudio] = useState(initialAudio); + const activeTab = tab === "browser" && !browserAvailable ? "window" : tab; useEffect(() => { const cancelOnEscape = (event: KeyboardEvent) => { @@ -39,17 +62,23 @@ export function CaptureSourcePicker({ return () => window.removeEventListener("keydown", cancelOnEscape); }, [onCancel]); - const noNativeSources = - nativeSources.kind === "unavailable" || - (nativeSources.kind === "ready" && nativeSources.sources.length === 0); - const anyNativeAudio = - nativeSources.kind === "ready" && - (nativeSources.processAudio || nativeSources.systemAudio); + const sources = + nativeSources.kind === "ready" + ? nativeSources.sources.filter( + (target) => target.kind === activeTab || target.kind === "picker", + ) + : []; const supportsAudio = (target: NativeCaptureTarget): boolean => nativeSources.kind === "ready" && (target.kind === "window" ? nativeSources.processAudio : nativeSources.systemAudio); + const anyNativeAudio = sources.some(supportsAudio); + const audioLabel = t( + activeTab === "window" && sources.some((target) => target.kind === "window") + ? "host.sourcePicker.windowAudio" + : "host.sourcePicker.systemAudio", + ); return (
{t("host.sourcePicker.title")} - {t("host.sourcePicker.hint")} )} + - {vis ? ( - - ) : null} - -
- - - {nativeSources.kind === "ready" - ? nativeSources.sources.map((target) => ( - - onNative(target, shareAudio && supportsAudio(target)) - } +
{ + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) + return; + const available = SOURCE_TABS.filter( + (value) => value !== "browser" || browserAvailable, + ); + const index = available.indexOf(activeTab); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? available.length - 1 + : (index + + (event.key === "ArrowRight" ? 1 : -1) + + available.length) % + available.length; + const nextTab = available[next]!; + event.preventDefault(); + setTab(nextTab); + event.currentTarget + .querySelector( + `[data-source-tab="${nextTab}"]`, + ) + ?.focus(); + }} + > + {SOURCE_TABS.map((value) => { + const button = ( + + ); + return vis ? ( + + {button} + + ) : ( + {button} + ); + })}
- {nativeSources.kind === "ready" ? ( -
- - {vis ? null : {t("host.sourcePicker.audio")}} -
- ) : null} +
+
+ {activeTab === "browser" ? ( + + ) : null} - {nativeSources.kind === "loading" ? ( - - - {vis ? null : {t("host.sourcePicker.loading")}} - - ) : noNativeSources && !vis ? ( - - {t("host.sourcePicker.unavailable")} - - ) : null} + {activeTab !== "browser" + ? sources.map((target) => ( + + onNative(target, shareAudio && supportsAudio(target)) + } + /> + )) + : null} +
+ + {activeTab !== "browser" && + nativeSources.kind === "ready" && + sources.length > 0 ? ( +
+ + {vis ? null : {audioLabel}} +
+ ) : null} + + {activeTab === "browser" ? null : nativeSources.kind === "loading" ? ( + + + {vis ? null : {t("host.sourcePicker.loading")}} + + ) : sources.length === 0 ? ( + + + {vis ? null : t("host.sourcePicker.empty")} + + ) : null} +
); @@ -175,30 +286,36 @@ export function CaptureSourcePicker({ function CaptureSourceOption({ target, audio, + disabled, onPreview, onSelect, }: { target: NativeCaptureTarget; audio: boolean; - onPreview: (target: NativeCaptureTarget) => Promise; + disabled: boolean; + onPreview: ( + target: NativeCaptureTarget, + signal?: AbortSignal, + ) => Promise; onSelect: () => void; }) { - const { vis, t } = useCopy(); + const { t } = useCopy(); const buttonRef = useRef(null); - const requestedRef = useRef(false); - const mountedRef = useRef(true); + const previewRequestRef = useRef(null); const [preview, setPreview] = useState(null); const requestPreview = useCallback(() => { - if (requestedRef.current) return; - requestedRef.current = true; - void onPreview(target).then((value) => { - if (mountedRef.current && value) setPreview(value); + if (target.kind === "picker" || previewRequestRef.current) return; + const request = new AbortController(); + previewRequestRef.current = request; + void onPreview(target, request.signal).then((value) => { + if (!request.signal.aborted && value) setPreview(value); }); }, [onPreview, target]); useEffect( () => () => { - mountedRef.current = false; + previewRequestRef.current?.abort(); + previewRequestRef.current = null; }, [], ); @@ -216,40 +333,47 @@ function CaptureSourceOption({ return () => observer.disconnect(); }, [requestPreview]); + const title = + target.kind === "picker" ? t("host.sourcePicker.system") : target.title; + const action = + target.kind === "picker" + ? t("host.sourcePicker.systemAction") + : t( + target.kind === "display" + ? "host.sourcePicker.display" + : "host.sourcePicker.window", + { title }, + ); + return ( diff --git a/src/client/components/living/Comic.tsx b/src/client/components/living/Comic.tsx index 7f7a566..b305d5f 100644 --- a/src/client/components/living/Comic.tsx +++ b/src/client/components/living/Comic.tsx @@ -167,10 +167,20 @@ export function Pawn({ return className ? {inner} : inner; } -/** Mini host TV: body + recessed screen. */ +/** Shared or watched media: the same TV silhouette in every scene. */ export function MiniTv({ x, y, w, h }: { x: number; y: number; w: number; h: number }) { + const antenna = Math.min(5, h * 0.16); + const foot = Math.min(4, h * 0.12); return ( <> + + + + + + + + + {children} + ; +} + /** Crescent moon (host not live / room asleep). Static; safe to transform. */ export function Moon({ x, @@ -473,8 +500,7 @@ ${rmBlock( - - + @@ -534,7 +560,7 @@ ${rmBlock(kills, pins)} - + ) : ( @@ -552,13 +578,12 @@ ${rmBlock(kills, pins)} - + )} - - + @@ -604,8 +629,7 @@ ${rmBlock( - - + @@ -642,8 +666,7 @@ ${rmBlock( `} - - + @@ -723,106 +746,45 @@ ${rmBlock( ); } -/** 7. route-failed: 4 panels, direct and SFU paths both end unavailable. */ +/** 7. route-failed: the viewer cannot reach the same TV; no route history implied. */ function SceneRouteFailed({ theme }: { theme: ComicTheme }) { return ( <> - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + ); @@ -898,7 +860,7 @@ ${rmBlock( `} - + @@ -943,8 +905,7 @@ ${rmBlock( {/* panel 1 (before): picture alive, three healthy sound arcs */} - - + @@ -1233,7 +1194,7 @@ ${rmBlock( - + - + @@ -1293,7 +1254,7 @@ ${rmBlock( - + @@ -1310,7 +1271,7 @@ ${rmBlock( - + diff --git a/src/client/components/living/RouteTree.tsx b/src/client/components/living/RouteTree.tsx index 6004750..9ffd680 100644 --- a/src/client/components/living/RouteTree.tsx +++ b/src/client/components/living/RouteTree.tsx @@ -83,7 +83,7 @@ function topologyVisibleLabel( const peerIdSuffix = peerId?.slice(-6) ?? ""; const withoutRedundantRole = peerIdSuffix && - (label === `👑-${peerIdSuffix}` || label === `👤-${peerIdSuffix}`) + (label === `👑 (${peerIdSuffix})` || label === `👤 (${peerIdSuffix})`) ? peerIdSuffix : label; return compactVisibleLabel(withoutRedundantRole, maximumCodePoints); diff --git a/src/client/components/living/Stage.tsx b/src/client/components/living/Stage.tsx index 1193b4a..51f412e 100644 --- a/src/client/components/living/Stage.tsx +++ b/src/client/components/living/Stage.tsx @@ -88,6 +88,7 @@ export function StageOverlay({ transition, spin, comic, + progress, onActivate, }: { icon: GlyphName; @@ -96,12 +97,14 @@ export function StageOverlay({ transition?: boolean; spin?: boolean; comic?: ComicKind; + progress?: string; onActivate?: () => void; }) { const { vis } = useCopy(); const content = ( <> {vis && comic ? : null} + {transition ? ( ) : ( @@ -109,19 +112,21 @@ export function StageOverlay({ )} - {vis ? null : {message}} + {vis ? progress && {progress} : + {message}} + ); if (onActivate) { return ( - ); } return (
diff --git a/src/client/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx index ae1dad1..b82c3d2 100644 --- a/src/client/components/living/hints/index.tsx +++ b/src/client/components/living/hints/index.tsx @@ -19,12 +19,16 @@ export type Set1Kind = | "hint-pause" | "hint-resume" | "hint-switch-source" - | "hint-reconnect"; + | "hint-reconnect" + | "hint-capture-browser" + | "hint-capture-window" + | "hint-capture-display"; export type Set2Kind = | "hint-copy-code" | "hint-shuffle-code" | "hint-copy-invite" + | "hint-client-link" | "hint-rotate-invite" | "hint-revoke-invite" | "hint-policy-open" @@ -52,7 +56,9 @@ export type Set4Kind = | "hint-theater-exit" | "hint-route-p2p" | "hint-route-sfu" - | "hint-nat-prediction"; + | "hint-nat-prediction" + | "hint-client-local" + | "hint-client-site"; export type HintKind = Set1Kind | Set2Kind | Set3Kind | Set4Kind; @@ -66,6 +72,7 @@ export const HINT_KINDS: readonly HintKind[] = [ "hint-copy-code", "hint-shuffle-code", "hint-copy-invite", + "hint-client-link", "hint-rotate-invite", "hint-revoke-invite", "hint-policy-open", @@ -90,6 +97,11 @@ export const HINT_KINDS: readonly HintKind[] = [ "hint-route-p2p", "hint-route-sfu", "hint-nat-prediction", + "hint-client-local", + "hint-client-site", + "hint-capture-browser", + "hint-capture-window", + "hint-capture-display", ]; export type HintScene = (props: { theme: ComicTheme }) => ReactNode; diff --git a/src/client/components/living/hints/set1.tsx b/src/client/components/living/hints/set1.tsx index 3bda88c..686b1a3 100644 --- a/src/client/components/living/hints/set1.tsx +++ b/src/client/components/living/hints/set1.tsx @@ -3,6 +3,7 @@ // before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls- // prefixed keyframes in an inline - - - - - - - - - - - - + + + + + + + @@ -284,6 +289,43 @@ ${rmBlock( ); +function CaptureHint({ theme, target }: Parameters[0] & { target: "browser" | "window" | "display" }) { + return <> + + + + {target === "browser" ? <> + + + + + + : target === "window" ? <> + + + : <> + + + + + } + + + + + {target === "display" ? + + + + : } + + ; +} + export const SET1_SCENES: Record = { "hint-share-start": SceneShareStart, "hint-share-stop": SceneShareStop, @@ -291,4 +333,7 @@ export const SET1_SCENES: Record = { "hint-resume": SceneResume, "hint-switch-source": SceneSwitchSource, "hint-reconnect": SceneReconnect, + "hint-capture-browser": (props) => , + "hint-capture-window": (props) => , + "hint-capture-display": (props) => , }; diff --git a/src/client/components/living/hints/set2.tsx b/src/client/components/living/hints/set2.tsx index f18783e..89b225e 100644 --- a/src/client/components/living/hints/set2.tsx +++ b/src/client/components/living/hints/set2.tsx @@ -9,6 +9,7 @@ import { FAINT, Frame, INK_STAGE, + LIVE, Pawn, RedX, SKY, @@ -260,6 +261,18 @@ ${rmBlock( ); +// The Client's public-link choice reuses the established invite animation and +// adds one small globe mark so the destination is clear without text. +const HintClientLink: HintScene = ({ theme }) => ( + <> + + + + + + +); + /* ------------------------------------------------------------------ */ /* hint-rotate-invite: a link → the link spun fresh + spark. */ /* ------------------------------------------------------------------ */ @@ -548,6 +561,7 @@ export const SET2_SCENES: Record = { "hint-copy-code": SceneCopyCode, "hint-shuffle-code": SceneShuffleCode, "hint-copy-invite": SceneCopyInvite, + "hint-client-link": HintClientLink, "hint-rotate-invite": SceneRotateInvite, "hint-revoke-invite": SceneRevokeInvite, "hint-policy-open": ScenePolicyOpen, diff --git a/src/client/components/living/hints/set3.tsx b/src/client/components/living/hints/set3.tsx index 05de947..6004a4e 100644 --- a/src/client/components/living/hints/set3.tsx +++ b/src/client/components/living/hints/set3.tsx @@ -8,13 +8,13 @@ import { LINE, LIVE, MINT, + MiniTv, Pawn, SKY, STAR_GOLD, Star, TV_BODY, TV_EDGE, - TV_SCREEN, rmBlock, } from "../Comic"; import type { HintScene, Set3Kind } from "./index"; @@ -44,8 +44,7 @@ ${rmBlock( {/* BEFORE: pawn squints at a soft picture (3 fat faint scanlines) */} - - + @@ -55,8 +54,7 @@ ${rmBlock( - - + @@ -211,13 +209,11 @@ ${rmBlock( - - + {/* AFTER: piece docked flush, happy eyes, LED live, star */} - - + + + {exit ? ( <> - - - - - + + + + + - + ) : ( <> - - - - - + + + + + - + )} @@ -492,6 +496,43 @@ ${rmBlock( ); +// The home mark distinguishes local sharing from a public direct route. +const HintClientLocal: HintScene = ({ theme }) => ( + <> + + + + + + +); + +const HintClientSite: HintScene = ({ theme }) => ( + <> + + + + + + + + + + + + + +); + /* hint-nat-prediction: [one direct path] -> [a small bounded fan of alternate direct paths]. The scene describes extra direct chances without suggesting a media relay. */ @@ -551,4 +592,6 @@ export const SET4_SCENES: Record = { "hint-route-p2p": HintRouteP2p, "hint-route-sfu": HintRouteSfu, "hint-nat-prediction": HintNatPrediction, + "hint-client-local": HintClientLocal, + "hint-client-site": HintClientSite, }; diff --git a/src/client/lib/display-name.ts b/src/client/lib/display-name.ts index f5a2031..92813b8 100644 --- a/src/client/lib/display-name.ts +++ b/src/client/lib/display-name.ts @@ -32,7 +32,7 @@ export function readDisplayName( function suffixedDefaultName(prefix: string, clientId: string): DisplayName { const suffix = clientId.slice(-6); return ( - normalizeDisplayName(suffix ? `${prefix}-${suffix}` : prefix) ?? + normalizeDisplayName(suffix ? `${prefix} (${suffix})` : prefix) ?? (prefix as DisplayName) ); } diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index 113777b..6e09df5 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -9,7 +9,8 @@ import { z } from "zod"; import { createOpaqueId } from "./opaque-id"; const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; -const CLIENT_ACCESS_BOOTSTRAP_PATTERN = /^[A-Za-z0-9_-]{32}$/; +const CLIENT_ACCESS_BOOTSTRAP_PATTERN = /^[\x21-\x7e]{8,128}$/; +const CLIENT_LAUNCH_STORAGE_KEY = "screener:client-launch:v1"; const HOST_ROOM_STORAGE_KEY = "screener:host-room:v1"; const HOST_ROOM_PREFERENCE_STORAGE_KEY = "screener:host-room-preference:v1"; const hostRoomStorageSchema = createRoomResponseSchema.pick({ @@ -97,12 +98,24 @@ export function takeClientLaunchBootstrap(): ClientLaunchBootstrap { ] as const; const present = keys.some((key) => params.has(key)); const accessValue = params.get("client-access"); + const launchedFromFragment = params.get("screener-client") === "1"; + let launchedByClient = launchedFromFragment; + try { + if (launchedFromFragment) { + window.localStorage.setItem(CLIENT_LAUNCH_STORAGE_KEY, "1"); + } else { + launchedByClient = + window.localStorage.getItem(CLIENT_LAUNCH_STORAGE_KEY) === "1"; + } + } catch { + // The launch fragment still enables the current load when storage is blocked. + } const result: ClientLaunchBootstrap = { accessToken: accessValue && CLIENT_ACCESS_BOOTSTRAP_PATTERN.test(accessValue) ? accessValue : null, - launchedByClient: params.get("screener-client") === "1", + launchedByClient, }; if (present) { for (const key of keys) params.delete(key); diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 9a3d3a9..dec8f6a 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -29,6 +29,9 @@ export const en: Record = { "client.launch.site": "Connect to Site", "client.launch.siteHint": "Use an existing Screener Site", "client.launch.siteAddress": "Screener Site address", + "client.launch.localAccess": "Local site password (optional)", + "client.launch.localAccessHint": "Leave blank for open access; fill it to require a password", + "client.launch.localAccessPlaceholder": "Leave blank for open access", "client.launch.go": "Open Screener", "client.launch.starting": "Preparing Screener", "client.launch.error": "Screener could not start; check the setting and retry", @@ -66,18 +69,23 @@ export const en: Record = { "host.idle.hint": "Share your screen and friends can drop in with the room code or invite link.", "host.start": "Start sharing", "host.sourcePicker.title": "Choose what to share", - "host.sourcePicker.hint": "Use the Browser picker, or let Client capture a local screen or window", "host.sourcePicker.browser": "Browser picker", "host.sourcePicker.browserHint": "Screen, window, or Browser tab", + "host.sourcePicker.tab.browser": "Browser", + "host.sourcePicker.tab.window": "Apps / Windows", + "host.sourcePicker.tab.display": "Screens", + "host.sourcePicker.windowAudio": "Application sound", + "host.sourcePicker.systemAudio": "System sound", + "host.sourcePicker.empty": "No sources available", "host.sourcePicker.native": "Client native capture", "host.sourcePicker.nativeAudio": "Client · with sound", - "host.sourcePicker.nativeVideo": "Client · picture only", "host.sourcePicker.window": "Share window: {title}", "host.sourcePicker.display": "Share screen: {title}", - "host.sourcePicker.audio": "Share sound", + "host.sourcePicker.system": "System screen or window picker", + "host.sourcePicker.systemAction": "Choose a screen or window with the system picker", "host.sourcePicker.audioHint": "Application sound for windows; system sound for screens", + "host.sourcePicker.refresh": "Refresh screens and windows", "host.sourcePicker.loading": "Finding local screens and windows", - "host.sourcePicker.unavailable": "Client capture is unavailable; the Browser picker still works", "host.join": "Join a room", "host.starting": "Connecting", "host.cancelStart": "Cancel", @@ -264,6 +272,7 @@ export const en: Record = { "viewer.msg.joining": "Joining the room", "viewer.msg.waitingHost": "Waiting for the host to share", "viewer.msg.preparingP2p": "Establishing P2P", + "viewer.msg.connectionAttempt": "Connecting ({current}/{total})", "viewer.msg.preparingSfu": "Connecting backup route", "viewer.msg.allocating": "Allocating a route", "viewer.msg.receiving": "Receiving video", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index 3bd3b44..1a7ffc5 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -27,6 +27,9 @@ export const zh = { "client.launch.site": "连接站点", "client.launch.siteHint": "使用已有的 Screener 站点", "client.launch.siteAddress": "Screener 站点地址", + "client.launch.localAccess": "本地站点密码(可选)", + "client.launch.localAccessHint": "留空即可开放访问;填写后需要密码", + "client.launch.localAccessPlaceholder": "留空即可开放访问", "client.launch.go": "进入 Screener", "client.launch.starting": "正在准备 Screener", "client.launch.error": "暂时无法启动,请检查设置后重试", @@ -64,18 +67,23 @@ export const zh = { "host.idle.hint": "分享你的屏幕,朋友凭房间号或邀请链接就能进来看。", "host.start": "开始分享", "host.sourcePicker.title": "选择分享来源", - "host.sourcePicker.hint": "使用浏览器选择器,或由 Client 捕获本机屏幕或窗口", "host.sourcePicker.browser": "浏览器选择器", "host.sourcePicker.browserHint": "屏幕、窗口或浏览器标签页", + "host.sourcePicker.tab.browser": "浏览器", + "host.sourcePicker.tab.window": "程序 / 窗口", + "host.sourcePicker.tab.display": "屏幕", + "host.sourcePicker.windowAudio": "应用声音", + "host.sourcePicker.systemAudio": "系统声音", + "host.sourcePicker.empty": "暂无可用来源", "host.sourcePicker.native": "Client 本机捕获", "host.sourcePicker.nativeAudio": "Client · 含声音", - "host.sourcePicker.nativeVideo": "Client · 仅画面", "host.sourcePicker.window": "分享窗口:{title}", "host.sourcePicker.display": "分享屏幕:{title}", - "host.sourcePicker.audio": "分享声音", + "host.sourcePicker.system": "系统屏幕与窗口选择器", + "host.sourcePicker.systemAction": "使用系统选择器选择屏幕或窗口", "host.sourcePicker.audioHint": "窗口分享应用声音,整屏分享系统声音", + "host.sourcePicker.refresh": "刷新屏幕和窗口", "host.sourcePicker.loading": "正在查找本机屏幕和窗口", - "host.sourcePicker.unavailable": "本机捕获暂不可用,仍可使用浏览器选择器", "host.join": "加入房间", "host.starting": "正在连接", "host.cancelStart": "取消", @@ -262,6 +270,7 @@ export const zh = { "viewer.msg.joining": "正在加入房间", "viewer.msg.waitingHost": "等待开始分享", "viewer.msg.preparingP2p": "正在建立 P2P", + "viewer.msg.connectionAttempt": "正在尝试连接({current}/{total})", "viewer.msg.preparingSfu": "正在连接备用线路", "viewer.msg.allocating": "正在分配线路", "viewer.msg.receiving": "正在接收画面", diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index 69bcbf3..3c586d5 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -229,18 +229,20 @@ export async function applyVideoCaptureProfile( track: MediaStreamTrack, profile: QualityProfile, ): Promise { - const capabilities = track.getCapabilities?.(); - if ( - capabilities && - !("width" in capabilities) && - !("height" in capabilities) && - !("frameRate" in capabilities) - ) { + if (!videoTrackOwnsCaptureConstraints(track)) { return; } await track.applyConstraints(captureConstraints(profile)); } +function videoTrackOwnsCaptureConstraints(track: MediaStreamTrack): boolean { + const capabilities = track.getCapabilities?.(); + return !capabilities || + "width" in capabilities || + "height" in capabilities || + "frameRate" in capabilities; +} + export function setMediaPaused(stream: MediaStream, paused: boolean): boolean { const videoTrack = stream.getVideoTracks()[0]; if (!videoTrack) { @@ -256,6 +258,9 @@ function requestedScaleResolutionDownBy( sender: RTCRtpSender, profile: QualityProfile, ): number { + if (sender.track && !videoTrackOwnsCaptureConstraints(sender.track)) { + return 1; + } const source = sender.track && typeof sender.track.getSettings === "function" ? sender.track.getSettings() diff --git a/src/client/native/capture-selection.ts b/src/client/native/capture-selection.ts index e7987ec..c74c972 100644 --- a/src/client/native/capture-selection.ts +++ b/src/client/native/capture-selection.ts @@ -1,4 +1,4 @@ -import type { NativeAdapter, NativeCaptureTarget } from "./wire"; +import type { NativeAdapter, NativeCaptureTarget, NativeVideoCodec } from "./wire"; export interface NativeCapturePath { adapterIndex: number; @@ -7,18 +7,25 @@ export interface NativeCapturePath { export function defaultNativeCapturePath( adapters: NativeAdapter[], + codec: NativeVideoCodec | "auto" = "auto", + softwareVP8 = false, ): NativeCapturePath | null { - for (const adapter of adapters) { - const encoder = adapter.hardwareH264[0]; - if (encoder) { - return { adapterIndex: adapter.index, encoderIndex: encoder.index }; + if (codec !== "vp8") { + for (const adapter of adapters) { + const encoder = adapter.hardwareH264[0]; + if (encoder) { + return { adapterIndex: adapter.index, encoderIndex: encoder.index }; + } } } - return null; + const adapter = adapters[0]; + return codec !== "h264" && softwareVP8 && adapter + ? { adapterIndex: adapter.index, encoderIndex: 0 } + : null; } export function nativeCaptureTargetKey(target: NativeCaptureTarget): string { return target.kind === "window" ? `window:${target.sourceId}:${target.pid}:${target.creationTime}` - : `display:${target.sourceId}`; + : `${target.kind}:${target.sourceId}`; } diff --git a/src/client/native/client.ts b/src/client/native/client.ts index 5f5fab7..36f3a90 100644 --- a/src/client/native/client.ts +++ b/src/client/native/client.ts @@ -1,8 +1,15 @@ import type { z } from "zod"; -import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import type { + IceConfig, + QualitySettings, + SignalPayload, +} from "../../shared/protocol"; import { createOpaqueId } from "../lib/opaque-id"; -import { nativeCaptureTargetKey } from "./capture-selection"; +import { + nativeCaptureTargetKey, + type NativeCapturePath, +} from "./capture-selection"; import { captureOptionsResponseSchema, edgeOfferResponseSchema, @@ -14,14 +21,19 @@ import { NATIVE_CLIENT_PROTOCOL, NATIVE_CLIENT_SUBPROTOCOL, pongResponseSchema, + receiveAnswerResponseSchema, readyResponseSchema, shareStartedResponseSchema, + shareSourceReplacedResponseSchema, + shareUpdatedResponseSchema, sourceListResponseSchema, sourcePreviewResponseSchema, type NativeAdapter, type NativeClientEvent, type NativeHealth, type NativeCaptureTarget, + type NativeIceCandidate, + type NativeVideoCodec, } from "./wire"; const DISCOVERY_TIMEOUT_MS = 400; @@ -31,7 +43,7 @@ interface PendingRequest { schema: z.ZodType; resolve: (value: T) => void; reject: (error: Error) => void; - timer: number; + timer: number | null; } export interface NativeShareInput { @@ -41,6 +53,8 @@ export interface NativeShareInput { adapterIndex: number; encoderIndex: number; edgeCapacity: number; + profile: QualitySettings; + codec: NativeVideoCodec | "auto"; } export async function discoverNativeHealth(): Promise { @@ -75,9 +89,26 @@ export async function discoverNativeHealth(): Promise { return null; } +export async function notifyNativePresentation( + language: "zh" | "en" | "vis", + signal: AbortSignal, +): Promise { + if (signal.aborted) return; + const health = await discoverNativeHealth(); + if (!health || signal.aborted) return; + await fetch(`http://127.0.0.1:${health.port}/presentation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ language }), + signal, + targetAddressSpace: "loopback", + } as RequestInit); +} + export class NativeClient { private readonly pending = new Map(); private readonly listeners = new Set<(event: NativeClientEvent) => void>(); + private readonly closeListeners = new Set<() => void>(); private closed = false; private constructor( @@ -126,6 +157,15 @@ export class NativeClient { return () => this.listeners.delete(listener); } + onClose(listener: () => void): () => void { + if (this.closed) { + listener(); + return () => undefined; + } + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + async ping(): Promise { await this.request("ping", {}, pongResponseSchema); } @@ -160,24 +200,71 @@ export class NativeClient { return response.data ? `data:${response.mime};base64,${response.data}` : null; } - async startShare(input: NativeShareInput): Promise<{ audio: boolean }> { - const response = await this.request("start-share", input, shareStartedResponseSchema); + async startShare( + input: NativeShareInput, + ): Promise<{ audio: boolean; codec: NativeVideoCodec }> { + const response = await this.request( + "start-share", + input, + shareStartedResponseSchema, + input.source.kind === "picker" ? null : REQUEST_TIMEOUT_MS, + ); if (response.shareId !== input.shareId) { throw new Error("Native share identity changed"); } - return { audio: response.audio }; + return { audio: response.audio, codec: response.codec }; + } + + async updateShare( + shareId: string, + profile: QualitySettings, + ): Promise { + const response = await this.request( + "update-share", + { shareId, profile }, + shareUpdatedResponseSchema, + null, + ); + if (response.shareId !== shareId) { + throw new Error("Native share identity changed"); + } + } + + async replaceShareSource( + shareId: string, + source: NativeCaptureTarget, + audio: boolean, + path: NativeCapturePath, + ): Promise { + const response = await this.request( + "replace-share-source", + { + shareId, + source, + audio, + adapterIndex: path.adapterIndex, + encoderIndex: path.encoderIndex, + }, + shareSourceReplacedResponseSchema, + source.kind === "picker" ? null : REQUEST_TIMEOUT_MS, + ); + if (response.shareId !== shareId) { + throw new Error("Native share identity changed"); + } } async prepareEdge( shareId: string, connectionId: string, iceConfig: IceConfig, + sourceConnectionId?: string, ): Promise { const response = await this.request( "prepare-edge", { shareId, connectionId, + ...(sourceConnectionId ? { sourceConnectionId } : {}), iceServers: iceConfig.iceServers.map((server) => ({ urls: Array.isArray(server.urls) ? server.urls : [server.urls], })), @@ -196,10 +283,15 @@ export class NativeClient { async prepareLocalEdge( shareId: string, connectionId: string, + sourceConnectionId?: string, ): Promise { const response = await this.request( "prepare-local-edge", - { shareId, connectionId }, + { + shareId, + connectionId, + ...(sourceConnectionId ? { sourceConnectionId } : {}), + }, edgeOfferResponseSchema, ); if ( @@ -211,6 +303,74 @@ export class NativeClient { return { type: "offer", sdp: response.sdp }; } + async receiveOffer( + shareId: string, + connectionId: string, + offer: RTCSessionDescriptionInit, + iceConfig: IceConfig, + edgeCapacity: number, + ): Promise<{ + answer: { type: "answer"; sdp: string }; + audio: boolean; + codec: NativeVideoCodec; + }> { + if (offer.type !== "offer" || !offer.sdp) { + throw new Error("Native receiver requires an SDP offer"); + } + const response = await this.request( + "receive-offer", + { + shareId, + connectionId, + edgeCapacity, + iceServers: iceConfig.iceServers.map((server) => ({ + urls: Array.isArray(server.urls) ? server.urls : [server.urls], + })), + sdp: offer.sdp, + }, + receiveAnswerResponseSchema, + ); + if ( + response.shareId !== shareId || + response.connectionId !== connectionId + ) { + throw new Error("Native receiver identity changed"); + } + return { + answer: { type: "answer", sdp: response.sdp }, + audio: response.audio, + codec: response.codec, + }; + } + + async addReceiveCandidate( + shareId: string, + connectionId: string, + candidate: NativeIceCandidate | null, + ): Promise { + await this.request( + "receive-candidate", + { shareId, connectionId, candidate }, + nativeAckResponseSchema, + ); + } + + async closeReceiver(shareId: string, connectionId: string): Promise { + await this.request( + "close-receiver", + { shareId, connectionId }, + nativeAckResponseSchema, + ); + } + + async stopReceive(shareId: string): Promise { + await this.request( + "stop-receive", + { shareId }, + nativeAckResponseSchema, + ); + } + async acceptSignal( shareId: string, connectionId: string, @@ -262,6 +422,7 @@ export class NativeClient { this.closed = true; this.rejectPending(); this.listeners.clear(); + this.closeListeners.clear(); if (this.socket.readyState < WebSocket.CLOSING) { this.socket.close(1000, "page closed"); } @@ -271,16 +432,19 @@ export class NativeClient { type: string, fields: object, schema: z.ZodType, + timeoutMs: number | null = REQUEST_TIMEOUT_MS, ): Promise { if (this.closed || this.socket.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("Screener Client is unavailable")); } const id = createOpaqueId(); return new Promise((resolveRequest, rejectRequest) => { - const timer = window.setTimeout(() => { - this.pending.delete(id); - rejectRequest(new Error("Screener Client request timed out")); - }, REQUEST_TIMEOUT_MS); + const timer = timeoutMs === null + ? null + : window.setTimeout(() => { + this.pending.delete(id); + rejectRequest(new Error("Screener Client request timed out")); + }, timeoutMs); this.pending.set(id, { schema, resolve: resolveRequest as (value: unknown) => void, @@ -295,7 +459,7 @@ export class NativeClient { ...fields, })); } catch { - window.clearTimeout(timer); + if (timer !== null) window.clearTimeout(timer); this.pending.delete(id); rejectRequest(new Error("Screener Client request failed")); } @@ -315,7 +479,7 @@ export class NativeClient { const pending = this.pending.get(id); if (!pending) return; this.pending.delete(id); - window.clearTimeout(pending.timer); + if (pending.timer !== null) window.clearTimeout(pending.timer); const parsed = pending.schema.safeParse(value); if (parsed.success) { pending.resolve(parsed.data); @@ -339,6 +503,9 @@ export class NativeClient { this.closed = true; this.rejectPending(); this.listeners.clear(); + const listeners = [...this.closeListeners]; + this.closeListeners.clear(); + for (const listener of listeners) listener(); } private failConnection(): void { @@ -350,7 +517,7 @@ export class NativeClient { private rejectPending(): void { for (const request of this.pending.values()) { - window.clearTimeout(request.timer); + if (request.timer !== null) window.clearTimeout(request.timer); request.reject(new Error("Screener Client disconnected")); } this.pending.clear(); diff --git a/src/client/native/media-bridge.ts b/src/client/native/media-bridge.ts index c0d844c..334b959 100644 --- a/src/client/native/media-bridge.ts +++ b/src/client/native/media-bridge.ts @@ -1,5 +1,11 @@ import type { SignalPayload } from "../../shared/protocol"; import { createOpaqueId } from "../lib/opaque-id"; +import type { ConnectionMetrics } from "../types"; +import { + collectConnectionMetrics, + decodedVideoFrames, + type StatsAccumulator, +} from "../webrtc/stats"; import type { NativeClientEvent } from "./wire"; const BRIDGE_TIMEOUT_MS = 8_000; @@ -9,6 +15,7 @@ export interface NativeMediaBridgeControl { prepareLocalEdge( shareId: string, connectionId: string, + sourceConnectionId?: string, ): Promise; acceptSignal( shareId: string, @@ -36,8 +43,21 @@ export class NativeMediaBridge { private readonly control: NativeMediaBridgeControl, private readonly onFailed: () => void, private readonly expectedAudio = false, + private readonly sourceConnectionId?: string, ) {} + collectMetrics(accumulator: StatsAccumulator): Promise { + return collectConnectionMetrics(this.peer, "receive", accumulator); + } + + async decodedVideoFrames(): Promise { + const track = this.stream.getVideoTracks()[0]; + return decodedVideoFrames( + await this.peer.getStats(), + track ? { trackIdentifier: track.id } : null, + ); + } + async start(): Promise { if (this.disposed || this.unsubscribe) { throw new Error("Native media bridge is unavailable"); @@ -132,6 +152,7 @@ export class NativeMediaBridge { const offer = await this.control.prepareLocalEdge( this.shareId, this.connectionId, + this.sourceConnectionId, ); await this.peer.setRemoteDescription(offer); this.remoteDescriptionSet = true; diff --git a/src/client/native/media-ingress.ts b/src/client/native/media-ingress.ts new file mode 100644 index 0000000..a5bc673 --- /dev/null +++ b/src/client/native/media-ingress.ts @@ -0,0 +1,188 @@ +import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import { createOpaqueId } from "../lib/opaque-id"; +import type { QualityProfile } from "../media/quality"; +import type { PeerSnapshot } from "../types"; +import { HostPeer } from "../webrtc/host-peer"; +import { MAX_ENDPOINT_MEDIA_CHILDREN } from "../webrtc/media-assignment"; +import { manualVideoCodecPreference } from "../webrtc/video-codec"; +import { preferScreenAudioStereo } from "../webrtc/screen-audio-sdp"; +import type { NativeSenderSource } from "./native-sender-peer"; +import type { NativeClientEvent, NativeIceCandidate } from "./wire"; + +const INGRESS_TIMEOUT_MS = 8_000; +const MAX_PENDING_CANDIDATES = 64; +const LOCAL_ICE_CONFIG: IceConfig = { iceServers: [] }; + +export interface NativeMediaIngressControl { + receiveOffer( + shareId: string, + connectionId: string, + offer: RTCSessionDescriptionInit, + iceConfig: IceConfig, + edgeCapacity: number, + ): Promise<{ answer: { type: "answer"; sdp: string }; audio: boolean }>; + addReceiveCandidate( + shareId: string, + connectionId: string, + candidate: NativeIceCandidate | null, + ): Promise; + closeReceiver(shareId: string, connectionId: string): Promise; + onEvent(listener: (event: NativeClientEvent) => void): () => void; +} + +/** One ordinary Browser sender supplies the Client's encoded fanout source. */ +export class NativeMediaIngress { + readonly connectionId = createOpaqueId(); + readonly source: NativeSenderSource = { + connectionId: this.connectionId, + format: () => { + const metrics = this.sender?.getSnapshot().metrics; + return { width: metrics?.frameWidth ?? null, height: metrics?.frameHeight ?? null }; + }, + }; + + private sender: HostPeer | null = null; + private unsubscribe: (() => void) | null = null; + private receiverReady = false; + private ready = false; + private disposed = false; + private audio = false; + private startTimer: number | null = null; + private settleStart: ((error?: Error) => void) | null = null; + private readonly pendingCandidates: Array = []; + + constructor( + readonly shareId: string, + private readonly client: NativeMediaIngressControl, + private readonly onFailed: () => void, + ) {} + + get hasAudio(): boolean { return this.audio; } + + async start(stream: MediaStream, profile: QualityProfile): Promise { + if (this.disposed || this.sender) throw new Error("Native media ingress is unavailable"); + this.audio = stream.getAudioTracks().length > 0; + const started = new Promise((resolve, reject) => { + this.settleStart = (error) => { + this.clearStart(); + if (error) reject(error); + else resolve(); + }; + this.startTimer = window.setTimeout(() => this.fail(), INGRESS_TIMEOUT_MS); + }); + try { + this.unsubscribe = this.client.onEvent((event) => this.onEvent(event)); + this.sender = new HostPeer( + this.connectionId, LOCAL_ICE_CONFIG, stream, profile, + { + sendSignal: (_peerId, payload) => { + if (this.disposed) return false; + void this.sendSignal(payload).catch(() => this.fail()); + return true; + }, + onUpdate: (snapshot) => this.onUpdate(snapshot), + }, + manualVideoCodecPreference("h264"), this.connectionId, + ); + void this.sender.start().then((sent) => { + if (!sent) this.fail(); + }).catch(() => this.fail()); + } catch { + this.fail(); + } + try { + await started; + } catch (error) { + this.dispose(); + throw error; + } + } + + updateProfile(profile: QualityProfile): Promise { + return this.sender?.updateCaptureProfile(profile) ?? Promise.resolve(false); + } + + setPaused(paused: boolean): void { this.sender?.setPaused(paused); } + + replaceStream(stream: MediaStream): Promise { + if ((stream.getAudioTracks().length > 0) !== this.audio) return Promise.resolve(false); + return this.sender?.replaceStream(stream) ?? Promise.resolve(false); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.settleStart?.(new Error("Native media ingress was closed")); + this.clearStart(); + this.unsubscribe?.(); + this.unsubscribe = null; + this.pendingCandidates.length = 0; + this.sender?.dispose(); + void this.client.closeReceiver(this.shareId, this.connectionId).catch(() => undefined); + } + + private async sendSignal(payload: SignalPayload): Promise { + if (payload.kind === "candidate") { + if (!this.receiverReady) { + if (this.pendingCandidates.length >= MAX_PENDING_CANDIDATES) throw new Error("Native candidate queue is full"); + this.pendingCandidates.push(payload.candidate); + } else { + await this.client.addReceiveCandidate(this.shareId, this.connectionId, payload.candidate); + } + return; + } + const result = await this.client.receiveOffer( + this.shareId, this.connectionId, payload.description, + LOCAL_ICE_CONFIG, MAX_ENDPOINT_MEDIA_CHILDREN, + ); + if (this.disposed) { + await this.client.closeReceiver(this.shareId, this.connectionId); + return; + } + this.receiverReady = true; + await this.sender?.acceptSignal({ + kind: "description", connectionId: this.connectionId, + description: { type: "answer", sdp: preferScreenAudioStereo(result.answer).sdp! }, + }); + for (const candidate of this.pendingCandidates.splice(0)) { + if (this.disposed) return; + await this.client.addReceiveCandidate(this.shareId, this.connectionId, candidate); + } + } + + private onEvent(event: NativeClientEvent): void { + if (this.disposed || event.shareId !== this.shareId || + !("connectionId" in event) || event.connectionId !== this.connectionId) return; + if (event.type === "edge-candidate") { + void this.sender?.acceptSignal({ + kind: "candidate", connectionId: this.connectionId, candidate: event.candidate, + }).catch(() => this.fail()); + } else if (event.type === "edge-state" && (event.state === "failed" || event.state === "closed")) { + this.fail(); + } + } + + private onUpdate(snapshot: PeerSnapshot): void { + if (this.disposed) return; + if (snapshot.connectionState === "failed" || snapshot.connectionState === "closed" || + (!this.ready && snapshot.error)) { + this.fail(); + } else if (!this.ready && snapshot.connectionState === "connected") { + this.ready = true; + this.settleStart?.(); + } + } + + private fail(): void { + if (this.disposed) return; + const notify = this.ready; + this.dispose(); + if (notify) this.onFailed(); + } + + private clearStart(): void { + if (this.startTimer !== null) window.clearTimeout(this.startTimer); + this.startTimer = null; + this.settleStart = null; + } +} diff --git a/src/client/native/host-edge.ts b/src/client/native/native-sender-edge.ts similarity index 61% rename from src/client/native/host-edge.ts rename to src/client/native/native-sender-edge.ts index 12c530f..8a1a363 100644 --- a/src/client/native/host-edge.ts +++ b/src/client/native/native-sender-edge.ts @@ -1,4 +1,11 @@ import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import { + iceServersWithNatPrediction, + isNativeNatSurveyCandidate, + NatPredictionCandidateBatch, + natPredictionSurveyUrls, + type SignalCandidate, +} from "../webrtc/nat-prediction"; import type { NativeClientEvent } from "./wire"; const MAX_PENDING_CANDIDATES = 64; @@ -8,6 +15,7 @@ export interface NativeEdgeControl { shareId: string, connectionId: string, iceConfig: IceConfig, + sourceConnectionId?: string, ): Promise; acceptSignal( shareId: string, @@ -18,17 +26,23 @@ export interface NativeEdgeControl { onEvent(listener: (event: NativeClientEvent) => void): () => void; } -interface NativeHostEdgeEvents { +interface NativeSenderEdgeEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; onState: (state: RTCPeerConnectionState) => void; - onPath?: (local: RTCIceCandidateType, remote: RTCIceCandidateType) => void; + onPath?: ( + local: RTCIceCandidateType, + remote: RTCIceCandidateType, + natTraversalPath: "unknown" | "ordinary" | "predicted", + ) => void; onQuality?: ( quality: Extract, ) => void; } -export class NativeHostEdge { +export class NativeSenderEdge { private readonly pendingCandidates: SignalPayload[] = []; + private readonly localIceCandidates: NatPredictionCandidateBatch | null; + private readonly iceConfig: IceConfig; private unsubscribe: (() => void) | null = null; private offerSent = false; private disposed = false; @@ -38,10 +52,32 @@ export class NativeHostEdge { readonly peerId: string, readonly connectionId: string, private readonly shareId: string, - private readonly iceConfig: IceConfig, + iceConfig: IceConfig, + natPredictionEnabled: boolean, private readonly control: NativeEdgeControl, - private readonly events: NativeHostEdgeEvents, - ) {} + private readonly events: NativeSenderEdgeEvents, + private readonly sourceConnectionId?: string, + ) { + const predictionEnabled = + natPredictionEnabled && + natPredictionSurveyUrls( + iceConfig.iceServers, + iceConfig.natPredictionStunUrls, + ).size > 0; + this.iceConfig = { + ...iceConfig, + iceServers: iceServersWithNatPrediction( + iceConfig.iceServers, + predictionEnabled, + iceConfig.natPredictionStunUrls, + ), + }; + this.localIceCandidates = predictionEnabled + ? new NatPredictionCandidateBatch((candidate) => + this.sendCandidate(candidate), + ) + : null; + } async start(): Promise { if (this.disposed || this.unsubscribe) return false; @@ -51,6 +87,7 @@ export class NativeHostEdge { this.shareId, this.connectionId, this.iceConfig, + this.sourceConnectionId, ); if ( this.disposed || @@ -97,6 +134,7 @@ export class NativeHostEdge { this.disposed = true; this.connected = false; this.pendingCandidates.length = 0; + this.localIceCandidates?.discard(); this.unsubscribe?.(); this.unsubscribe = null; void this.control.closeEdge(this.shareId, this.connectionId).catch( @@ -118,23 +156,17 @@ export class NativeHostEdge { return; } if (event.type === "edge-candidate") { - const payload: SignalPayload = { - kind: "candidate", - connectionId: this.connectionId, - candidate: event.candidate, - }; - if (!this.offerSent) { - if (this.pendingCandidates.length >= MAX_PENDING_CANDIDATES) { - this.dispose(); - this.events.onState("failed"); - return; + if (this.localIceCandidates) { + if (event.candidate) { + this.localIceCandidates.add( + event.candidate, + isNativeNatSurveyCandidate(event.candidate), + ); + } else { + this.localIceCandidates.complete(); } - this.pendingCandidates.push(payload); - return; - } - if (!this.events.sendSignal(this.peerId, payload)) { - this.dispose(); - this.events.onState("failed"); + } else { + this.sendCandidate(event.candidate); } return; } @@ -144,11 +176,36 @@ export class NativeHostEdge { return; } if (event.type === "edge-path") { - this.events.onPath?.(event.localType, event.remoteType); + this.events.onPath?.( + event.localType, + event.remoteType, + event.natTraversalPath, + ); return; } if (event.type === "edge-quality") { this.events.onQuality?.(event); } } + + private sendCandidate(candidate: SignalCandidate): void { + const payload: SignalPayload = { + kind: "candidate", + connectionId: this.connectionId, + candidate, + }; + if (!this.offerSent) { + if (this.pendingCandidates.length >= MAX_PENDING_CANDIDATES) { + this.dispose(); + this.events.onState("failed"); + return; + } + this.pendingCandidates.push(payload); + return; + } + if (!this.events.sendSignal(this.peerId, payload)) { + this.dispose(); + this.events.onState("failed"); + } + } } diff --git a/src/client/native/native-host-peer.ts b/src/client/native/native-sender-peer.ts similarity index 69% rename from src/client/native/native-host-peer.ts rename to src/client/native/native-sender-peer.ts index 59bf983..c0d4244 100644 --- a/src/client/native/native-host-peer.ts +++ b/src/client/native/native-sender-peer.ts @@ -1,16 +1,31 @@ -import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import type { + IceConfig, + PreparedRouteCandidate, + SignalPayload, +} from "../../shared/protocol"; import { EMPTY_METRICS, type PeerSnapshot } from "../types"; import type { HostMediaPeer } from "../webrtc/host-peer"; -import { NativeHostEdge, type NativeEdgeControl } from "./host-edge"; +import { NativeSenderEdge, type NativeEdgeControl } from "./native-sender-edge"; +import type { NativeVideoCodec } from "./wire"; -interface NativeHostPeerEvents { +interface NativeSenderPeerEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; onUpdate: (snapshot: PeerSnapshot) => void; } -export class NativeHostPeer implements HostMediaPeer { +export interface NativeSourceFormat { + width: number | null; + height: number | null; +} + +export interface NativeSenderSource { + connectionId: string; + format?: () => NativeSourceFormat; +} + +export class NativeSenderPeer implements HostMediaPeer { readonly connectionId: string; - private readonly edge: NativeHostEdge; + private readonly edge: NativeSenderEdge; private disposed = false; private state: RTCPeerConnectionState = "new"; private localCandidateType: string | null = null; @@ -22,8 +37,11 @@ export class NativeHostPeer implements HostMediaPeer { connectionId: string, shareId: string, iceConfig: IceConfig, + natPredictionEnabled: boolean, control: NativeEdgeControl, - events: NativeHostPeerEvents, + events: NativeSenderPeerEvents, + codec: NativeVideoCodec, + private readonly source?: NativeSenderSource, ) { this.connectionId = connectionId; this.snapshot = { @@ -38,11 +56,12 @@ export class NativeHostPeer implements HostMediaPeer { qualityWarning: null, qualityWarningKind: null, }; - this.edge = new NativeHostEdge( + this.edge = new NativeSenderEdge( peerId, connectionId, shareId, iceConfig, + natPredictionEnabled, control, { sendSignal: events.sendSignal, @@ -63,7 +82,7 @@ export class NativeHostPeer implements HostMediaPeer { }; events.onUpdate(this.getSnapshot()); }, - onPath: (local, remote) => { + onPath: (local, remote, natTraversalPath) => { this.localCandidateType = local; this.remoteCandidateType = remote; this.snapshot = { @@ -72,13 +91,16 @@ export class NativeHostPeer implements HostMediaPeer { ...this.snapshot.metrics, localCandidateType: local, remoteCandidateType: remote, - natTraversalPath: "ordinary", + natTraversalPath, }, }; events.onUpdate(this.getSnapshot()); }, onQuality: (quality) => { const known = quality.state !== "unknown"; + const format = this.source?.format?.(); + const width = quality.width || format?.width || null; + const height = quality.height || format?.height || null; this.snapshot = { ...this.snapshot, metrics: { @@ -89,8 +111,8 @@ export class NativeHostPeer implements HostMediaPeer { trackIdentifier: quality.trackIdentifier, videoEncodingCount: 1, activeVideoEncodingCount: this.isConnected() ? 1 : 0, - captureWidth: quality.width || null, - captureHeight: quality.height || null, + captureWidth: width, + captureHeight: height, captureFramesPerSecond: quality.framesPerSecond, mediaSourceFramesPerSecond: quality.framesPerSecond, bitrateKbps: quality.bitrateKbps, @@ -98,17 +120,16 @@ export class NativeHostPeer implements HostMediaPeer { ? quality.availableOutgoingKbps : null, framesPerSecond: quality.framesPerSecond, - frameWidth: quality.width || null, - frameHeight: quality.height || null, + frameWidth: width, + frameHeight: height, resolution: - quality.width > 0 && quality.height > 0 - ? `${quality.width}x${quality.height}` + width !== null && height !== null + ? `${width}x${height}` : null, - codec: "video/H264", - codecProfile: "42c01f", - codecParameters: - "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42c01f", - powerEfficientEncoder: true, + codec: `video/${codec.toUpperCase()}`, + codecProfile: null, + codecParameters: null, + powerEfficientEncoder: this.source ? null : codec === "h264", intervalFramesEncoded: quality.intervalFramesEncoded, qualityLimitationReason: quality.reason, nativeEdgeQualityState: quality.state, @@ -117,11 +138,12 @@ export class NativeHostPeer implements HostMediaPeer { events.onUpdate(this.getSnapshot()); }, }, + this.source?.connectionId, ); this.events = events; } - private readonly events: NativeHostPeerEvents; + private readonly events: NativeSenderPeerEvents; start(): Promise { return this.edge.start(); @@ -168,14 +190,13 @@ export class NativeHostPeer implements HostMediaPeer { } updateProfile(_profile: Parameters[0]): Promise { - // The current native generation has a fixed 1280x720 H.264 source. A - // profile change requires a new capture generation, so report unsupported - // instead of claiming that the sender changed. - return Promise.resolve(false); + // The share-level native owner updates the one encoded source before its + // Pion edges. An individual edge cannot own or repeat that operation. + return Promise.resolve(true); } updateCaptureProfile(_profile: Parameters[0]): Promise { - return Promise.resolve(false); + return Promise.resolve(true); } setPaused(_paused: boolean): void { @@ -183,7 +204,9 @@ export class NativeHostPeer implements HostMediaPeer { } replaceStream(_stream: MediaStream): Promise { - return Promise.resolve(false); + // A received native source is independent of its Browser preview stream. + // A capture-backed Host source is replaced by its share-level owner. + return Promise.resolve(this.source !== undefined); } dispose(): void { @@ -192,3 +215,10 @@ export class NativeHostPeer implements HostMediaPeer { this.edge.dispose(); } } + +export function shouldUseBrowserQualityCandidate( + current: HostMediaPeer | undefined, + candidate: PreparedRouteCandidate, +): boolean { + return candidate.qualityProbe && current instanceof NativeSenderPeer; +} diff --git a/src/client/native/native-viewer-peer.ts b/src/client/native/native-viewer-peer.ts new file mode 100644 index 0000000..dce01cf --- /dev/null +++ b/src/client/native/native-viewer-peer.ts @@ -0,0 +1,745 @@ +import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import { parse, parsePayloads } from "sdp-transform"; +import { observeDecodedFrameProof } from "../media/decoded-frame-proof"; +import { EMPTY_METRICS, type PeerSnapshot } from "../types"; +import { + createStatsAccumulator, + type StatsAccumulator, +} from "../webrtc/stats"; +import { + type ViewerMediaPeer, + VIEWER_AUTOMATIC_RECOVERY_TIMEOUT_MS, + VIEWER_INITIAL_CONNECTION_TIMEOUT_MS, + VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS, + VIEWER_MAX_PENDING_CANDIDATES, + ViewerPeer, + type ViewerPeerEvents, + type ViewerPeerOptions, +} from "../webrtc/viewer-peer"; +import { + iceServersWithNatPrediction, + isNativeNatSurveyCandidate, + NatPredictionCandidateBatch, + natPredictionSurveyUrls, + type SignalCandidate, +} from "../webrtc/nat-prediction"; +import { NativeClient } from "./client"; +import { NativeMediaBridge } from "./media-bridge"; +import type { NativeClientEvent, NativeVideoCodec } from "./wire"; + + +export interface NativeViewerSource { + client: NativeClient; + sessionId: string; + connectionId: string; + generation: number; + codec: NativeVideoCodec; +} + +type PeerIceConfig = Pick & { + natPredictionStunUrls?: readonly string[]; +}; + +export class NativeCapableViewerPeer implements ViewerMediaPeer { + private backend: ViewerMediaPeer | null = null; + private readonly pendingCandidates = new Map< + string, + Array> + >(); + private disposed = false; + private identity: { parentPeerId: string; connectionId: string } | null = null; + private signalTail: Promise = Promise.resolve(); + private currentIceConfig: PeerIceConfig; + private forceBrowserNext = false; + + constructor( + iceConfig: PeerIceConfig, + private readonly events: ViewerPeerEvents, + private readonly options: ViewerPeerOptions, + private readonly nativeClient: Promise, + private readonly nativeSessionId: string, + private readonly edgeCapacity: number, + ) { + this.currentIceConfig = iceConfig; + } + + acceptSignal(parentPeerId: string, payload: SignalPayload): Promise { + const work = async () => { + if (this.disposed) return; + if ( + payload.kind === "candidate" && + (!this.backend || + this.identity?.parentPeerId !== parentPeerId || + this.identity.connectionId !== payload.connectionId) + ) { + this.queueCandidate(payload); + return; + } + if (payload.kind === "description" && payload.description.type === "offer") { + const nextIdentity = { + parentPeerId, + connectionId: payload.connectionId, + }; + if ( + !this.identity || + this.identity.parentPeerId !== parentPeerId || + this.identity.connectionId !== payload.connectionId + ) { + this.backend?.dispose(); + this.backend = null; + this.identity = nextIdentity; + } + if (!this.backend) { + const forceBrowser = this.forceBrowserNext; + this.forceBrowserNext = false; + const client = !forceBrowser && offerHasNativeVideoCodec(payload.description.sdp) + ? await this.nativeClient.catch(() => null) + : null; + if (this.disposed) return; + this.backend = client + ? new NativeViewerPeer( + this.currentIceConfig, + this.events, + this.options, + client, + this.nativeSessionId, + this.edgeCapacity, + () => { + this.forceBrowserNext = true; + }, + ) + : this.newBrowserPeer(); + } + await this.backend.acceptSignal(parentPeerId, payload); + const pending = this.pendingCandidates.get(payload.connectionId) ?? []; + this.pendingCandidates.delete(payload.connectionId); + for (const candidate of pending) { + await this.backend.acceptSignal(parentPeerId, candidate); + } + return; + } + await this.backend?.acceptSignal(parentPeerId, payload); + }; + const result = this.signalTail.then(work, work); + this.signalTail = result.catch(() => undefined); + return result; + } + + updateIceConfig(iceConfig: IceConfig): void { + this.currentIceConfig = iceConfig; + this.backend?.updateIceConfig(iceConfig); + } + + requestRecovery(rebuild = false): boolean { + return this.backend?.requestRecovery(rebuild) ?? false; + } + + isRecovering(): boolean { return this.backend?.isRecovering() ?? false; } + hasConnection(): boolean { return this.backend?.hasConnection() ?? this.identity !== null; } + hasConnectionId(connectionId: string): boolean { + return this.backend?.hasConnectionId(connectionId) ?? + this.identity?.connectionId === connectionId; + } + getConnectionIdentity(): { parentPeerId: string; connectionId: string } | null { + return this.backend?.getConnectionIdentity() ?? this.identity; + } + isConnected(): boolean { return this.backend?.isConnected() ?? false; } + get nativeSource(): NativeViewerSource | null { + return this.backend instanceof NativeViewerPeer + ? this.backend.source + : null; + } + stopDecodedFrameProof(): void { this.backend?.stopDecodedFrameProof(); } + activatePreparedRoute(): void { this.backend?.activatePreparedRoute(); } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.backend?.dispose(); + this.backend = null; + this.identity = null; + this.pendingCandidates.clear(); + } + + private queueCandidate( + payload: Extract, + ): void { + let queue = this.pendingCandidates.get(payload.connectionId); + if (!queue) { + if (this.pendingCandidates.size >= 2) { + const oldest = this.pendingCandidates.keys().next().value; + if (oldest) this.pendingCandidates.delete(oldest); + } + queue = []; + this.pendingCandidates.set(payload.connectionId, queue); + } + if (queue.length < VIEWER_MAX_PENDING_CANDIDATES) queue.push(payload); + } + + private newBrowserPeer(): ViewerPeer { + return new ViewerPeer(this.currentIceConfig, this.events, this.options); + } +} + +class NativeViewerPeer implements ViewerMediaPeer { + private currentIceConfig: PeerIceConfig; + private parentPeerId: string | null = null; + private connectionId: string | null = null; + private bridge: NativeMediaBridge | null = null; + private localCandidates: NatPredictionCandidateBatch | null = null; + private unsubscribe: (() => void) | null = null; + private unsubscribeClose: (() => void) | null = null; + private snapshot: PeerSnapshot | null = null; + private statsAccumulator: StatsAccumulator = createStatsAccumulator(); + private statsTimer: number | null = null; + private statsInFlight = false; + private stopDecodedFrameObserver: (() => void) | null = null; + private disconnectTimer: number | null = null; + private initialConnectionTimer: number | null = null; + private recoveryTimer: number | null = null; + private automaticRecoveryRequests = 0; + private recoveryExhaustedReported = false; + private state: RTCPeerConnectionState = "new"; + private localCandidateType: RTCIceCandidateType | null = null; + private remoteCandidateType: RTCIceCandidateType | null = null; + private natTraversalPath: "unknown" | "ordinary" | "predicted" = "unknown"; + private recoveryOwner: "viewer" | "route"; + private readonly natPredictionEnabled: boolean; + private disposed = false; + private failureNotified = false; + private sourceGeneration = 0; + private sourceReady = false; + private sourceCodec: NativeVideoCodec | null = null; + + get source(): NativeViewerSource | null { + return this.connectionId && this.sourceReady && this.sourceCodec + ? { + client: this.client, + sessionId: this.sessionId, + connectionId: this.connectionId, + generation: this.sourceGeneration, + codec: this.sourceCodec, + } + : null; + } + + constructor( + iceConfig: PeerIceConfig, + private readonly events: ViewerPeerEvents, + options: ViewerPeerOptions, + private readonly client: NativeClient, + private readonly sessionId: string, + private readonly edgeCapacity: number, + private readonly onFailure: () => void, + ) { + this.currentIceConfig = iceConfig; + this.recoveryOwner = options.recoveryOwner ?? "viewer"; + this.natPredictionEnabled = options.natPredictionEnabled ?? false; + this.unsubscribe = client.onEvent((event) => this.onEvent(event)); + this.unsubscribeClose = client.onClose(() => { + this.handleBridgeFailure(this.connectionId ?? ""); + }); + } + + async acceptSignal(parentPeerId: string, payload: SignalPayload): Promise { + if (this.disposed) return; + if (payload.kind === "candidate") { + if ( + this.parentPeerId !== parentPeerId || + this.connectionId !== payload.connectionId + ) { + return; + } + try { + await this.client.addReceiveCandidate( + this.sessionId, + payload.connectionId, + payload.candidate, + ); + } catch { + this.handleBridgeFailure(payload.connectionId); + } + return; + } + if (payload.description.type !== "offer") return; + try { + await this.acceptOffer(parentPeerId, payload); + } catch { + this.handleBridgeFailure(payload.connectionId); + } + } + + updateIceConfig(iceConfig: IceConfig): void { + this.currentIceConfig = iceConfig; + } + + requestRecovery(rebuild = false): boolean { + if ( + this.recoveryOwner !== "viewer" || + this.recoveryTimer !== null || + !this.connectionId || + !this.parentPeerId + ) { + return false; + } + const sent = this.events.sendRestartRequest( + this.parentPeerId, + this.connectionId, + rebuild, + ); + if (sent) { + this.clearInitialConnectionTimer(); + this.automaticRecoveryRequests = Math.max( + this.automaticRecoveryRequests, + rebuild ? VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS : 1, + ); + this.scheduleRecoveryDeadline(); + } + return sent; + } + + isRecovering(): boolean { return this.recoveryTimer !== null; } + hasConnection(): boolean { return this.connectionId !== null; } + hasConnectionId(connectionId: string): boolean { + return this.connectionId === connectionId; + } + getConnectionIdentity(): { parentPeerId: string; connectionId: string } | null { + return this.parentPeerId && this.connectionId + ? { parentPeerId: this.parentPeerId, connectionId: this.connectionId } + : null; + } + isConnected(): boolean { return this.state === "connected"; } + stopDecodedFrameProof(): void { + this.stopDecodedFrameObserver?.(); + this.stopDecodedFrameObserver = null; + } + activatePreparedRoute(): void { + if (this.recoveryOwner === "viewer") return; + this.recoveryOwner = "viewer"; + this.handleConnectionState(this.state); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.clearTimers(); + this.disposeBridge(); + this.localCandidates?.discard(); + this.localCandidates = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.unsubscribeClose?.(); + this.unsubscribeClose = null; + const connectionId = this.connectionId; + this.connectionId = null; + this.parentPeerId = null; + this.sourceReady = false; + this.sourceCodec = null; + if (connectionId) { + void this.client.closeReceiver(this.sessionId, connectionId).catch( + () => undefined, + ); + } + } + + private async acceptOffer( + parentPeerId: string, + payload: Extract, + ): Promise { + const connectionId = payload.connectionId; + const identityChanged = + this.parentPeerId !== parentPeerId || this.connectionId !== connectionId; + if (identityChanged) { + const previous = this.connectionId; + this.disposeBridge(); + this.localCandidates?.discard(); + if (previous) { + await this.client.closeReceiver(this.sessionId, previous).catch( + () => undefined, + ); + } + this.parentPeerId = parentPeerId; + this.connectionId = connectionId; + this.resetAutomaticRecovery(); + } else { + this.disposeBridge(); + this.localCandidates?.discard(); + } + this.state = "connecting"; + this.failureNotified = false; + this.sourceGeneration += 1; + this.sourceReady = false; + this.sourceCodec = null; + this.statsAccumulator = createStatsAccumulator(); + this.snapshot = { + peerId: parentPeerId, + connectionId, + connectionState: "connecting", + iceConnectionState: "checking", + metrics: { ...EMPTY_METRICS }, + error: null, + }; + const predictionEnabled = + this.natPredictionEnabled && + natPredictionSurveyUrls( + this.currentIceConfig.iceServers, + this.currentIceConfig.natPredictionStunUrls, + ).size > 0; + const nativeIceConfig: IceConfig = { + natPredictionStunUrls: this.currentIceConfig.natPredictionStunUrls + ? [...this.currentIceConfig.natPredictionStunUrls] + : undefined, + iceServers: iceServersWithNatPrediction( + this.currentIceConfig.iceServers, + predictionEnabled, + this.currentIceConfig.natPredictionStunUrls, + ), + }; + const sendCandidate = (candidate: SignalCandidate) => { + if ( + this.disposed || + this.connectionId !== connectionId || + this.parentPeerId !== parentPeerId + ) { + return; + } + this.events.sendSignal(parentPeerId, { + kind: "candidate", + connectionId, + candidate, + }); + }; + const candidates = predictionEnabled + ? new NatPredictionCandidateBatch(sendCandidate) + : null; + this.localCandidates = candidates; + this.emit(); + const result = await this.client.receiveOffer( + this.sessionId, + connectionId, + payload.description, + nativeIceConfig, + this.edgeCapacity, + ); + if ( + this.disposed || + this.connectionId !== connectionId || + this.parentPeerId !== parentPeerId + ) { + await this.client.closeReceiver(this.sessionId, connectionId).catch( + () => undefined, + ); + return; + } + this.sourceCodec = result.codec; + this.sourceReady = true; + if (!this.events.sendSignal(parentPeerId, { + kind: "description", + connectionId, + description: result.answer, + })) { + throw new Error("Native Viewer answer could not be signaled"); + } + this.scheduleInitialConnectionDeadline(connectionId); + const bridge = new NativeMediaBridge( + this.sessionId, + this.client, + () => this.handleBridgeFailure(connectionId), + result.audio, + connectionId, + ); + this.bridge = bridge; + void bridge.start().then((stream) => { + if (this.disposed || this.bridge !== bridge || this.connectionId !== connectionId) { + bridge.dispose(); + return; + } + this.events.onStream(stream); + this.startObservation(bridge, connectionId); + }).catch(() => this.handleBridgeFailure(connectionId)); + } + + private onEvent(event: NativeClientEvent): void { + if (this.disposed || event.shareId !== this.sessionId) { + return; + } + if (event.type === "share-ended") { + this.handleBridgeFailure(this.connectionId ?? ""); + return; + } + if (!("connectionId" in event) || event.connectionId !== this.connectionId) { + return; + } + if (event.type === "edge-candidate") { + if (this.localCandidates) { + if (event.candidate) { + this.localCandidates.add( + event.candidate, + isNativeNatSurveyCandidate(event.candidate), + ); + } else this.localCandidates.complete(); + } else if (this.parentPeerId && this.connectionId) { + this.events.sendSignal(this.parentPeerId, { + kind: "candidate", + connectionId: this.connectionId, + candidate: event.candidate, + }); + } + return; + } + if (event.type === "edge-path") { + this.localCandidateType = event.localType; + this.remoteCandidateType = event.remoteType; + this.natTraversalPath = event.natTraversalPath; + this.emit(); + return; + } + if (event.type === "edge-state") { + this.state = event.state; + this.handleConnectionState(event.state); + this.emit(); + } + } + + private startObservation(bridge: NativeMediaBridge, connectionId: string): void { + this.stopDecodedFrameProof(); + this.stopDecodedFrameObserver = observeDecodedFrameProof({ + readFramesDecoded: () => bridge.decodedVideoFrames(), + owns: () => + !this.disposed && this.bridge === bridge && this.connectionId === connectionId, + onProof: () => this.events.onFirstDecodedFrame?.(connectionId) ?? true, + }); + this.statsTimer = window.setInterval(() => { + void this.updateStats(bridge, connectionId); + }, 2_000); + void this.updateStats(bridge, connectionId); + } + + private async updateStats(bridge: NativeMediaBridge, connectionId: string): Promise { + if ( + this.disposed || + this.bridge !== bridge || + this.connectionId !== connectionId || + this.statsInFlight + ) { + return; + } + this.statsInFlight = true; + try { + const metrics = await bridge.collectMetrics(this.statsAccumulator); + if (this.bridge !== bridge || this.connectionId !== connectionId || !this.snapshot) return; + this.snapshot = { + ...this.snapshot, + metrics: { + ...metrics, + path: this.localCandidateType && this.remoteCandidateType ? "direct" : metrics.path, + natTraversalPath: this.natTraversalPath, + selectedCandidatePairId: null, + iceProtocol: + this.localCandidateType && this.remoteCandidateType ? "udp" : null, + localCandidateType: this.localCandidateType, + remoteCandidateType: this.remoteCandidateType, + localCandidateAddress: null, + localCandidatePort: null, + remoteCandidateAddress: null, + remoteCandidatePort: null, + rttMs: null, + jitterMs: null, + audioJitterMs: null, + }, + }; + this.emit(); + } catch { + // Metrics are observational and never own the media connection. + } finally { + this.statsInFlight = false; + } + } + + private handleConnectionState(state: RTCPeerConnectionState): void { + if (state === "connected") { + this.clearDisconnectTimer(); + this.clearInitialConnectionTimer(); + this.resetAutomaticRecovery(); + return; + } + if (this.recoveryOwner === "route") { + this.clearDisconnectTimer(); + if (state === "failed") this.reportRecoveryExhausted(); + return; + } + if (state === "failed") { + this.clearDisconnectTimer(); + this.attemptAutomaticRecovery(); + return; + } + if (state === "disconnected" && this.disconnectTimer === null) { + this.disconnectTimer = window.setTimeout(() => { + this.disconnectTimer = null; + if (this.state === "disconnected") this.attemptAutomaticRecovery(); + }, 3_000); + } + } + + private handleBridgeFailure(connectionId: string): void { + if (this.disposed || this.connectionId !== connectionId) return; + this.disposeBridge(); + if (this.failureNotified) return; + this.failureNotified = true; + this.sourceReady = false; + void this.client.closeReceiver(this.sessionId, connectionId).catch( + () => undefined, + ); + if (this.recoveryOwner === "route") { + this.reportRecoveryExhausted(); + return; + } + this.onFailure(); + if (!this.requestRecovery(true)) { + this.reportRecoveryExhausted(); + } + } + + private attemptAutomaticRecovery(): void { + this.clearInitialConnectionTimer(); + if ( + this.disposed || + this.recoveryTimer !== null || + this.recoveryExhaustedReported || + !this.connectionId || + !this.parentPeerId + ) { + return; + } + if ( + this.automaticRecoveryRequests >= + VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS + ) { + this.reportRecoveryExhausted(); + return; + } + const rebuild = this.automaticRecoveryRequests > 0; + if (!this.events.sendRestartRequest(this.parentPeerId, this.connectionId, rebuild)) { + this.reportRecoveryExhausted(); + return; + } + this.automaticRecoveryRequests += 1; + this.scheduleRecoveryDeadline(); + } + + private reportRecoveryExhausted(): void { + if ( + this.recoveryExhaustedReported || + !this.parentPeerId || + !this.connectionId + ) return; + this.clearRecoveryTimer(); + const reported = this.events.onRecoveryExhausted?.( + this.parentPeerId, + this.connectionId, + ) ?? true; + if (reported) this.recoveryExhaustedReported = true; + else this.scheduleRecoveryDeadline(); + } + + private scheduleInitialConnectionDeadline(connectionId: string): void { + this.clearInitialConnectionTimer(); + const timer = window.setTimeout(() => { + if (this.initialConnectionTimer !== timer) return; + this.initialConnectionTimer = null; + if (this.connectionId === connectionId && this.state !== "connected") { + this.attemptAutomaticRecovery(); + } + }, VIEWER_INITIAL_CONNECTION_TIMEOUT_MS); + this.initialConnectionTimer = timer; + } + + private scheduleRecoveryDeadline(): void { + this.clearRecoveryTimer(); + this.recoveryTimer = window.setTimeout(() => { + this.recoveryTimer = null; + if (this.state !== "connected") this.attemptAutomaticRecovery(); + this.emit(); + }, VIEWER_AUTOMATIC_RECOVERY_TIMEOUT_MS); + } + + private resetAutomaticRecovery(): void { + this.clearRecoveryTimer(); + this.automaticRecoveryRequests = 0; + this.recoveryExhaustedReported = false; + } + + private disposeBridge(): void { + this.stopDecodedFrameProof(); + if (this.statsTimer !== null) { + window.clearInterval(this.statsTimer); + this.statsTimer = null; + } + this.statsInFlight = false; + this.bridge?.dispose(); + this.bridge = null; + } + + private emit(): void { + if (!this.snapshot) return; + this.snapshot = { + ...this.snapshot, + connectionState: this.state, + iceConnectionState: + this.state === "connected" + ? "connected" + : this.state === "failed" + ? "failed" + : this.state === "closed" + ? "closed" + : this.state === "disconnected" + ? "disconnected" + : "checking", + error: this.state === "failed" ? "Native media receiver failed" : null, + }; + this.events.onUpdate({ + ...this.snapshot, + metrics: { ...this.snapshot.metrics }, + }); + } + + private clearTimers(): void { + this.clearDisconnectTimer(); + this.clearInitialConnectionTimer(); + this.clearRecoveryTimer(); + } + private clearDisconnectTimer(): void { + if (this.disconnectTimer === null) return; + window.clearTimeout(this.disconnectTimer); + this.disconnectTimer = null; + } + private clearInitialConnectionTimer(): void { + if (this.initialConnectionTimer === null) return; + window.clearTimeout(this.initialConnectionTimer); + this.initialConnectionTimer = null; + } + private clearRecoveryTimer(): void { + if (this.recoveryTimer === null) return; + window.clearTimeout(this.recoveryTimer); + this.recoveryTimer = null; + } + +} + +export function offerHasNativeVideoCodec(sdp: string): boolean { + try { + const session = parse(sdp); + const video = session.media.filter((media) => { + const direction = media.direction ?? session.direction ?? "sendrecv"; + return media.type === "video" && media.port > 0 && + (direction === "sendonly" || direction === "sendrecv"); + }); + if (video.length !== 1) return false; + const media = video[0]!; + const payloads = new Set(parsePayloads(media.payloads ?? "")); + return media.rtp.some((codec) => payloads.has(codec.payload) && + codec.rate === 90_000 && /^(h264|vp8)$/i.test(codec.codec)); + } catch { + return false; + } +} diff --git a/src/client/native/wire.ts b/src/client/native/wire.ts index 72bdaf4..501f24b 100644 --- a/src/client/native/wire.ts +++ b/src/client/native/wire.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -export const NATIVE_CLIENT_PROTOCOL = 5; +export const NATIVE_CLIENT_PROTOCOL = 8; export const NATIVE_CLIENT_PORT_START = 39_721; export const NATIVE_CLIENT_PORT_END = 39_730; -export const NATIVE_CLIENT_SUBPROTOCOL = "screener-client-v5"; +export const NATIVE_CLIENT_SUBPROTOCOL = "screener-client-v8"; const decimalIdentifierSchema = z.string().regex(/^[1-9]\d{0,19}$/); const opaqueIdentifierSchema = z @@ -24,6 +24,7 @@ export const nativeHealthSchema = z processAudio: z.boolean(), systemAudio: z.boolean(), hardwareH264: z.boolean(), + softwareVP8: z.boolean(), }) .strict(), }) @@ -48,9 +49,18 @@ const nativeDisplayTargetSchema = z }) .strict(); +const nativePickerTargetSchema = z + .object({ + kind: z.literal("picker"), + sourceId: decimalIdentifierSchema, + title: z.string().min(1).max(4096), + }) + .strict(); + export const nativeCaptureTargetSchema = z.discriminatedUnion("kind", [ nativeWindowTargetSchema, nativeDisplayTargetSchema, + nativePickerTargetSchema, ]); export type NativeCaptureTarget = z.infer; @@ -86,6 +96,9 @@ const responseBase = { id: opaqueIdentifierSchema, }; +const nativeVideoCodecSchema = z.enum(["h264", "vp8"]); +export type NativeVideoCodec = z.infer; + export const readyResponseSchema = z .object({ ...responseBase, type: z.literal("ready") }) .strict(); @@ -112,7 +125,7 @@ export const sourcePreviewResponseSchema = z type: z.literal("source-preview"), sourceKey: z.string().min(8).max(256), mime: z.literal("image/bmp"), - data: z.string().max(64 * 1024), + data: z.string().max(256 * 1024), }) .strict(); export const shareStartedResponseSchema = z @@ -121,6 +134,21 @@ export const shareStartedResponseSchema = z type: z.literal("share-started"), shareId: opaqueIdentifierSchema, audio: z.boolean(), + codec: nativeVideoCodecSchema, + }) + .strict(); +export const shareUpdatedResponseSchema = z + .object({ + ...responseBase, + type: z.literal("share-updated"), + shareId: opaqueIdentifierSchema, + }) + .strict(); +export const shareSourceReplacedResponseSchema = z + .object({ + ...responseBase, + type: z.literal("share-source-replaced"), + shareId: opaqueIdentifierSchema, }) .strict(); export const edgeOfferResponseSchema = z @@ -132,6 +160,17 @@ export const edgeOfferResponseSchema = z sdp: z.string().min(1).max(48 * 1024), }) .strict(); +export const receiveAnswerResponseSchema = z + .object({ + ...responseBase, + type: z.literal("receive-answer"), + shareId: opaqueIdentifierSchema, + connectionId: opaqueIdentifierSchema, + sdp: z.string().min(1).max(48 * 1024), + audio: z.boolean(), + codec: nativeVideoCodecSchema, + }) + .strict(); export const nativeAckResponseSchema = z .object({ @@ -140,6 +179,9 @@ export const nativeAckResponseSchema = z "edge-answer-accepted", "edge-candidate-accepted", "edge-closed", + "receive-candidate-accepted", + "receiver-closed", + "receive-stopped", "share-stopped", "share-paused", ]), @@ -189,6 +231,7 @@ export const nativeEventSchema = z.discriminatedUnion("type", [ connectionId: opaqueIdentifierSchema, localType: z.enum(["host", "srflx", "prflx", "relay"]), remoteType: z.enum(["host", "srflx", "prflx", "relay"]), + natTraversalPath: z.enum(["unknown", "ordinary", "predicted"]), }) .strict(), z diff --git a/src/client/pages/ClientLauncherPage.tsx b/src/client/pages/ClientLauncherPage.tsx index 0a99270..3e24e26 100644 --- a/src/client/pages/ClientLauncherPage.tsx +++ b/src/client/pages/ClientLauncherPage.tsx @@ -2,8 +2,10 @@ import { useEffect, useState, type FormEvent } from "react"; import { z } from "zod"; import { BrandLoader, BrandMark } from "../components/living/BrandMark"; +import { ComicTooltip } from "../components/living/ComicTooltip"; import { AppHeader } from "../components/living/Header"; import { Btn, Pill } from "../components/living/primitives"; +import type { HintKind } from "../components/living/hints"; import { Glyph, type GlyphName } from "../ui/icons"; import { useCopy, type CopyKey } from "../ui/copy"; import { @@ -16,6 +18,7 @@ type ClientMode = "local" | "link" | "site"; const launcherStateSchema = z .object({ site: z.string(), + localAccessPassword: z.string().max(128), defaultMode: z.enum(["local", "site"]), revision: z.string(), }) @@ -27,33 +30,38 @@ const MODES: Array<{ icon: GlyphName; label: CopyKey; hint: CopyKey; + comic: HintKind; }> = [ { mode: "local", icon: "users", label: "client.launch.local", hint: "client.launch.localHint", + comic: "hint-client-local", }, { mode: "link", icon: "globe", label: "client.launch.link", hint: "client.launch.linkHint", + comic: "hint-client-link", }, { mode: "site", icon: "server", label: "client.launch.site", hint: "client.launch.siteHint", + comic: "hint-client-site", }, ]; export function ClientLauncherPage() { - const { vis, t } = useCopy(); + const { lang, vis, t } = useCopy(); const [loading, setLoading] = useState(true); const [starting, setStarting] = useState(false); const [mode, setMode] = useState("local"); const [site, setSite] = useState(""); + const [localAccessPassword, setLocalAccessPassword] = useState(""); const [error, setError] = useState(false); const [update, setUpdate] = useState(null); @@ -68,6 +76,7 @@ export function ClientLauncherPage() { if (!current) return; setMode(state.defaultMode); setSite(state.site); + setLocalAccessPassword(state.localAccessPassword); setLoading(false); void checkReleaseUpdate(state.revision).then((notice) => { if (current && notice) setUpdate(notice); @@ -92,7 +101,11 @@ export function ClientLauncherPage() { const response = await fetch("/api/client-launcher/launch", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode, ...(mode === "site" ? { site } : {}) }), + body: JSON.stringify({ + mode, + language: vis ? "vis" : lang, + ...(mode === "site" ? { site } : { localAccessPassword }), + }), }); if (!response.ok) throw new Error(); const result = launcherResultSchema.parse(await response.json()); @@ -103,6 +116,26 @@ export function ClientLauncherPage() { } } + const accessField = ( + + ); + return (
@@ -157,29 +190,38 @@ export function ClientLauncherPage() { role="radiogroup" aria-label={t("client.launch.title")} > - {MODES.map((choice) => ( - - ))} + {MODES.map((choice) => { + const button = ( + + ); + return vis ? ( + + {button} + + ) : ( + button + ); + })}
{mode === "site" ? ( @@ -201,6 +243,16 @@ export function ClientLauncherPage() { ) : null} + {mode !== "site" ? ( + vis ? ( + + {accessField} + + ) : ( + accessField + ) + ) : null} + {error ? ( 0, + }; +} + function readableError(error: unknown, action: HostAction): string { return error instanceof ApiError ? error.message @@ -561,13 +577,17 @@ export function HostPage({ const hostSfuRouteRef = useRef(null); const sfuStandbyPrewarmerRef = useRef(null); const nativeClientRef = useRef(null); + const nativeClientConnectRef = useRef | null>(null); const nativeShareGenerationRef = useRef(null); const nativeMediaBridgeRef = useRef(null); + const nativeMediaIngressRef = useRef(null); const nativeEventCleanupRef = useRef<(() => void) | null>(null); + const nativeClientCloseCleanupRef = useRef<(() => void) | null>(null); const nativeModeRef = useRef(false); const nativeSourceRequestRef = useRef(null); - const nativeSourceClientRef = useRef(null); + const nativePreviewTailRef = useRef>(Promise.resolve()); const nativeSourcePathRef = useRef(null); + const nativeShareCleanupRef = useRef>(Promise.resolve()); const mediaViewers = useMemo( () => Array.from(peerSnapshots.values()), @@ -596,7 +616,7 @@ export function HostPage({ [mediaViewers], ); const displayedVideoCodecMode = - videoCodecMode === "auto" && phase === "live" && resolvedVideoCodec + phase === "live" && resolvedVideoCodec ? resolvedVideoCodec : videoCodecMode; @@ -683,10 +703,13 @@ export function HostPage({ hostSfuRouteRef.current = null; sfuStandbyPrewarmerRef.current?.dispose(); nativeSourceRequestRef.current = null; - nativeSourceClientRef.current?.close(); - nativeSourceClientRef.current = null; nativeSourcePathRef.current = null; disposeNativeShare(); + nativeClientCloseCleanupRef.current?.(); + nativeClientCloseCleanupRef.current = null; + nativeClientConnectRef.current = null; + nativeClientRef.current?.close(); + nativeClientRef.current = null; streamRef.current?.getTracks().forEach((track) => track.stop()); retiringStreamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; @@ -1186,6 +1209,8 @@ export function HostPage({ let bridge: NativeMediaBridge | null = null; let shareStarted = false; try { + await nativePreviewTailRef.current; + if (!isCurrentShare(generation, shareGeneration)) return null; const started = await client.startShare({ shareId: shareGeneration, source: target, @@ -1193,8 +1218,11 @@ export function HostPage({ adapterIndex: path.adapterIndex, encoderIndex: path.encoderIndex, edgeCapacity: MAX_ENDPOINT_MEDIA_CHILDREN, + profile: qualitySettingsRef.current, + codec: videoCodecModeRef.current, }); shareStarted = true; + videoCodecRef.current = manualVideoCodecPreference(started.codec); bridge = new NativeMediaBridge( shareGeneration, client, @@ -1210,12 +1238,12 @@ export function HostPage({ ); // Register ownership before waiting for the local bridge. A native edge // may fail immediately after becoming ready. - nativeClientRef.current = client; + ownNativeClient(client); nativeShareGenerationRef.current = shareGeneration; nativeMediaBridgeRef.current = bridge; nativeModeRef.current = true; setNativeActive(true); - nativeEventCleanupRef.current = client.onEvent((event) => { + const nativeEventCleanup = client.onEvent((event) => { if ( event.type === "share-ended" && event.shareId === shareGeneration && @@ -1227,6 +1255,7 @@ export function HostPage({ }); } }); + nativeEventCleanupRef.current = nativeEventCleanup; const stream = await bridge.start(); if (!isCurrentShare(generation, shareGeneration)) { disposeNativeShare(); @@ -1242,18 +1271,63 @@ export function HostPage({ } else { bridge?.dispose(); if (shareStarted) { - await client.stopShare(shareGeneration).catch(() => undefined); + nativeShareCleanupRef.current = client + .stopShare(shareGeneration) + .catch(() => discardNativeClient(client)); + } else { + discardNativeClient(client); } - client.close(); } throw error; } } + function ownNativeClient(client: NativeClient): void { + if (nativeClientRef.current === client) return; + nativeClientCloseCleanupRef.current?.(); + nativeClientRef.current = client; + nativeClientCloseCleanupRef.current = client.onClose(() => { + if (nativeClientRef.current !== client) return; + nativeClientRef.current = null; + nativeClientCloseCleanupRef.current = null; + if (nativeMediaIngressRef.current) { + recoverBrowserFanout(nativeMediaIngressRef.current); + } else if (nativeModeRef.current && activeGenerationRef.current !== null) { + endSharing({ key: "host.shareEnded" }); + } + }); + } + + function discardNativeClient(client: NativeClient): void { + if (nativeClientRef.current === client) { + nativeClientCloseCleanupRef.current?.(); + nativeClientCloseCleanupRef.current = null; + nativeClientRef.current = null; + } + client.close(); + } + + async function acquireNativeClient(): Promise { + await nativeShareCleanupRef.current; + const current = nativeClientRef.current; + if (current) return current; + if (nativeClientConnectRef.current) return nativeClientConnectRef.current; + const connecting = NativeClient.connect().then((client) => { + if (nativeClientConnectRef.current !== connecting) { + client?.close(); + return null; + } + if (client) ownNativeClient(client); + return client; + }).finally(() => { + if (nativeClientConnectRef.current === connecting) nativeClientConnectRef.current = null; + }); + nativeClientConnectRef.current = connecting; + return connecting; + } + function closeCaptureSourcePicker(): void { nativeSourceRequestRef.current = null; - nativeSourceClientRef.current?.close(); - nativeSourceClientRef.current = null; nativeSourcePathRef.current = null; setNativeSources(null); } @@ -1261,22 +1335,19 @@ export function HostPage({ async function openCaptureSourcePicker(): Promise { const request = {}; nativeSourceRequestRef.current = request; - nativeSourceClientRef.current?.close(); - nativeSourceClientRef.current = null; nativeSourcePathRef.current = null; setNativeSources({ kind: "loading" }); - const client = await NativeClient.connect(); + const client = await acquireNativeClient(); if (nativeSourceRequestRef.current !== request) { - client?.close(); return; } if ( !client || !client.health.nativeMedia.video || - !client.health.nativeMedia.hardwareH264 + (!client.health.nativeMedia.hardwareH264 && + !client.health.nativeMedia.softwareVP8) ) { - client?.close(); setNativeSources({ kind: "unavailable" }); return; } @@ -1285,17 +1356,18 @@ export function HostPage({ client.captureOptions(), client.sources(), ]); - const path = defaultNativeCapturePath(adapters); + const path = defaultNativeCapturePath( + adapters, + nativeModeRef.current ? videoCodecRef.current.primary : videoCodecModeRef.current, + client.health.nativeMedia.softwareVP8, + ); if (nativeSourceRequestRef.current !== request) { - client.close(); return; } if (!path) { - client.close(); setNativeSources({ kind: "unavailable" }); return; } - nativeSourceClientRef.current = client; nativeSourcePathRef.current = path; setNativeSources({ kind: "ready", @@ -1304,13 +1376,55 @@ export function HostPage({ systemAudio: client.health.nativeMedia.systemAudio, }); } catch { - client.close(); + if (!nativeModeRef.current) { + discardNativeClient(client); + } if (nativeSourceRequestRef.current === request) { setNativeSources({ kind: "unavailable" }); } } } + async function startBrowserNativeIngress( + generation: number, + shareGeneration: string, + captured: MediaStream, + ): Promise { + if (!launchedByClient || videoCodecRef.current.primary !== "h264" || + !routePolicyRef.current.topologyOptimization) return; + const client = await acquireNativeClient(); + if (!client || !isCurrentShare(generation, shareGeneration)) return; + const ingress = new NativeMediaIngress(shareGeneration, client, () => { + recoverBrowserFanout(ingress); + }); + nativeMediaIngressRef.current = ingress; + nativeShareGenerationRef.current = shareGeneration; + try { + await ingress.start(captured, qualitySettingsRef.current); + if (!isCurrentShare(generation, shareGeneration)) { + ingress.dispose(); + return; + } + } catch { + if (nativeMediaIngressRef.current === ingress) disposeNativeShare(); + } + } + + function recoverBrowserFanout(ingress: NativeMediaIngress): void { + if (nativeMediaIngressRef.current !== ingress) return; + disposeNativeShare(); + discardPreparedHostChild(); + const generation = activeGenerationRef.current; + if (generation === null || !streamRef.current) return; + for (const [peerId, peer] of peersRef.current) { + if (!(peer instanceof NativeSenderPeer)) continue; + removePeer(peerId); + void startPeer(peerId, generation).catch((error: unknown) => { + if (isCurrentGeneration(generation)) setNoticeError(error, "connection"); + }); + } + } + function requestSharing(): void { setJoiningRoom(false); if (!launchedByClient) { @@ -1327,15 +1441,26 @@ export function HostPage({ async function loadNativeSourcePreview( target: NativeCaptureTarget, + signal?: AbortSignal, ): Promise { - const client = nativeSourceClientRef.current; - if (!client || nativeSources?.kind !== "ready") return null; - try { - const preview = await client.sourcePreview(target); - return nativeSourceClientRef.current === client ? preview : null; - } catch { - return null; - } + const client = nativeClientRef.current; + const request = nativeSourceRequestRef.current; + if (!client || !request || nativeSources?.kind !== "ready") return null; + const owns = () => !signal?.aborted && + nativeSourceRequestRef.current === request && + nativeClientRef.current === client; + // Keep thumbnails from filling the same control queue used to start media. + const preview = nativePreviewTailRef.current.then(async () => { + if (!owns()) return null; + try { + const value = await client.sourcePreview(target); + return owns() ? value : null; + } catch { + return null; + } + }); + nativePreviewTailRef.current = preview.then(() => undefined); + return preview; } function startNativeShareFromPicker( @@ -1343,11 +1468,15 @@ export function HostPage({ audio: boolean, ): void { if (nativeSources?.kind !== "ready") return; - const client = nativeSourceClientRef.current; + const client = nativeClientRef.current; const path = nativeSourcePathRef.current; if (!client || !path) return; + if (phase === "live" && nativeModeRef.current) { + closeCaptureSourcePicker(); + void switchNativeSource(client, target, audio, path); + return; + } nativeSourceRequestRef.current = null; - nativeSourceClientRef.current = null; nativeSourcePathRef.current = null; setNativeSources(null); void startSharing({ kind: "native", client, target, audio, path }); @@ -1356,25 +1485,23 @@ export function HostPage({ function disposeNativeShare(): void { const client = nativeClientRef.current; const shareGeneration = nativeShareGenerationRef.current; + const ingress = nativeMediaIngressRef.current; + nativeMediaIngressRef.current = null; nativeMediaBridgeRef.current?.dispose(); nativeMediaBridgeRef.current = null; + ingress?.dispose(); nativeEventCleanupRef.current?.(); nativeEventCleanupRef.current = null; - nativeClientRef.current = null; nativeShareGenerationRef.current = null; nativeModeRef.current = false; setNativeActive(false); - if (!client) { + if (!client || !shareGeneration) { return; } - if (shareGeneration) { - void client - .stopShare(shareGeneration) - .catch(() => undefined) - .finally(() => client.close()); - } else { - client.close(); - } + nativeShareCleanupRef.current = (ingress + ? client.stopReceive(shareGeneration) + : client.stopShare(shareGeneration)) + .catch(() => discardNativeClient(client)); } function finishSourceSwitch(token: object): void { @@ -1433,11 +1560,6 @@ export function HostPage({ async function changeQuality(nextProfile: QualitySettings): Promise { advancedQualityRef.current = nextProfile; setAdvancedQuality(nextProfile); - if (phase === "live" && nativeModeRef.current) { - advancedQualityRef.current = qualitySettingsRef.current; - setAdvancedQuality(qualitySettingsRef.current); - return; - } if (phase !== "live") { if (qualitySettingsEqual(qualitySettingsRef.current, nextProfile)) { return; @@ -1480,9 +1602,30 @@ export function HostPage({ resolveScreenAudioQuality(previousProfile.screenAudioQuality) !== resolveScreenAudioQuality(nextProfile.screenAudioQuality); try { - if (captureChanged) { + const nativeClient = nativeClientRef.current; + const nativeShareGeneration = nativeShareGenerationRef.current; + const nativeUpdate = nativeModeRef.current + ? nativeClient && nativeShareGeneration + ? { client: nativeClient, shareGeneration: nativeShareGeneration } + : null + : undefined; + if (nativeUpdate === null) { + throw new Error("Native share is unavailable"); + } + if (nativeUpdate) { + await nativeUpdate.client.updateShare( + nativeUpdate.shareGeneration, + nextProfile, + ); + } + const appliedProfile = nextProfile; + if (!nativeUpdate && captureChanged) { await applyCaptureProfile(activeStream, nextProfile); } + const ingress = nativeMediaIngressRef.current; + if (ingress && !(await ingress.updateProfile(nextProfile))) { + throw new Error("Native media ingress is unavailable"); + } if ( !isCurrentGeneration(generation) || qualityChangeRef.current !== token || @@ -1491,26 +1634,32 @@ export function HostPage({ return; } - commitQuality(nextProfile); - if (captureChanged) { + commitQuality(appliedProfile); + if (nativeUpdate) { + setDetails(nativeCaptureDetails(appliedProfile, activeStream)); + } else if (captureChanged) { setDetails(captureDetails(activeStream)); } if (peerAssistedRef.current) { - signalRef.current?.setHostQualitySettings(nextProfile); + signalRef.current?.setHostQualitySettings(appliedProfile); } const activeSfuRoute = hostSfuRouteRef.current; const [results, sfuUpdated] = await Promise.all([ Promise.all( [ ...[...peersRef.current.values()].map((peer) => - peer.updateCaptureProfile(nextProfile), + peer.updateCaptureProfile(appliedProfile), ), ...(hostProvisionalChildRef.current - ? [hostProvisionalChildRef.current.updateProfile(nextProfile)] + ? [ + hostProvisionalChildRef.current.updateProfile( + appliedProfile, + ), + ] : []), ], ), - activeSfuRoute?.updateProfile(nextProfile) ?? + activeSfuRoute?.updateProfile(appliedProfile) ?? Promise.resolve(true), ]); if ( @@ -1532,7 +1681,7 @@ export function HostPage({ : videoChanged && audioChanged ? say("host.notice.qualityApplied") : say("host.notice.qualitySet", { - label: qualitySettingsLabel(nextProfile), + label: qualitySettingsLabel(appliedProfile), }); setNotice(connectionWarning ?? (sfuWarning ? null : successNotice)); } @@ -1575,7 +1724,7 @@ export function HostPage({ ) { return; } - if (nativeClient && nativeShareGeneration) { + if (nativeModeRef.current && nativeClient && nativeShareGeneration) { const generation = activeGenerationRef.current; if (generation === null) return; const nextPaused = !sharingPausedRef.current; @@ -1591,6 +1740,10 @@ export function HostPage({ if (activeStream) { setMediaPaused(activeStream, nextPaused); } + for (const peer of peersRef.current.values()) { + peer.setPaused(nextPaused); + } + hostProvisionalChildRef.current?.setPaused(nextPaused); hostSfuRouteRef.current?.setPaused(nextPaused); sharingPausedRef.current = nextPaused; setSharingPaused(nextPaused); @@ -1611,11 +1764,13 @@ export function HostPage({ setNoticeKey("host.pause.noTracksResume"); return; } + nativeMediaIngressRef.current?.setPaused(false); for (const peer of peersRef.current.values()) peer.setPaused(false); hostProvisionalChildRef.current?.setPaused(false); hostSfuRouteRef.current?.setPaused(false); if (signalRef.current?.setSharingPaused(false) !== true) { setMediaPaused(activeStream, true); + nativeMediaIngressRef.current?.setPaused(true); for (const peer of peersRef.current.values()) peer.setPaused(true); hostProvisionalChildRef.current?.setPaused(true); hostSfuRouteRef.current?.setPaused(true); @@ -1632,6 +1787,7 @@ export function HostPage({ setNoticeKey("host.pause.noTracksPause"); return; } + nativeMediaIngressRef.current?.setPaused(true); for (const peer of peersRef.current.values()) peer.setPaused(true); hostProvisionalChildRef.current?.setPaused(true); sharingPausedRef.current = true; @@ -1735,16 +1891,36 @@ export function HostPage({ } }, createPeer: - nativeModeRef.current && nativeClient && nativeShareGeneration - ? (candidate, _input, events) => - new NativeHostPeer( + nativeClient && nativeShareGeneration + ? (candidate, input, events) => { + const current = peersRef.current.get(candidate.childPeerId); + if ( + shouldUseBrowserQualityCandidate(current, candidate) && + input.stream + ) { + return new HostPeer( + candidate.childPeerId, + input.iceConfig, + input.stream, + input.profile, + events, + input.videoCodec, + candidate.connectionId, + input.natPredictionEnabled, + ); + } + return new NativeSenderPeer( candidate.childPeerId, candidate.connectionId, nativeShareGeneration, iceConfig!, + input.natPredictionEnabled, nativeClient, events, - ) + videoCodecRef.current.primary, + nativeMediaIngressRef.current?.source, + ); + } : undefined, }); return hostProvisionalChildRef.current.prepare({ @@ -1804,8 +1980,7 @@ export function HostPage({ const activeStream = streamRef.current; const nativeClient = nativeClientRef.current; const nativeShareGeneration = nativeShareGenerationRef.current; - const useNative = nativeModeRef.current && - nativeClient !== null && nativeShareGeneration !== null; + const useNative = nativeClient !== null && nativeShareGeneration !== null; const iceConfig = iceConfigRef.current; const signal = signalRef.current; if ((!activeStream && !useNative) || !iceConfig || !signal) { @@ -1839,13 +2014,16 @@ export function HostPage({ }, }; peer = useNative - ? new NativeHostPeer( + ? new NativeSenderPeer( peerId, createOpaqueId(), nativeShareGeneration!, iceConfig, + routePolicyRef.current.natPrediction, nativeClient, peerEvents, + videoCodecRef.current.primary, + nativeMediaIngressRef.current?.source, ) : new HostPeer( peerId, @@ -2078,7 +2256,7 @@ export function HostPage({ const activeStream = streamRef.current; const nativeClient = nativeClientRef.current; const nativeShareGeneration = nativeShareGenerationRef.current; - if (nativeClient && nativeShareGeneration) { + if (nativeModeRef.current && nativeClient && nativeShareGeneration) { void nativeClient .setPaused(nativeShareGeneration, true) .catch(() => undefined); @@ -2086,6 +2264,7 @@ export function HostPage({ if (activeStream) { setMediaPaused(activeStream, true); } + nativeMediaIngressRef.current?.setPaused(true); for (const peer of peersRef.current.values()) peer.setPaused(true); hostProvisionalChildRef.current?.setPaused(true); hostSfuRouteRef.current?.setPaused(true); @@ -2282,16 +2461,17 @@ export function HostPage({ streamRef.current = captured; setStream(captured); watchCaptureEnd(captured, generation); - if (nativeStarted) { - setDetails({ - resolution: "1280x720", - frameRate: 30, - hasAudio: captured.getAudioTracks().length > 0, - }); - videoCodecRef.current = manualVideoCodecPreference("h264"); + if (selection.kind === "native") { + setDetails( + nativeCaptureDetails(qualitySettingsRef.current, captured), + ); } else { setDetails(captureDetails(captured)); videoCodecRef.current = await resolveStreamVideoCodec(captured); + if (isCurrentShare(generation, shareGeneration)) { + await startBrowserNativeIngress(generation, shareGeneration, captured); + nativeStarted = nativeMediaIngressRef.current !== null; + } } setResolvedVideoCodec(videoCodecRef.current.primary); } @@ -2500,6 +2680,76 @@ export function HostPage({ } } + async function switchNativeSource( + client: NativeClient, + target: NativeCaptureTarget, + audio: boolean, + path: NativeCapturePath, + ): Promise { + const generation = activeGenerationRef.current; + const shareGeneration = nativeShareGenerationRef.current; + if ( + phase !== "live" || + generation === null || + !shareGeneration || + !isCurrentGeneration(generation) || + nativeClientRef.current !== client || + sourceSwitchRef.current || + qualityChangeRef.current + ) { + return; + } + const token = {}; + sourceSwitchRef.current = token; + setSwitchingSource(true); + setNotice(null); + try { + await nativePreviewTailRef.current; + if ( + !isCurrentGeneration(generation) || + sourceSwitchRef.current !== token || + nativeClientRef.current !== client + ) return; + await client.replaceShareSource( + shareGeneration, + target, + audio, + path, + ); + if ( + !isCurrentGeneration(generation) || + sourceSwitchRef.current !== token || + nativeClientRef.current !== client + ) { + return; + } + invalidateSenderQualityEvidence(); + if (routePolicyRef.current.topologyOptimization) { + signalRef.current?.send({ type: "reset-sender-quality" }); + } + const activeStream = streamRef.current; + if (activeStream) { + setDetails( + nativeCaptureDetails(qualitySettingsRef.current, activeStream), + ); + } + setNotice(sourceSwitchNotice({ + failedPeerCount: 0, + sfuReplaced: true, + sfuWarning: null, + })); + } catch (error) { + if ( + isCurrentGeneration(generation) && + sourceSwitchRef.current === token + ) { + setNoticeError(error, "source"); + } + } finally { + finishSourceSwitch(token); + } + } + async function switchSource(): Promise { const generation = activeGenerationRef.current; if ( @@ -2512,6 +2762,7 @@ export function HostPage({ return; } if (nativeModeRef.current) { + await openCaptureSourcePicker(); return; } @@ -2563,6 +2814,42 @@ export function HostPage({ watchCaptureEnd(captured, generation); try { + const ingress = nativeMediaIngressRef.current; + const reboundPeerIds: string[] = []; + if (ingress) { + try { + if (ingress.hasAudio !== (captured.getAudioTracks().length > 0)) { + const client = nativeClientRef.current; + if (!client) throw new Error("Native media ingress is unavailable"); + const replacement = new NativeMediaIngress(ingress.shareId, client, () => { + recoverBrowserFanout(replacement); + }); + try { + await replacement.start(captured, qualitySettingsRef.current); + if (!isCurrentGeneration(generation) || nativeMediaIngressRef.current !== ingress) { + replacement.dispose(); + return; + } + replacement.setPaused(sharingPausedRef.current); + nativeMediaIngressRef.current = replacement; + } catch (error) { + replacement.dispose(); + throw error; + } + discardPreparedHostChild(); + for (const [peerId, peer] of peersRef.current) { + if (!(peer instanceof NativeSenderPeer)) continue; + removePeer(peerId); + reboundPeerIds.push(peerId); + } + ingress.dispose(); + } else if (!(await ingress.replaceStream(captured))) { + throw new Error("Native media ingress could not replace its source"); + } + } catch { + recoverBrowserFanout(ingress); + } + } const activeSfuRoute = hostSfuRouteRef.current; const provisional = hostProvisionalChildRef.current; const [replacements, , sfuReplaced] = await Promise.all([ @@ -2591,7 +2878,7 @@ export function HostPage({ return; } - const failedPeerIds: string[] = []; + const failedPeerIds: string[] = [...reboundPeerIds]; for (const { peerId, peer, replaced } of replacements) { if (!replaced && peersRef.current.get(peerId) === peer) { removePeer(peerId); @@ -3097,13 +3384,21 @@ export function HostPage({ ) : null} - {!stream && nativeSources ? ( + {nativeSources ? ( 0 + : true + } + audioLocked={nativeActive} /> ) : !stream && (phase === "idle" || phase === "ended" || phase === "error") ? ( @@ -3334,7 +3629,7 @@ export function HostPage({ ) : null} {noticeText && (vis || noticeText !== phaseLine) ? ( @@ -3490,7 +3785,7 @@ export function HostPage({ icon="switchSource" cap={switchingSource ? "host.switching" : "host.switchSource"} title="host.switchSource" - disabled={nativeActive || switchingSource || changingQuality} + disabled={switchingSource || changingQuality} onClick={() => void switchSource()} />, "end", @@ -3934,7 +4229,7 @@ export function HostPage({ }) } aria-label={t(QUALITY_PROFILE_CAPTIONS[id])} - disabled={nativeActive || phase === "starting" || switchingSource} + disabled={phase === "starting" || switchingSource} onClick={() => void changeQuality({ ...QUALITY_PROFILES[id], @@ -4011,7 +4306,7 @@ export function HostPage({ @@ -4038,7 +4333,7 @@ export function HostPage({ max={60} step={5} value={advancedQuality.maxFramerate} - disabled={nativeActive || phase === "starting" || switchingSource} + disabled={phase === "starting" || switchingSource} aria-label={t("host.advanced.framerate")} onChange={(event) => changeAdvancedQuality({ @@ -4064,7 +4359,7 @@ export function HostPage({ max={12000000} step={500000} value={advancedQuality.maxBitrate} - disabled={nativeActive || phase === "starting" || switchingSource} + disabled={phase === "starting" || switchingSource} aria-label={t("host.advanced.bitrate")} onChange={(event) => changeAdvancedQuality({ @@ -4103,7 +4398,7 @@ export function HostPage({ selected={ advancedQuality.degradationPreference === preference } - disabled={nativeActive || phase !== "live" || switchingSource} + disabled={phase === "starting" || switchingSource} title={`${t(PREFERENCE_PRESENTATION[preference].cap)} · ${t(PREFERENCE_PRESENTATION[preference].hint)}`} hint="hint-degrade-pref" onClick={() => @@ -4146,7 +4441,7 @@ export function HostPage({ advancedQuality.screenAudioQuality, ) === audioQuality } - disabled={nativeActive || phase === "starting" || switchingSource} + disabled={phase === "starting" || switchingSource} title={t("host.audio.title", { label: t(AUDIO_QUALITY_CAPTIONS[audioQuality]), kbps: String( diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index b23ced0..48c8732 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -14,6 +14,7 @@ import { type IceConfig, type ParticipantPresenceEntry, type ParticipantRouteAssignment, + type PreparedRouteCandidate, type ServerMessage, type RoutePolicy, } from "../../shared/protocol"; @@ -55,6 +56,7 @@ import { } from "../lib/display-name"; import { useDocumentTitle } from "../ui/document-title"; import { clearViewerGrant, getStableClientId } from "../lib/session"; +import { createOpaqueId } from "../lib/opaque-id"; import { SignalingClient } from "../lib/signaling"; import { labelParticipantSnapshot } from "../lib/viewer-presence"; import { @@ -108,12 +110,26 @@ import { type MediaAssignment, viewerSignalMessage, } from "../webrtc/media-assignment"; +import type { + ViewerMediaPeer, + ViewerPeerEvents, + ViewerPeerOptions, +} from "../webrtc/viewer-peer"; import { ViewerPeer } from "../webrtc/viewer-peer"; -import { ViewerRelay } from "../webrtc/viewer-relay"; +import { + ViewerRelay, + type ViewerRelayPeerFactory, +} from "../webrtc/viewer-relay"; +import { NativeClient } from "../native/client"; +import { + NativeCapableViewerPeer, +} from "../native/native-viewer-peer"; +import { NativeSenderPeer } from "../native/native-sender-peer"; interface ViewerPageProps { roomId: string; viewerGrant?: string; + launchedByClient?: boolean; } type ViewerQualityEvidence = Extract< @@ -127,7 +143,7 @@ interface SfuUpstreamState { interface PendingPeerRoute { revision: number; parentPeerId: string; - peer: ViewerPeer | null; + peer: ViewerMediaPeer | null; decodedFrame: boolean; connectedSent: boolean; readySent: boolean; @@ -258,7 +274,11 @@ function MeterTag({ icon, label }: { icon: GlyphName; label: string }) { ); } -export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { +export function ViewerPage({ + roomId, + viewerGrant, + launchedByClient = false, +}: ViewerPageProps) { const { lang, t, vis, titleFrames } = useCopy(); const viewerClientId = useMemo( () => getStableClientId("viewer", roomId), @@ -280,6 +300,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { revision: number; phase: "prepare" | "active"; upstream: ParticipantRouteAssignment["upstream"]; + connectionAttempt?: PreparedRouteCandidate["connectionAttempt"]; } | null>(null); const [relaySnapshot, setRelaySnapshot] = useState(null); const [relayChildEvidence, setRelayChildEvidence] = @@ -381,7 +402,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } const videoRef = useRef(null); - const peerRef = useRef(null); + const peerRef = useRef(null); const viewerSfuRouteRef = useRef(null); const signalRef = useRef(null); const presentationStateRef = useRef(presentationState); @@ -551,11 +572,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { revision: number, upstream: ParticipantRouteAssignment["upstream"], phase: "prepare" | "active" = "active", + connectionAttempt?: PreparedRouteCandidate["connectionAttempt"], ): void { setAssignedRoute((current) => current && revision < current.revision ? current - : { revision, phase, upstream }, + : { revision, phase, upstream, connectionAttempt }, ); dispatchPresentation({ type: "route", @@ -590,6 +612,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { childPeerIds: [], }; let viewerRelay: ViewerRelay | null = null; + let viewerRelaySourceKey: string | null = null; let viewerSfuRoute: ViewerSfuRoute | null = null; let preparedParentPeerId: string | null = null; let preparedParentSignals: Array< @@ -603,6 +626,32 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { null; let relayChildEvidenceTimer: number | null = null; let sfuTransportConnected = false; + let nativeClientPromise: Promise | null = null; + const nativeViewerSessionId = createOpaqueId(); + + const acquireNativeClient = (): Promise => { + if (!launchedByClient) return Promise.resolve(null); + nativeClientPromise ??= NativeClient.connect(); + return nativeClientPromise; + }; + + function createViewerMediaPeer( + iceConfig: IceConfig, + events: ViewerPeerEvents, + options: ViewerPeerOptions, + ): ViewerMediaPeer { + if (!launchedByClient) { + return new ViewerPeer(iceConfig, events, options); + } + return new NativeCapableViewerPeer( + iceConfig, + events, + options, + acquireNativeClient(), + nativeViewerSessionId, + endpointMediaCopyCapacity, + ); + } let pageSuspended = document.visibilityState !== "visible"; const syncDecodedFrameStallPause = (): void => { @@ -751,7 +800,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } function offerPeerQualityEvidence( snapshot: PeerSnapshot, - peer: ViewerPeer, + peer: ViewerMediaPeer, ): void { const connectionHealthy = snapshot.connectionState === "connected" && !peer.isRecovering(); @@ -844,13 +893,69 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ); } - function ensureViewerRelay(): ViewerRelay | null { - if (viewerRelay) { - return viewerRelay; - } + function ensureViewerRelay( + upstreamKind = currentRouteAssignment?.upstream.kind, + ): ViewerRelay | null { if (!peerAssisted || !currentIceConfig) { return null; } + const source = + upstreamKind === "peer" && + peerRef.current instanceof NativeCapableViewerPeer + ? peerRef.current.nativeSource + : null; + // Wait until the upstream media identity is known. This lets a native + // Viewer choose the encoded relay source before any child edge exists. + if (!remoteMediaRef.current?.stream) { + return null; + } + const sourceKey = source + ? `native:${source.sessionId}:${source.connectionId}:${source.generation}` + : "browser"; + if (viewerRelay && viewerRelaySourceKey === sourceKey) { + return viewerRelay; + } + if (viewerRelay) { + viewerRelay.dispose(); + viewerRelay = null; + viewerRelaySourceKey = null; + } + const peerFactory: ViewerRelayPeerFactory | null = source + ? { + requiresStream: false, + create: (childPeerId, connectionId, peerEvents, candidate) => + candidate?.qualityProbe + ? null + : new NativeSenderPeer( + childPeerId, + connectionId ?? createOpaqueId(), + source.sessionId, + currentIceConfig!, + currentRoutePolicy.natPrediction, + source.client, + peerEvents, + source.codec, + { + connectionId: source.connectionId, + format: () => { + const settings = remoteMediaRef.current?.stream + .getVideoTracks()[0] + ?.getSettings(); + return { + width: + typeof settings?.width === "number" + ? settings.width + : null, + height: + typeof settings?.height === "number" + ? settings.height + : null, + }; + }, + }, + ), + } + : null; viewerRelay = new ViewerRelay( currentIceConfig, currentQualitySettings, @@ -899,7 +1004,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { }, endpointMediaCopyCapacity, currentRoutePolicy.natPrediction, + peerFactory, ); + viewerRelaySourceKey = sourceKey; viewerRelay.setChildren(currentAssignment.childPeerIds); return viewerRelay; } @@ -935,7 +1042,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { function reportActivePeerFailure( parentPeerId: string, connectionId: string, - peer: ViewerPeer, + peer: ViewerMediaPeer, ): boolean { if ( currentRouteAssignment?.upstream.kind !== "peer" || @@ -991,7 +1098,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { function pendingPeerHasDecodedFrame( probe: PendingPeerRoute, - ): probe is PendingPeerRoute & { peer: ViewerPeer; snapshot: PeerSnapshot } { + ): probe is PendingPeerRoute & { peer: ViewerMediaPeer; snapshot: PeerSnapshot } { const { snapshot } = probe; return ( snapshot !== null && @@ -1072,8 +1179,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { }, true, ); - ensureViewerRelay()?.setStream(probe.stream); bindRemoteStream(probe.stream, revision); + ensureViewerRelay("peer")?.setStream(probe.stream); setPeerSnapshot(probe.snapshot); setSfuUpstream(null); return true; @@ -1250,9 +1357,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { endpointMediaCopyCapacity, ); reconcileRelayChildren(previousChildPeerIds); - const relay = ensureViewerRelay(); - relay?.setStream(nextStream); bindRemoteStream(nextStream, revision); + const relay = ensureViewerRelay("sfu"); + relay?.setStream(nextStream); if (initialVideoStream) { sfuTransportConnected = true; } @@ -1357,11 +1464,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { reconcileRelayChildren(previousChildPeerIds); } - function ensurePendingPeerRoute(): ViewerPeer | null { + function ensurePendingPeerRoute(): ViewerMediaPeer | null { const probe = pendingPeer; if (!probe || !currentIceConfig) return null; if (probe.peer) return probe.peer; - const peer: ViewerPeer = new ViewerPeer( + const peer = createViewerMediaPeer( currentIceConfig, { sendSignal: (targetPeerId, payload) => @@ -1485,14 +1592,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { return peer; } - function ensurePeer(): ViewerPeer | null { + function ensurePeer(): ViewerMediaPeer | null { if (peerRef.current) { return peerRef.current; } if (!currentIceConfig) { return null; } - const peer: ViewerPeer = new ViewerPeer( + const peer = createViewerMediaPeer( currentIceConfig, { sendSignal: (targetPeerId, payload) => @@ -1606,6 +1713,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { clearPeerState(); viewerRelay?.dispose(); viewerRelay = null; + viewerRelaySourceKey = null; currentAssignment = { parentPeerId: null, childPeerIds: [] }; } peerAssisted = nextPeerAssisted; @@ -1729,6 +1837,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { message.revision, message.assignment.upstream, "prepare", + message.candidate.connectionAttempt, ); } } @@ -2040,8 +2149,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { peerRef.current = null; viewerRelay?.dispose(); viewerRelay = null; + viewerRelaySourceKey = null; + void nativeClientPromise?.then(async (client) => { + if (!client) return; + await client.stopReceive(nativeViewerSessionId).catch(() => undefined); + client.close(); + }); }; - }, [roomId, viewerGrant, viewerPasswordAttempt]); + }, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient]); useEffect(() => { const video = videoRef.current; @@ -2341,6 +2456,20 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { presentation.stage, assignedRouteKind, ); + const connectionAttempt = presentation.stage === "preparing-p2p" && + assignedRoute?.phase === "prepare" && + assignedRoute.revision === presentationState.revision + ? assignedRoute.connectionAttempt + : undefined; + const stageMessage = connectionAttempt + ? t("viewer.msg.connectionAttempt", { + current: String(connectionAttempt.current), + total: String(connectionAttempt.total), + }) + : t(presentation.messageKey); + const connectionProgress = connectionAttempt + ? `${connectionAttempt.current}/${connectionAttempt.total}` + : undefined; const noticeVisual = presentation.noticeKey ? viewerNoticeVisual(presentation.noticeKey) : null; @@ -2406,7 +2535,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { .lr-tv-screen:has(:focus-visible) { outline: 3px solid var(--action); outline-offset: 2px; } `} } + led={} />

@@ -2453,7 +2582,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { icon={overlayGlyph.icon} transition={overlayGlyph.spin} comic={overlayComic} - message={t(presentation.messageKey)} + message={stageMessage} + progress={connectionProgress} /> )} {presentation.overlay === "status" && @@ -2476,7 +2606,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { dim icon={overlayGlyph.icon} comic={overlayComic} - message={t(presentation.messageKey)} + message={stageMessage} + progress={connectionProgress} spin={overlayGlyph.spin} /> )} diff --git a/src/client/styles.css b/src/client/styles.css index 3d6f69d..51d5177 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -452,6 +452,12 @@ body.lr-theater-open { overflow: hidden; } text-align: center; } .lr-tv-overlay.is-dim { background: rgba(10, 16, 28, .62); } +.lr-tv-status-content { display: flex; flex-direction: column; align-items: center; gap: 14px; } +.lr-tv-overlay.has-comic { gap: 8px; padding: 10px; } +.lr-tv-overlay.has-comic .lr-tv-status-content { flex-direction: row; gap: 8px; } +.lr-tv-overlay.has-comic .lr-brand-loader, +.lr-tv-overlay.has-comic .lr-tv-big { width: 40px; height: 40px; } +.lr-tv-overlay.has-comic .lr-brand-mark { width: 32px; height: 32px; } .lr-tv-big { position: relative; @@ -508,6 +514,7 @@ body.lr-theater-open { overflow: hidden; } } .lr-source-picker-panel { display: grid; + grid-template-rows: auto auto minmax(0, 1fr); width: min(720px, 100%); min-width: 0; min-height: 0; @@ -549,11 +556,11 @@ body.lr-theater-open { overflow: hidden; } color: #9aa8bb; font-size: 11.5px; } +.lr-source-picker-refresh, .lr-source-picker-close { display: grid; width: 38px; height: 38px; - margin-left: auto; flex: 0 0 auto; place-items: center; border: 2px solid rgba(223, 232, 242, .62); @@ -562,55 +569,54 @@ body.lr-theater-open { overflow: hidden; } color: #dfe8f2; transition: transform 140ms var(--spring), background 120ms ease; } +.lr-source-picker-refresh { margin-left: auto; } +.lr-source-picker-refresh:hover, .lr-source-picker-close:hover { background: rgba(255, 255, 255, .1); } +.lr-source-picker-refresh:active, .lr-source-picker-close:active { transform: scale(.92); } -.lr-source-picker-visual-guide { +.lr-source-picker-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + border-bottom: 1px solid rgba(223, 232, 242, .35); +} +.lr-source-picker-tabs > span { min-width: 0; } +.lr-source-picker-tabs button { display: flex; + width: 100%; + min-height: 44px; align-items: center; justify-content: center; - gap: 10px; - min-height: 34px; - color: #dfe8f2; + gap: 7px; + padding: 8px 4px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #9aa8bb; + font-size: 12px; + font-weight: 700; + transition: color 120ms ease, background 120ms ease; } -.lr-source-picker-guide-icon { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 2px solid rgba(223, 232, 242, .58); - border-radius: 50%; - background: rgba(255, 255, 255, .06); -} -.lr-source-picker-guide-icon.is-target { - border-color: rgba(47, 166, 106, .78); +.lr-source-picker-tabs button svg { flex: 0 0 auto; } +.lr-source-picker-tabs button:hover { background: rgba(255, 255, 255, .06); } +.lr-source-picker-tabs button[aria-selected="true"] { + border-bottom-color: #9de8bf; color: #9de8bf; } -.lr-source-picker-guide-flow { +.lr-source-picker-body { display: flex; - width: 58px; - align-items: center; - justify-content: space-between; -} -.lr-source-picker-guide-flow i { - width: 5px; - height: 5px; - border-radius: 50%; - background: #9de8bf; - opacity: .25; - animation: lr-source-flow 2.4s ease-in-out infinite; -} -.lr-source-picker-guide-flow i:nth-child(2) { animation-delay: .22s; } -.lr-source-picker-guide-flow i:nth-child(3) { animation-delay: .44s; } -@keyframes lr-source-flow { - 0%, 100% { opacity: .2; transform: translateX(-5px) scale(.8); } - 45%, 65% { opacity: 1; transform: translateX(5px) scale(1); } + min-height: 0; + flex-direction: column; + gap: 10px; } .lr-source-picker-list { - display: flex; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + grid-auto-rows: max-content; min-width: 0; + min-height: 0; max-height: min(300px, 48vh); overflow: auto; - flex-wrap: wrap; align-content: start; gap: 8px; padding: 2px; @@ -620,7 +626,6 @@ body.lr-theater-open { overflow: hidden; } display: flex; min-width: 0; min-height: 58px; - flex: 1 1 210px; align-items: center; gap: 10px; padding: 9px 12px; @@ -638,6 +643,29 @@ body.lr-theater-open { overflow: hidden; } transform: translateY(-2px); } .lr-source-option:active { transform: scale(.98); } +.lr-source-option:not(.is-browser) { + display: grid; + align-items: stretch; + gap: 6px; + padding: 8px; +} +.lr-source-option:not(.is-browser) .lr-source-option-copy { + display: flex; + min-height: 36px; + align-items: flex-start; + gap: 8px; +} +.lr-source-option:not(.is-browser) .lr-source-option-copy strong { + display: -webkit-box; + flex: 1; + min-width: 0; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; +} +.lr-source-option-copy > span { flex: 0 0 auto; } .lr-source-option-icon { width: 38px; height: 38px; @@ -645,8 +673,9 @@ body.lr-theater-open { overflow: hidden; } background: rgba(255, 255, 255, .1); } .lr-source-option-preview { - width: 104px; - height: 58px; + width: 100%; + aspect-ratio: 16 / 9; + height: auto; overflow: hidden; border-radius: 8px; background: rgba(255, 255, 255, .1); @@ -666,8 +695,7 @@ body.lr-theater-open { overflow: hidden; } font-size: 13px; } .lr-source-picker.is-visual .lr-source-option.is-browser { - min-width: 58px; - flex: 0 0 58px; + min-height: 90px; justify-content: center; padding: 8px; } @@ -682,6 +710,7 @@ body.lr-theater-open { overflow: hidden; } .lr-source-picker-audio { display: flex; min-height: 38px; + flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 9px; @@ -1542,6 +1571,11 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; } grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.lr-client-modes > .lr-comic-tip-wrap { + width: 100%; + min-width: 0; +} +.lr-client-modes > .lr-comic-tip-wrap > .lr-client-mode { width: 100%; } .lr-client-mode { display: grid; min-width: 0; @@ -1568,6 +1602,18 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; } .lr-client-mode strong { font-size: 14px; } .lr-client-mode small { font-size: 11px; opacity: .66; } .lr-client-site { width: min(460px, 100%); } +.lr-client-access { + width: min(460px, 100%); +} +.lr-client-access input { + min-width: 0; +} +.lr-client-launch-panel > .lr-comic-tip-wrap { + width: min(460px, 100%); +} +.lr-client-launch-panel > .lr-comic-tip-wrap > .lr-client-access { + width: 100%; +} .lr-join { flex: 1; @@ -1776,18 +1822,13 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); } .lr-dial { width: 54px; height: 66px; font-size: 28px; } .lr-status-text { width: 100%; } .lr-tv-screen.has-entry { aspect-ratio: auto; min-height: 292px; } + .lr-tv-screen:has(.lr-source-picker) { aspect-ratio: auto; min-height: 440px; } .lr-tv-big { width: 72px; height: 72px; } .lr-source-picker { padding: 12px; } .lr-source-picker-panel { gap: 8px; } .lr-source-picker-mark { width: 36px; height: 36px; } - .lr-source-picker-guide-flow { width: 42px; } - .lr-source-picker-list { max-height: 170px; } - .lr-source-option-preview { width: 80px; height: 45px; } - .lr-source-option { min-height: 50px; flex-basis: 100%; padding: 6px 8px; } - .lr-source-picker.is-visual .lr-source-option.is-browser { - min-width: 50px; - flex-basis: 50px; - } + .lr-source-picker-list { max-height: min(320px, 48vh); } + .lr-source-option { min-height: 78px; padding: 6px 8px; } .lr-source-option-icon { width: 34px; height: 34px; } .lr-leds-label { display: none; } .lr-header-controls .lr-btn .lr-cap { display: none; } diff --git a/src/client/webrtc/nat-prediction.ts b/src/client/webrtc/nat-prediction.ts index 5d29506..b114324 100644 --- a/src/client/webrtc/nat-prediction.ts +++ b/src/client/webrtc/nat-prediction.ts @@ -7,8 +7,13 @@ export type SignalCandidate = Extract< >["candidate"]; type ConcreteSignalCandidate = NonNullable; +export function isNativeNatSurveyCandidate( + candidate: SignalCandidate, +): boolean { + return candidate !== null && /^candidate:ns\d+\s/i.test(candidate.candidate); +} + const BASE_STUN_PORT = 3478; -const AUXILIARY_STUN_PORTS = [3479, 3480] as const; const MIN_PREDICTABLE_PORT = 1; const MAX_PREDICTABLE_PORT = 65_535; @@ -50,41 +55,6 @@ function cloneIceServer(server: RTCIceServer): RTCIceServer { }; } -function auxiliaryUrlsFor(url: string): readonly string[] { - if (!/^stun:/i.test(url)) { - return []; - } - const authority = url.slice(url.indexOf(":") + 1); - let parsed: URL; - try { - parsed = new URL(`http://${authority}`); - } catch { - return []; - } - if ( - !parsed.hostname || - parsed.username || - parsed.password || - parsed.pathname !== "/" || - parsed.search || - parsed.hash - ) { - return []; - } - const port = parsed.port ? Number(parsed.port) : BASE_STUN_PORT; - if (port !== BASE_STUN_PORT) { - return []; - } - const hostname = parsed.hostname.startsWith("[") - ? parsed.hostname - : parsed.hostname.includes(":") - ? `[${parsed.hostname}]` - : parsed.hostname; - return AUXILIARY_STUN_PORTS.map( - (auxiliaryPort) => `stun:${hostname}:${auxiliaryPort}`, - ); -} - function normalizedStunUrl(url: string): string | null { if (!/^stun:/i.test(url)) { return null; @@ -125,22 +95,17 @@ export function natPredictionSurveyUrls( return normalized ? [normalized] : []; }), ); + if (configuredAuxiliaryUrls.size !== 2) { + return new Set(); + } for (const server of iceServers ?? []) { for (const url of urlsOf(server)) { - const auxiliaryUrls = auxiliaryUrlsFor(url); const baseUrl = normalizedStunUrl(url); - const normalizedAuxiliaryUrls = auxiliaryUrls.flatMap((entry) => { - const normalized = normalizedStunUrl(entry); - return normalized ? [normalized] : []; - }); - if ( - baseUrl && - normalizedAuxiliaryUrls.length === AUXILIARY_STUN_PORTS.length && - normalizedAuxiliaryUrls.every((entry) => - configuredAuxiliaryUrls.has(entry), - ) - ) { - return new Set([baseUrl, ...normalizedAuxiliaryUrls]); + if (baseUrl?.endsWith(`:${BASE_STUN_PORT}`)) { + const surveyUrls = new Set([baseUrl, ...configuredAuxiliaryUrls]); + if (surveyUrls.size === 3) { + return surveyUrls; + } } } } @@ -148,8 +113,8 @@ export function natPredictionSurveyUrls( } /** - * Add the two optional same-host survey listeners without changing the - * server-provided STUN list. The caller opts into this per connection. + * Add two optional survey destinations without changing the server-provided + * ordinary STUN list. The caller opts into this per connection. */ export function iceServersWithNatPrediction( iceServers: readonly RTCIceServer[] | undefined, diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index 78c705c..6f22f17 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -19,10 +19,10 @@ import { NatPredictionCandidateEmitter, } from "./nat-prediction"; -const MAX_PENDING_CANDIDATES = 64; -const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2; -const INITIAL_CONNECTION_TIMEOUT_MS = 15_000; -const AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000; +export const VIEWER_MAX_PENDING_CANDIDATES = 64; +export const VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS = 2; +export const VIEWER_INITIAL_CONNECTION_TIMEOUT_MS = 15_000; +export const VIEWER_AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000; type PeerIceConfig = Pick & { natPredictionStunUrls?: readonly string[]; }; @@ -31,7 +31,7 @@ type SignalCandidate = Extract< { kind: "candidate" } >["candidate"]; -interface ViewerPeerEvents { +export interface ViewerPeerEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; sendRestartRequest: ( peerId: string, @@ -47,12 +47,29 @@ interface ViewerPeerEvents { ) => boolean; } -interface ViewerPeerOptions { +export interface ViewerPeerOptions { natPredictionEnabled?: boolean; recoveryOwner?: "viewer" | "route"; } -export class ViewerPeer { +export interface ViewerMediaPeer { + acceptSignal(parentPeerId: string, payload: SignalPayload): Promise; + updateIceConfig(iceConfig: IceConfig): void; + requestRecovery(rebuild?: boolean): boolean; + isRecovering(): boolean; + hasConnection(): boolean; + hasConnectionId(connectionId: string): boolean; + getConnectionIdentity(): { + parentPeerId: string; + connectionId: string; + } | null; + isConnected(): boolean; + stopDecodedFrameProof(): void; + activatePreparedRoute(): void; + dispose(): void; +} + +export class ViewerPeer implements ViewerMediaPeer { private connection: RTCPeerConnection | null = null; private connectionId: string | null = null; private parentPeerId: string | null = null; @@ -161,7 +178,7 @@ export class ViewerPeer { this.clearInitialConnectionTimer(); this.automaticRecoveryRequests = Math.max( this.automaticRecoveryRequests, - rebuild ? MAX_AUTOMATIC_RECOVERY_REQUESTS : 1, + rebuild ? VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS : 1, ); this.scheduleRecoveryDeadline(); } @@ -381,7 +398,7 @@ export class ViewerPeer { this.events.sendRestartRequest(parentPeerId, connectionId, true) ) { this.offerRecoveryAttempts += 1; - this.automaticRecoveryRequests = MAX_AUTOMATIC_RECOVERY_REQUESTS; + this.automaticRecoveryRequests = VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS; this.scheduleRecoveryDeadline(); } else { this.reportRecoveryExhausted(); @@ -454,7 +471,7 @@ export class ViewerPeer { ) { return; } - if (this.automaticRecoveryRequests >= MAX_AUTOMATIC_RECOVERY_REQUESTS) { + if (this.automaticRecoveryRequests >= VIEWER_MAX_AUTOMATIC_RECOVERY_REQUESTS) { this.reportRecoveryExhausted(); return; } @@ -498,7 +515,7 @@ export class ViewerPeer { ) { this.attemptAutomaticRecovery(); } - }, INITIAL_CONNECTION_TIMEOUT_MS); + }, VIEWER_INITIAL_CONNECTION_TIMEOUT_MS); this.initialConnectionTimer = timer; } @@ -510,7 +527,7 @@ export class ViewerPeer { this.attemptAutomaticRecovery(); } this.emit(); - }, AUTOMATIC_RECOVERY_TIMEOUT_MS); + }, VIEWER_AUTOMATIC_RECOVERY_TIMEOUT_MS); } private reportRecoveryExhausted(): void { @@ -557,7 +574,7 @@ export class ViewerPeer { queue = []; this.pendingByConnection.set(connectionId, queue); } - if (queue.length < MAX_PENDING_CANDIDATES) { + if (queue.length < VIEWER_MAX_PENDING_CANDIDATES) { queue.push(candidate); } } diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index 2492f0f..8d6ed7c 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -5,7 +5,11 @@ import type { } from "../../shared/protocol"; import type { QualityProfile } from "../media/quality"; import type { PeerSnapshot } from "../types"; -import { HostPeer } from "./host-peer"; +import { + HostPeer, + type HostMediaPeer, + type HostPeerEvents, +} from "./host-peer"; import { MAX_ENDPOINT_MEDIA_CHILDREN } from "./media-assignment"; import { automaticVideoCodecPreference, @@ -14,7 +18,7 @@ import { } from "./video-codec"; import { preferredVideoCodecForTrack } from "./video-codec-preflight"; -interface ViewerRelayEvents { +export interface ViewerRelayEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; onUpdate?: (snapshot: PeerSnapshot | null) => void; onSenderUpdate?: (snapshot: PeerSnapshot, revision: number | null) => void; @@ -23,15 +27,30 @@ interface PreparedChild { revision: number; childPeerId: string; candidate: PreparedRouteCandidate; - peer: HostPeer; + peer: HostMediaPeer; failed: boolean; replacesConnectionId: string | null; } +export interface ViewerRelayPeerFactory { + requiresStream: boolean; + create( + childPeerId: string, + connectionId: string | undefined, + events: ViewerRelayPeerEvents, + candidate: PreparedRouteCandidate | null, + ): HostMediaPeer | null; +} + +export interface ViewerRelayPeerEvents { + sendSignal: (peerId: string, payload: SignalPayload) => boolean; + onUpdate: (snapshot: PeerSnapshot) => void; +} + export class ViewerRelay { private childPeerIds: string[] = []; private stream: MediaStream | null = null; - private readonly peers = new Map(); + private readonly peers = new Map(); private readonly snapshots = new Map(); private preparedChild: PreparedChild | null = null; private preparedRevision: number | null = null; @@ -51,6 +70,7 @@ export class ViewerRelay { private readonly events: ViewerRelayEvents, private maxMediaEdges = MAX_ENDPOINT_MEDIA_CHILDREN, private readonly natPredictionEnabled = false, + private readonly peerFactory: ViewerRelayPeerFactory | null = null, ) {} getSnapshot(childPeerId?: string): PeerSnapshot | null { @@ -72,7 +92,7 @@ export class ViewerRelay { const planned = [...new Set(plannedChildPeerIds)]; if ( this.disposed || - !stream || + (!stream && (this.peerFactory?.requiresStream ?? true)) || candidate.transport === "sfu" || !planned.includes(candidate.childPeerId) || planned.length !== plannedChildPeerIds.length || @@ -193,7 +213,7 @@ export class ViewerRelay { for (const childPeerId of this.childPeerIds) { const peer = this.peers.get(childPeerId); if ( - this.stream && + (this.stream || this.peerFactory?.requiresStream === false) && childPeerId !== promotedPeerId && (!peer || !peer.isConnected()) ) { @@ -209,7 +229,7 @@ export class ViewerRelay { return; } const nextVideoTrack = stream.getVideoTracks()[0] ?? null; - if (nextVideoTrack !== this.codecProbeTrack) { + if (!this.peerFactory && nextVideoTrack !== this.codecProbeTrack) { this.cancelCodecProbe(); this.codecProbeTrack = nextVideoTrack; if (!this.codecProbeSettled) { @@ -284,7 +304,11 @@ export class ViewerRelay { ): Promise { const stream = this.stream; const peer = this.peers.get(fromPeerId); - if (this.disposed || !stream || !this.childPeerIds.includes(fromPeerId)) { + if ( + this.disposed || + (!stream && (this.peerFactory?.requiresStream ?? true)) || + !this.childPeerIds.includes(fromPeerId) + ) { return; } if (rebuild) { @@ -341,12 +365,13 @@ export class ViewerRelay { private startPreparedChild( revision: number, candidate: PreparedRouteCandidate, - stream: MediaStream, + stream: MediaStream | null, ): void { const peer = this.createPeer( candidate.childPeerId, stream, candidate.connectionId, + candidate, ); this.preparedChild = { revision, @@ -370,7 +395,7 @@ export class ViewerRelay { private startPreparedChildIfCurrent( revision: number, candidate: PreparedRouteCandidate, - stream: MediaStream, + stream: MediaStream | null, ): void { if ( this.disposed || @@ -386,7 +411,7 @@ export class ViewerRelay { private async syncStream( stream: MediaStream, - preparedPeer: HostPeer | null, + preparedPeer: HostMediaPeer | null, ): Promise { if (this.disposed || this.stream !== stream) { return; @@ -455,7 +480,7 @@ export class ViewerRelay { } private async replacePeerStream( - peer: HostPeer, + peer: HostMediaPeer, stream: MediaStream, ): Promise { try { @@ -467,7 +492,7 @@ export class ViewerRelay { private startPeer( childPeerId: string, - stream: MediaStream, + stream: MediaStream | null, attempt = 0, ): void { if ( @@ -504,50 +529,61 @@ export class ViewerRelay { private createPeer( childPeerId: string, - stream: MediaStream, + stream: MediaStream | null, connectionId?: string, - ): HostPeer { - let peer: HostPeer; + candidate: PreparedRouteCandidate | null = null, + ): HostMediaPeer { + let peer: HostMediaPeer; + const peerEvents: HostPeerEvents = { + sendSignal: (targetPeerId, payload) => + !this.disposed && + (this.peers.get(childPeerId) === peer || + this.preparedChild?.peer === peer) && + childPeerId === targetPeerId + ? this.events.sendSignal(targetPeerId, payload) + : false, + onUpdate: (snapshot) => { + if (this.preparedChild?.peer === peer) { + if (snapshot.connectionState === "failed") { + this.failPreparedChild(peer); + } + this.events.onSenderUpdate?.( + snapshot, + this.preparedChild?.revision ?? null, + ); + return; + } + if ( + !this.disposed && + this.peers.get(childPeerId) === peer && + this.childPeerIds.includes(childPeerId) && + snapshot.peerId === childPeerId && + snapshot.connectionId === peer.connectionId + ) { + this.snapshots.set(childPeerId, { + ...snapshot, + metrics: { ...snapshot.metrics }, + }); + this.events.onSenderUpdate?.(snapshot, null); + this.events.onUpdate?.(this.getSnapshot()); + } + }, + }; + if (this.peerFactory) { + const nativePeer = this.peerFactory.create( + childPeerId, + connectionId, + peerEvents, + candidate, + ); + if (nativePeer) return nativePeer; + } peer = new HostPeer( childPeerId, this.iceConfig, - stream, + stream!, this.desiredProfile, - { - sendSignal: (targetPeerId, payload) => - !this.disposed && - (this.peers.get(childPeerId) === peer || - this.preparedChild?.peer === peer) && - childPeerId === targetPeerId - ? this.events.sendSignal(targetPeerId, payload) - : false, - onUpdate: (snapshot) => { - if (this.preparedChild?.peer === peer) { - if (snapshot.connectionState === "failed") { - this.failPreparedChild(peer); - } - this.events.onSenderUpdate?.( - snapshot, - this.preparedChild?.revision ?? null, - ); - return; - } - if ( - !this.disposed && - this.peers.get(childPeerId) === peer && - this.childPeerIds.includes(childPeerId) && - snapshot.peerId === childPeerId && - snapshot.connectionId === peer.connectionId - ) { - this.snapshots.set(childPeerId, { - ...snapshot, - metrics: { ...snapshot.metrics }, - }); - this.events.onSenderUpdate?.(snapshot, null); - this.events.onUpdate?.(this.getSnapshot()); - } - }, - }, + peerEvents, this.videoCodec, connectionId, this.natPredictionEnabled, @@ -559,6 +595,7 @@ export class ViewerRelay { const track = this.codecProbeTrack; if ( this.disposed || + this.peerFactory || this.codecProbeSettled || this.codecProbeAbort || this.codecProbePromise || @@ -604,7 +641,7 @@ export class ViewerRelay { controller?.abort(); } - private failPreparedChild(peer: HostPeer): void { + private failPreparedChild(peer: HostMediaPeer): void { const prepared = this.preparedChild; if (prepared?.peer !== peer) { return; diff --git a/src/server/app.ts b/src/server/app.ts index 718d2af..7447c76 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -111,6 +111,7 @@ export async function createScreenerServer( const iceOptions = { stunUrls: config.stunUrls, natPredictionEnabled: config.natPredictionEnabled, + natPredictionStunUrls: config.natPredictionStunUrls, }; let frontendHandler: FrontendHandler | undefined; diff --git a/src/server/config.ts b/src/server/config.ts index 61b8787..019556b 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -58,6 +58,7 @@ export interface ServerConfig { livekitFallback?: LiveKitFallbackConfig; stunUrls: readonly string[]; natPredictionEnabled: boolean; + natPredictionStunUrls?: readonly string[]; } function parseBoolean( diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts index 80efbf2..5a285e4 100644 --- a/src/server/hybrid-media-router.ts +++ b/src/server/hybrid-media-router.ts @@ -1085,6 +1085,7 @@ export class HybridMediaRouter { sfuEnabled: Boolean(this.options.sfuFallback) && routePolicy?.peerOnly !== true, qualityConvergenceEnabled: routePolicy?.topologyOptimization === true, + natPredictionEnabled: routePolicy?.natPrediction === true, }); room.controller.upsertParticipant({ peerId: host.peerId, @@ -2265,6 +2266,9 @@ function preparedRouteCandidate( connectionId, transport: tuple.kind === "peer" ? tuple.transport : "sfu", qualityProbe: operation.reason === "quality-convergence", + ...(operation.current?.connectionAttempt + ? { connectionAttempt: operation.current.connectionAttempt } + : {}), }; } diff --git a/src/server/ice.ts b/src/server/ice.ts index 73c6939..11ef3cc 100644 --- a/src/server/ice.ts +++ b/src/server/ice.ts @@ -3,6 +3,7 @@ import type { WireIceConfig } from "../shared/protocol.js"; export interface IceConfigOptions { stunUrls: readonly string[]; natPredictionEnabled: boolean; + natPredictionStunUrls?: readonly string[]; } const NAT_PREDICTION_BASE_PORT = 3478; @@ -38,11 +39,13 @@ export function natPredictionStunUrls( } export function createIceConfig(options: IceConfigOptions): WireIceConfig { + const predictionStunUrls = + options.natPredictionStunUrls ?? natPredictionStunUrls(options.stunUrls); return { iceServers: options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] : [], natPredictionStunUrls: options.natPredictionEnabled - ? natPredictionStunUrls(options.stunUrls) + ? [...predictionStunUrls] : [], }; } diff --git a/src/server/local-config.ts b/src/server/local-config.ts index eb34cac..c92894a 100644 --- a/src/server/local-config.ts +++ b/src/server/local-config.ts @@ -17,8 +17,9 @@ export interface LocalServerConfigOptions { publicAddress: string; publicOrigin?: string; allowedAddresses?: readonly string[]; - siteAccessPassword: string; + siteAccessPassword?: string; stunUrls?: readonly string[]; + natPredictionStunUrls?: readonly string[]; } export function createLocalServerConfig( @@ -32,7 +33,13 @@ export function createLocalServerConfig( if (publicAddress === "0.0.0.0" || publicAddress.startsWith("127.")) { throw new Error("Local server public address must be reachable from the LAN"); } - if (!VISIBLE_ASCII_PATTERN.test(options.siteAccessPassword)) { + const siteAccessPassword = options.siteAccessPassword?.trim() ?? ""; + if ( + siteAccessPassword && + (!VISIBLE_ASCII_PATTERN.test(siteAccessPassword) || + Buffer.byteLength(siteAccessPassword) < 8 || + Buffer.byteLength(siteAccessPassword) > 128) + ) { throw new Error("Local access password must contain 8 to 128 visible ASCII bytes"); } @@ -43,9 +50,17 @@ export function createLocalServerConfig( ), ]); const stunUrls = [...(options.stunUrls ?? [])]; + const natPredictionStunUrls = [ + ...(options.natPredictionStunUrls ?? []), + ]; if ( - stunUrls.length > MAX_ICE_SERVER_URLS || - stunUrls.some((url) => !stunUrlSchema.safeParse(url).success) + stunUrls.length + natPredictionStunUrls.length > MAX_ICE_SERVER_URLS || + stunUrls.some((url) => !stunUrlSchema.safeParse(url).success) || + natPredictionStunUrls.some( + (url) => !stunUrlSchema.safeParse(url).success, + ) || + ![0, 2].includes(natPredictionStunUrls.length) || + (natPredictionStunUrls.length > 0 && stunUrls.length === 0) ) { throw new Error("Local STUN URLs are invalid"); } @@ -65,13 +80,14 @@ export function createLocalServerConfig( ...[...allowedAddresses].map(origin), publicBaseUrl.origin, ]), - siteAccessPassword: options.siteAccessPassword, + siteAccessPassword: siteAccessPassword || undefined, roomLeaseMs: LOCAL_ROOM_LEASE_MS, maxViewersPerRoom: MAX_VIEWERS_PER_ROOM_LIMIT, peerAssistedMedia: true, endpointMediaCopyCapacity: DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY, stunUrls, - natPredictionEnabled: false, + natPredictionEnabled: natPredictionStunUrls.length === 2, + natPredictionStunUrls, }; } @@ -79,12 +95,10 @@ export function loadLocalServerConfig( environment: NodeJS.ProcessEnv = process.env, ): ServerConfig { const publicAddress = environment.SCREENER_CLIENT_LAN_ADDRESS?.trim(); - const siteAccessPassword = environment.SCREENER_CLIENT_LOCAL_PASSWORD; - if (!publicAddress || !siteAccessPassword) { - throw new Error( - "SCREENER_CLIENT_LAN_ADDRESS and SCREENER_CLIENT_LOCAL_PASSWORD are required", - ); + if (!publicAddress) { + throw new Error("SCREENER_CLIENT_LAN_ADDRESS is required"); } + const siteAccessPassword = environment.SCREENER_CLIENT_LOCAL_PASSWORD?.trim() ?? ""; const portText = environment.SCREENER_CLIENT_PORT?.trim(); const port = portText ? Number(portText) : undefined; const allowedAddresses = environment.SCREENER_CLIENT_ALLOWED_LAN_ADDRESSES @@ -95,14 +109,20 @@ export function loadLocalServerConfig( ?.split(",") .map((url) => url.trim()) .filter(Boolean); + const natPredictionStunUrls = + environment.SCREENER_CLIENT_NAT_PREDICTION_STUN_URLS + ?.split(",") + .map((url) => url.trim()) + .filter(Boolean); const publicOrigin = environment.SCREENER_CLIENT_PUBLIC_ORIGIN?.trim(); return createLocalServerConfig({ ...(port === undefined ? {} : { port }), publicAddress, ...(publicOrigin ? { publicOrigin } : {}), ...(allowedAddresses ? { allowedAddresses } : {}), - siteAccessPassword, + ...(siteAccessPassword ? { siteAccessPassword } : {}), ...(stunUrls ? { stunUrls } : {}), + ...(natPredictionStunUrls ? { natPredictionStunUrls } : {}), }); } diff --git a/src/server/room-route-controller.ts b/src/server/room-route-controller.ts index 3ad0f95..be1d42d 100644 --- a/src/server/room-route-controller.ts +++ b/src/server/room-route-controller.ts @@ -101,12 +101,25 @@ export interface ControllerOptions { operationTimeoutMs: number; sfuEnabled?: boolean; qualityConvergenceEnabled?: boolean; + natPredictionEnabled?: boolean; } const routeDebug = debuglog("screener-route"); const MAX_DIRECT_HEAD_START_MS = 5_000; +const MAX_NAT_CONNECTION_ATTEMPTS = 3; const SFU_OPPORTUNITY_PREFIX = "sfu\0"; +interface CandidateOpportunity { + rank: number; + startedAttempts: number; + exhausted?: true; +} + +interface ConnectionAttemptProgress { + current: number; + total: typeof MAX_NAT_CONNECTION_ATTEMPTS; +} + interface Participant { peerId: string; role: "host" | "viewer"; @@ -115,7 +128,7 @@ interface Participant { joinOrder: number; effectiveDownstreamCapacity: number; availabilityExhausted: boolean; - consumedCandidateOpportunities: Map; + consumedCandidateOpportunities: Map; bootstrapFailureReported?: boolean; sfuFirstAtNextRoute?: boolean; } @@ -133,6 +146,7 @@ interface Attempt { startedAtMs: number; transportConnected: boolean; mediaReady: boolean; + connectionAttempt?: ConnectionAttemptProgress; senderQualityState?: "healthy" | "degraded"; senderQualityAcceptedAtMs?: number; senderQualityConsecutiveHealthyWindows?: number; @@ -154,6 +168,7 @@ interface ChildOperation { deadlineAtMs: number; builtAtFactVersion: number; deferredParentPeerIds: string[]; + peerAttemptStarted: boolean; qualitySource?: SenderQualityIdentity; current?: Attempt; } @@ -187,7 +202,12 @@ export interface OperationSnapshot { cursor: number; deadlineAtMs: number; wakeAtMs: number; - current?: { tuple: CandidateTuple; revision: number; connectionId: string }; + current?: { + tuple: CandidateTuple; + revision: number; + connectionId: string; + connectionAttempt?: ConnectionAttemptProgress; + }; } export interface RouteSnapshot { @@ -1751,6 +1771,7 @@ export class RoomRouteController { deadlineAtMs: nowMs + this.options.operationTimeoutMs, builtAtFactVersion: this.factVersion, deferredParentPeerIds: [], + peerAttemptStarted: false, qualitySource: reason === "quality-convergence" ? this.currentQualityOperationSource(childPeerId) @@ -1852,12 +1873,16 @@ export class RoomRouteController { startedAtMs: input.nowMs, transportConnected: false, mediaReady: false, + connectionAttempt: this.startCandidateOpportunity(operation, plan), }; this.debug("candidate-started", { child: this.debugPeer(operation.childPeerId), candidate: this.debugTuple(tuple), revision: operation.current.revision, cursor: operation.cursor, + ...(operation.current.connectionAttempt + ? { connectionAttempt: operation.current.connectionAttempt } + : {}), }); this.startCandidateTiming(operation.demandPeerId, input.nowMs); return { @@ -2156,12 +2181,7 @@ export class RoomRouteController { } } if (!commitReservation(attempt.reservation)) { - if (this.isAvailabilityOperation(operation.reason)) { - this.consumeCandidateOpportunity( - operation.childPeerId, - operation.candidates[operation.cursor]!, - ); - } + this.consumeCurrentAvailabilityOpportunity(operation); this.debug("candidate-commit-rejected", { child: this.debugPeer(operation.childPeerId), candidate: this.debugTuple(attempt.tuple), @@ -2255,19 +2275,13 @@ export class RoomRouteController { operation?.current && this.guardMatches(guard, operation, operation.current) ) { - if (this.isAvailabilityOperation(operation.reason)) { - this.consumeCandidateOpportunity( - operation.childPeerId, - operation.candidates[operation.cursor]!, - ); - } + this.consumeCurrentAvailabilityOpportunity(operation); this.debug("candidate-failed", { child: this.debugPeer(operation.childPeerId), candidate: this.debugTuple(operation.current.tuple), revision: operation.current.revision, }); this.promoteNextDirectWithinHeadStart(operation, nowMs); - this.consumeDirectContinuationCandidate(operation); this.noteRejection(operation.demandPeerId, "candidate-failed"); } const validation = this.validateOrAdvance(nowMs, guard); @@ -2304,7 +2318,6 @@ export class RoomRouteController { cursor: this.operation.cursor, candidateCount: this.operation.candidates.length, }); - this.consumeDirectContinuationCandidate(this.operation); } const validation = this.validateOrAdvance(nowMs); return { @@ -2428,7 +2441,8 @@ export class RoomRouteController { const exhausted = sfuBootstrap ? failedBootstrapDemand !== undefined : !backgroundConvergence && - !bootstrapAvailable; + !bootstrapAvailable && + !this.hasRemainingNatOpportunity(operation); if (bootstrapAvailable) this.stageSfuBootstrap(operation); if (!sfuBootstrap) { this.finishTiming( @@ -2566,7 +2580,8 @@ export class RoomRouteController { const exhausted = sfuBootstrap ? failedBootstrapDemand !== undefined : !backgroundConvergence && - !bootstrapAvailable; + !bootstrapAvailable && + !this.hasRemainingNatOpportunity(operation); if (bootstrapAvailable) this.stageSfuBootstrap(operation); if (!sfuBootstrap) { this.finishTiming( @@ -2662,7 +2677,11 @@ export class RoomRouteController { : operation.reason !== "direct-convergence" ? [ ...operation.candidates - .slice(operation.cursor + 1) + .slice(this.options.natPredictionEnabled ? 0 : operation.cursor + 1) + .filter((candidate) => + !this.options.natPredictionEnabled || + this.candidateOpportunityAvailable(operation.childPeerId, candidate), + ) .flatMap((candidate) => candidate.tuple.kind === "peer" ? [candidate.tuple.parentPeerId] @@ -2766,7 +2785,7 @@ export class RoomRouteController { childPeerId: string, sfuOnly: boolean, ): CandidatePlan[] { - return this.buildCandidatePlans(childPeerId, sfuOnly).filter( + return this.buildCandidatePlans(childPeerId, sfuOnly, true).filter( (candidate) => this.candidateOpportunityAvailable(childPeerId, candidate), ); @@ -2775,12 +2794,16 @@ export class RoomRouteController { private buildCandidatePlans( childPeerId: string, sfuOnly: boolean, + preferUntried = false, ): CandidatePlan[] { const descendants = this.descendantsOf(childPeerId); const parents = [...this.participants.values()] .filter((parent) => parent.sessionId && !parent.departureConfirmed && parent.peerId !== childPeerId && !descendants.has(parent.peerId) && this.sourceUsable(parent.peerId) && this.targetFits(parent.peerId, childPeerId)) - .sort((left, right) => this.depth(left.peerId) - this.depth(right.peerId) || + .sort((left, right) => (preferUntried + ? this.peerAttemptsStarted(childPeerId, left.peerId) - this.peerAttemptsStarted(childPeerId, right.peerId) + : 0) || + this.depth(left.peerId) - this.depth(right.peerId) || this.remaining(right.peerId, childPeerId) - this.remaining(left.peerId, childPeerId) || stablePairRank(childPeerId, left.peerId) - stablePairRank(childPeerId, right.peerId) || compareParticipant(left, right)); @@ -2825,7 +2848,69 @@ export class RoomRouteController { ); return ( consumed === undefined || - endpointTransitionRank(plan.endpointTransition) < consumed + endpointTransitionRank(plan.endpointTransition) < consumed.rank || + (this.options.natPredictionEnabled === true && + plan.tuple.kind === "peer" && + !consumed.exhausted && + consumed.startedAttempts < MAX_NAT_CONNECTION_ATTEMPTS) + ); + } + + private startCandidateOpportunity( + operation: ChildOperation, + plan: CandidatePlan, + ): ConnectionAttemptProgress | undefined { + if ( + !this.options.natPredictionEnabled || + plan.tuple.kind !== "peer" || + (!this.isAvailabilityOperation(operation.reason) && + operation.reason !== "direct-convergence") + ) return undefined; + const opportunities = this.participants.get(operation.childPeerId)! + .consumedCandidateOpportunities; + const key = this.candidateOpportunityBase(operation.childPeerId, plan); + const rank = endpointTransitionRank(plan.endpointTransition); + const previous = opportunities.get(key); + const startedAttempts = + (previous && previous.rank <= rank ? previous.startedAttempts : 0) + 1; + opportunities.set(key, { + rank: Math.min(previous?.rank ?? rank, rank), + startedAttempts, + }); + operation.peerAttemptStarted = true; + return { current: startedAttempts, total: MAX_NAT_CONNECTION_ATTEMPTS }; + } + + private peerAttemptsStarted(childPeerId: string, parentPeerId: string): number { + if (!this.options.natPredictionEnabled) return 0; + const plan = this.planCandidate(childPeerId, { + kind: "peer", parentPeerId, transport: "direct", + }); + if (!plan) return 0; + const opportunity = this.participants.get(childPeerId) + ?.consumedCandidateOpportunities.get(this.candidateOpportunityBase(childPeerId, plan)); + return opportunity && opportunity.rank <= endpointTransitionRank(plan.endpointTransition) + ? opportunity.startedAttempts + : 0; + } + + private viewerAttemptsStarted(peerId: string): number { + if (!this.options.natPredictionEnabled) return 0; + let started = 0; + for (const [key, opportunity] of this.participants.get(peerId)! + .consumedCandidateOpportunities) { + if (key.startsWith("peer:")) started += opportunity.startedAttempts; + } + return started; + } + + private hasRemainingNatOpportunity(operation: ChildOperation): boolean { + return Boolean( + this.isAvailabilityOperation(operation.reason) && + operation.peerAttemptStarted && + this.buildCandidates(operation.childPeerId, false).some( + (plan) => plan.tuple.kind === "peer", + ), ); } @@ -2853,11 +2938,14 @@ export class RoomRouteController { childPeerId, )?.consumedCandidateOpportunities; if (!consumed) return; - consumeOpportunity( - consumed, - this.candidateOpportunityBase(childPeerId, plan), - endpointTransitionRank(plan.endpointTransition), - ); + const key = this.candidateOpportunityBase(childPeerId, plan); + const rank = endpointTransitionRank(plan.endpointTransition); + const previous = consumed.get(key); + consumed.set(key, { + rank: Math.min(previous?.rank ?? rank, rank), + startedAttempts: previous?.startedAttempts ?? 0, + exhausted: true, + }); } private qualityCandidateOpportunityAvailable( @@ -2906,12 +2994,10 @@ export class RoomRouteController { edge: CommittedEdge, ): void { if (edge.kind === "peer") { - consumeOpportunity( - child.consumedCandidateOpportunities, - `peer:${edge.parentPeerId}\0${child.sessionId ?? ""}\0${ - edge.parentSessionId - }`, - 0, + const key = `peer:${edge.parentPeerId}\0${child.sessionId ?? ""}\0${edge.parentSessionId}`; + child.consumedCandidateOpportunities.set( + key, + { rank: 0, startedAttempts: child.consumedCandidateOpportunities.get(key)?.startedAttempts ?? 0, exhausted: true }, ); return; } @@ -2926,6 +3012,7 @@ export class RoomRouteController { ): void { if (!this.isAvailabilityOperation(operation.reason)) return; for (const plan of operation.candidates) { + if (operation.peerAttemptStarted && plan.tuple.kind === "peer") continue; this.consumeCandidateOpportunity(operation.childPeerId, plan); } } @@ -2935,6 +3022,7 @@ export class RoomRouteController { ): void { if (!this.isAvailabilityOperation(operation.reason)) return; const plan = operation.candidates[operation.cursor]; + if (operation.current?.connectionAttempt && plan?.tuple.kind === "peer") return; if (plan) this.consumeCandidateOpportunity(operation.childPeerId, plan); } @@ -3379,20 +3467,28 @@ export class RoomRouteController { private selectNextChild(): string | undefined { const viewers = this.availableViewers(); - const departedParent = viewers.find(({ peerId }) => { + const departedParents = viewers.filter(({ peerId }) => { const edge = this.upstreamByViewer.get(peerId); return edge?.kind === "peer" && this.participants.get(edge.parentPeerId)?.departureConfirmed; }); - if (departedParent) return departedParent.peerId; const overflow = this.overflowChildren(); - if (overflow.length > 0) return overflow[0]; - const unusable = viewers.find(({ peerId }) => this.upstreamByViewer.get(peerId)?.usable === false); - const staleSfu = viewers.find(({ peerId }) => { + const unusable = viewers.filter(({ peerId }) => this.upstreamByViewer.get(peerId)?.usable === false); + const staleSfu = viewers.filter(({ peerId }) => { const edge = this.upstreamByViewer.get(peerId); return edge?.kind === "sfu" && (!this.hostPublication?.usable || this.hostPublication.generation !== edge.publicationGeneration); }); - return unusable?.peerId ?? staleSfu?.peerId ?? viewers.find(({ peerId }) => !this.upstreamByViewer.has(peerId))?.peerId; + const candidates = [...new Set([ + ...departedParents.map(({ peerId }) => peerId), + ...overflow, + ...unusable.map(({ peerId }) => peerId), + ...staleSfu.map(({ peerId }) => peerId), + ...viewers.filter(({ peerId }) => !this.upstreamByViewer.has(peerId)) + .map(({ peerId }) => peerId), + ])]; + return candidates.sort((left, right) => + this.viewerAttemptsStarted(left) - this.viewerAttemptsStarted(right), + )[0]; } private selectDirectContinuation(): { @@ -3401,7 +3497,9 @@ export class RoomRouteController { } | undefined { for (const [childPeerId, continuation] of [ ...this.directContinuations, - ]) { + ].sort(([left], [right]) => + this.viewerAttemptsStarted(left) - this.viewerAttemptsStarted(right), + )) { const child = this.participants.get(childPeerId); const edge = this.upstreamByViewer.get(childPeerId); if ( @@ -3420,7 +3518,10 @@ export class RoomRouteController { this.directContinuations.delete(childPeerId); continue; } - for (const parentPeerId of continuation.parentPeerIds) { + for (const parentPeerId of [...continuation.parentPeerIds].sort( + (left, right) => this.peerAttemptsStarted(childPeerId, left) - + this.peerAttemptsStarted(childPeerId, right), + )) { const tuple: CandidateTuple = { kind: "peer", parentPeerId, @@ -3430,7 +3531,9 @@ export class RoomRouteController { if ( plan && plan.endpointTransition.kind !== "bounded-gap" && - this.candidateValid(childPeerId, plan) + this.candidateValid(childPeerId, plan) && + (!this.options.natPredictionEnabled || + this.candidateOpportunityAvailable(childPeerId, plan)) ) { return { childPeerId, plan }; } @@ -3487,6 +3590,14 @@ export class RoomRouteController { const index = continuation.parentPeerIds.indexOf(tuple.parentPeerId); if (index < 0) return; continuation.parentPeerIds.splice(index, 1); + const plan = operation.candidates[operation.cursor]; + if ( + operation.current?.connectionAttempt && + plan && + this.candidateOpportunityAvailable(operation.childPeerId, plan) + ) { + continuation.parentPeerIds.push(tuple.parentPeerId); + } this.debug("direct-convergence-advanced", { child: this.debugPeer(operation.childPeerId), parent: this.debugPeer(tuple.parentPeerId), @@ -3844,7 +3955,8 @@ export class RoomRouteController { ): boolean { return ( this.candidateValid(operation.childPeerId, plan, attempt) && - (!this.isAvailabilityOperation(operation.reason) || + (attempt?.connectionAttempt !== undefined || + !this.isAvailabilityOperation(operation.reason) || this.candidateOpportunityAvailable(operation.childPeerId, plan)) ); } @@ -4100,7 +4212,11 @@ export class RoomRouteController { cursor: operation.cursor, deadlineAtMs: operation.deadlineAtMs, wakeAtMs: this.operationWakeAt(operation), current: operation.current ? { tuple: { ...operation.current.tuple }, revision: operation.current.revision, - connectionId: operation.current.connectionId } : undefined }; + connectionId: operation.current.connectionId, + ...(operation.current.connectionAttempt + ? { connectionAttempt: { ...operation.current.connectionAttempt } } + : {}), + } : undefined }; } private advanceExpiredDirectHeadStart( @@ -4127,12 +4243,7 @@ export class RoomRouteController { : null; if (operation.current) { this.noteRejection(operation.demandPeerId, "first-frame-timeout"); - if (this.isAvailabilityOperation(operation.reason)) { - this.consumeCandidateOpportunity( - operation.childPeerId, - operation.candidates[operation.cursor]!, - ); - } + this.consumeCurrentAvailabilityOpportunity(operation); released.push(...reservationResources(operation.current.reservation)); this.advanceActiveRevision(operation); operation.current = undefined; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index d08eada..4ea0e7d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -7,7 +7,7 @@ import { NAT_TRAVERSAL_PATHS } from "./nat-candidate.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v19"; +export const SIGNALING_PROTOCOL = "screener-v20"; export const SIGNAL_CLOSE_CODES = { serviceRestart: 1012, sessionReplaced: 4001, @@ -348,6 +348,10 @@ export const preparedRouteCandidateSchema = z connectionId: opaqueIdSchema, transport: z.enum(["direct", "sfu"]), qualityProbe: z.boolean(), + connectionAttempt: z.object({ + current: z.number().int().min(1).max(3), + total: z.literal(3), + }).strict().optional(), }) .strict(); export type PreparedRouteCandidate = z.infer< diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index fb6a0c9..251b833 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -66,14 +66,14 @@ describe("browser-local display name", () => { it("localizes text defaults and uses visual emoji identities", () => { setCopy({ lang: "zh", vis: false }); expect(defaultViewerDisplayName("viewer-abcdef", false)).toBe("观众"); - expect(defaultHostDisplayName("host-abcdef", false)).toBe("分享者-abcdef"); + expect(defaultHostDisplayName("host-abcdef", false)).toBe("分享者 (abcdef)"); setCopy({ lang: "en", vis: false }); expect(defaultViewerDisplayName("viewer-abcdef", false)).toBe("Viewer"); - expect(defaultHostDisplayName("host-abcdef", false)).toBe("Host-abcdef"); + expect(defaultHostDisplayName("host-abcdef", false)).toBe("Host (abcdef)"); - expect(defaultViewerDisplayName("viewer-abcdef", true)).toBe("👤-abcdef"); - expect(defaultHostDisplayName("host-abcdef", true)).toBe("👑-abcdef"); + expect(defaultViewerDisplayName("viewer-abcdef", true)).toBe("👤 (abcdef)"); + expect(defaultHostDisplayName("host-abcdef", true)).toBe("👑 (abcdef)"); }); it("stores only the canonical preference and falls back when cleared", () => { @@ -269,7 +269,7 @@ describe("client session identity", () => { it("works without secure-context-only crypto.randomUUID", () => { const values = new Map(); vi.stubGlobal("window", { - sessionStorage: { + localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), }, @@ -792,8 +792,24 @@ describe("room codes", () => { expect(replaceState).toHaveBeenCalledWith(null, "", "/"); }); + it("accepts a bounded user-chosen Client access value", () => { + const replaceState = vi.fn(); + vi.stubGlobal("window", { + location: { + hash: "#client-access=a%2Bb%26c%3Fd%3De", + pathname: "/", + search: "", + }, + history: { state: null, replaceState }, + }); + + expect(takeClientLaunchBootstrap().accessToken).toBe("a+b&c?d=e"); + expect(replaceState).toHaveBeenCalledWith(null, "", "/"); + }); + it("consumes the Client launch marker from a server-private fragment", () => { const replaceState = vi.fn(); + const session = new Map(); vi.stubGlobal("window", { location: { hash: `#client-access=${"a".repeat(32)}&screener-client=1&retained=yes`, @@ -801,6 +817,10 @@ describe("room codes", () => { search: "?room=6020", }, history: { state: null, replaceState }, + localStorage: { + getItem: (key: string) => session.get(key) ?? null, + setItem: (key: string, value: string) => session.set(key, value), + }, }); expect(takeClientLaunchBootstrap()).toEqual({ @@ -812,6 +832,12 @@ describe("room codes", () => { "", "/?room=6020#retained=yes", ); + + window.location.hash = ""; + expect(takeClientLaunchBootstrap()).toEqual({ + accessToken: null, + launchedByClient: true, + }); }); }); diff --git a/tests/hybrid-media-router.test.ts b/tests/hybrid-media-router.test.ts index 335b28f..3170200 100644 --- a/tests/hybrid-media-router.test.ts +++ b/tests/hybrid-media-router.test.ts @@ -1018,6 +1018,53 @@ describe("HybridMediaRouter v9 runtime", () => { } }); + it.each([false, true])("creates NAT retries through real prepare messages with configured SFU=%s but peer-only media", async (withSfu) => { + const { store, sent, router } = harness(2, withSfu); + try { + const room = await store.createRoom(); + const host = connectHost(store, room, { + peerOnly: true, topologyOptimization: false, natPrediction: true, + }); + complete(router, host); + const viewer = connectViewer(store, room, "nat-retry"); + complete(router, viewer); + const connectionIds = new Set(); + let staleFailure: Parameters[1] | undefined; + for (let current = 1; current <= 3; current += 1) { + await vi.waitFor(() => expect( + preparedFor(sent, viewer.sessionId)?.candidate.connectionAttempt, + ).toEqual({ current, total: 3 })); + const prepared = preparedFor(sent, viewer.sessionId)!; + connectionIds.add(prepared.candidate.connectionId); + expect(preparedFor(sent, host.sessionId)?.candidate).toEqual(prepared.candidate); + if (staleFailure) router.handleRouteFailed(viewer, staleFailure); + expect(preparedFor(sent, viewer.sessionId)?.candidate.connectionId) + .toBe(prepared.candidate.connectionId); + if (current === 3) { + router.handleRouteReady(viewer, { + type: "route-ready", revision: prepared.revision, phase: "prepare", + }); + expect(router.resolveActiveViewerMediaEdge(room.roomId, viewer.peerId)) + .toMatchObject({ connectionId: prepared.candidate.connectionId }); + } else { + staleFailure = { + type: "route-failed", revision: prepared.revision, phase: "prepare", + connectionId: prepared.candidate.connectionId, + }; + router.handleRouteFailed(viewer, staleFailure); + router.handleRouteFailed(host, staleFailure); + } + } + expect(connectionIds.size).toBe(3); + expect([...sent.values()].flat().some((message) => + message.type === "sfu-config" || + (message.type === "route-status" && message.state === "failed"), + )).toBe(false); + } finally { + await router.close(); + } + }); + it("keeps peer-only shares out of configured SFU fallback", async () => { const { store, sent, router } = harness(1, true); try { diff --git a/tests/ice.test.ts b/tests/ice.test.ts index 4aa6a95..4085ca7 100644 --- a/tests/ice.test.ts +++ b/tests/ice.test.ts @@ -11,6 +11,10 @@ describe("ordinary ICE configuration", () => { "stun:stun-b.example.test:3478", ], natPredictionEnabled: true, + natPredictionStunUrls: [ + "stun:survey-a.example.test:3478", + "stun:survey-b.example.test:3478", + ], }), ).toEqual({ iceServers: [ @@ -22,8 +26,8 @@ describe("ordinary ICE configuration", () => { }, ], natPredictionStunUrls: [ - "stun:stun-a.example.test:3479", - "stun:stun-a.example.test:3480", + "stun:survey-a.example.test:3478", + "stun:survey-b.example.test:3478", ], }); }); diff --git a/tests/living-components.test.ts b/tests/living-components.test.ts deleted file mode 100644 index 71022bc..0000000 --- a/tests/living-components.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it } from "vitest"; - -import { Couch } from "../src/client/components/living/Couch.tsx"; -import { participantColor } from "../src/client/components/living/participant-color.ts"; -import { - BrandLoader, - BrandMark, -} from "../src/client/components/living/BrandMark.tsx"; -import { Comic } from "../src/client/components/living/Comic.tsx"; -import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx"; -import { HintComic } from "../src/client/components/living/hints/index.tsx"; -import { RoomAdmissionBadge } from "../src/client/components/living/RoomChip.tsx"; -import { StageOverlay } from "../src/client/components/living/Stage.tsx"; -import { ViewerOverview } from "../src/client/components/living/ViewerOverview.tsx"; -import { Btn, NameTag } from "../src/client/components/living/primitives.tsx"; -import { EMPTY_METRICS } from "../src/client/types.ts"; -import { setCopy, t } from "../src/client/ui/copy.ts"; - -describe("living-room presentation", () => { - afterEach(() => setCopy({ lang: "zh", vis: false })); - - it("keeps visual-only stage status available to assistive technology", () => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement(StageOverlay, { - icon: "refresh", - message: "Media connection recovering", - }), - ); - - expect(html).toContain('role="status"'); - expect(html).toContain('aria-label="Media connection recovering"'); - expect(html).not.toContain("lr-tv-msg"); - }); - - it("uses one static brand shape and an aria-hidden animated loading variant", () => { - const mark = renderToStaticMarkup(createElement(BrandMark, { size: 32 })); - const loader = renderToStaticMarkup(createElement(BrandLoader)); - - expect(mark).toContain('class="lr-brand-mark"'); - expect(mark).toContain("lr-brand-eye-wink"); - expect(mark).not.toContain("is-animated"); - expect(loader).toContain('class="lr-brand-loader"'); - expect(loader).toContain("lr-brand-mark is-animated is-loop-intro"); - expect(loader).toContain('aria-hidden="true"'); - }); - - it("uses the animated brand for transient stage states", () => { - const html = renderToStaticMarkup( - createElement(StageOverlay, { - icon: "loader", - transition: true, - message: "Allocating media route", - }), - ); - - expect(html).toContain('role="status"'); - expect(html).toContain("lr-brand-loader"); - expect(html).not.toContain("lr-tv-big"); - }); - - it("ends the exhausted-route comic with a failed fallback", () => { - const html = renderToStaticMarkup( - createElement(Comic, { kind: "route-failed", size: 320 }), - ); - - expect(html).toContain("vls-rf-fallback-x"); - expect(html).toContain("vls-rf-fallback"); - expect(html).toContain("vlsRfFailurePulse"); - expect(html).toContain("vlsRfFailureX"); - expect(html).not.toContain("vls-rf-green"); - expect(html).not.toContain("vls-rf-star"); - }); - - it("announces the supplied state for a Viewer without committed media", () => { - setCopy({ lang: "en", vis: false }); - const html = renderToStaticMarkup( - createElement(Couch, { - entries: [ - { - key: "viewer-1", - name: "TNT", - connected: false, - statusLabel: "Recovering", - }, - ], - }), - ); - - expect(html).toContain("TNT · Recovering"); - expect(html).toContain("lr-pawn-led is-wait"); - expect(html).toContain('aria-label="Viewers 1"'); - }); - - it("shows the Host as a stable couch participant without counting it as a Viewer", () => { - const hostKey = "host-stable-identity"; - const html = renderToStaticMarkup( - createElement(Couch, { - host: { key: hostKey, name: "Host", you: true }, - entries: [], - }), - ); - - expect(html).toContain("lr-pawn is-host is-static is-you"); - expect(html).toContain(participantColor(hostKey)); - expect(html).toContain("Host · 分享者"); - expect(html).toContain('aria-label="分享者 · 观看者"'); - expect(html).toContain('aria-label="观看者 0"'); - expect(html).not.toContain("lr-couch-empty"); - }); - - it("lets the Host pawn own the existing connection-details control", () => { - const html = renderToStaticMarkup( - createElement(Couch, { - host: { - key: "host-interactive", - name: "Host", - selected: true, - controls: "host-details-panel host-viewer-overview", - onSelect: () => undefined, - }, - entries: [ - { key: "viewer-after-host", name: "Viewer", connected: true }, - ], - }), - ); - - expect(html).toContain("lr-pawn is-host is-selected"); - expect(html).toContain('aria-pressed="true"'); - expect(html).toContain( - 'aria-controls="host-details-panel host-viewer-overview"', - ); - expect(html.indexOf("Host")).toBeLessThan(html.indexOf("Viewer")); - }); - - it("keeps the current display name visible in visual mode", () => { - setCopy({ lang: "en", vis: true }); - const identity = "viewer-name-tag"; - const html = renderToStaticMarkup( - createElement(NameTag, { name: "TNT", identity }), - ); - - expect(html).toContain(">TNT<"); - expect(html).toContain(participantColor(identity)); - expect(html).not.toContain("visually-hidden"); - }); - - it("leaves hidden tooltip artwork out of the initial markup", () => { - const html = renderToStaticMarkup( - createElement( - ComicTooltip, - { - kind: "hint-share-start", - children: createElement("button", { type: "button" }, "Share"), - }, - ), - ); - - expect(html).toContain("lr-comic-tip"); - expect(html).toContain(">Share"); - expect(html).not.toContain('viewBox="0 0 320 96"'); - }); - - it("keeps a hint-wrapped disabled control explainable by keyboard", () => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement( - ComicTooltip, - { - kind: "hint-join-go", - children: createElement(Btn, { - icon: "refresh", - title: "viewer.reconnect", - disabled: true, - }), - }, - ), - ); - - expect(html).toContain('class="lr-comic-tip-wrap is-disabled-trigger"'); - expect(html).toContain('tabindex="0"'); - expect(html).toContain(`aria-label="${t("en", "viewer.reconnect")}"`); - }); - - it("animates the private-room hint around its door and lock", () => { - const html = renderToStaticMarkup( - createElement(HintComic, { kind: "hint-policy-private", size: 240 }), - ); - - expect(html).toContain("vls-priv-door"); - expect(html).toContain("vls-priv-lock"); - expect(html).toContain("vls-priv-card"); - expect(html).toContain("vlsPrivDoor"); - expect(html).toContain("vlsPrivLock"); - }); - - it("uses opposite motion for entering and leaving theater mode", () => { - const entering = renderToStaticMarkup( - createElement(HintComic, { kind: "hint-theater", size: 240 }), - ); - const leaving = renderToStaticMarkup( - createElement(HintComic, { kind: "hint-theater-exit", size: 240 }), - ); - - expect(entering).toContain("animation:vlsThEnter"); - expect(leaving).toContain("animation:vlsThExit"); - expect(entering).toContain("vls-th-arrows"); - expect(leaving).toContain("vls-th-arrows"); - }); - - it.each([ - ["open", false, "host.policy.currentOpen", true], - ["private", true, "host.policy.currentPassword", true], - ["private", false, "host.policy.currentInvite", false], - ] as const)( - "renders %s/%s admission from one policy fact", - (policy, passwordEnabled, labelKey, good) => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement(RoomAdmissionBadge, { policy, passwordEnabled }), - ); - - expect(html).toContain( - `${t("en", labelKey)}`, - ); - expect(html).toContain("lr-comic-tip-wrap"); - expect(html).toContain("visually-hidden"); - expect(html.includes("lr-pill is-good")).toBe(good); - }, - ); - - it("uses distinct native quality-limit comics", () => { - const bandwidth = renderToStaticMarkup( - createElement(Comic, { kind: "bandwidth-limited", size: 320 }), - ); - const encoder = renderToStaticMarkup( - createElement(Comic, { kind: "encoder-limited", size: 320 }), - ); - - expect(bandwidth).toContain("vls-bw-throat"); - expect(bandwidth).toContain("vlsBwSmall"); - expect(encoder).toContain("vls-en-drop"); - expect(encoder).toContain("vlsEnHeat"); - }); - - it("keeps the Host-offline comic free of the waiting-room moon", () => { - const html = renderToStaticMarkup( - createElement(Comic, { kind: "host-offline", theme: "stage" }), - ); - - expect(html).not.toContain("M229 39.5a8.5"); - }); - - it("draws the waiting moon as one open crescent instead of nested circles", () => { - const html = renderToStaticMarkup( - createElement(Comic, { kind: "waiting-for-host", theme: "stage" }), - ); - - expect(html).toContain( - "M0 -8.5A8.5 8.5 0 1 0 0 8.5A6.2 8.5 0 0 1 0 -8.5Z", - ); - expect(html).not.toContain("M229 39.5a8.5"); - expect(html).not.toContain('fill-rule="evenodd"'); - }); - - it("keeps every crowded and relay Viewer name visible in visual mode", () => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement(Couch, { - entries: Array.from({ length: 12 }, (_, index) => ({ - key: `viewer-${index}`, - name: - index === 11 - ? "Relay Viewer With A Full Name" - : `Viewer ${index}`, - connected: true, - })), - }), - ); - - expect(html).toContain("lr-pawns is-crowded"); - expect(html).toContain( - 'Relay Viewer With A Full Name', - ); - expect(html).toContain('title="Relay Viewer With A Full Name"'); - expect(html).not.toContain("max-width:72px"); - }); - - it("derives every participant color only from the peer identity", () => { - const peerId = "viewer-stable-identity"; - const color = participantColor(peerId); - const html = renderToStaticMarkup( - createElement(Couch, { - entries: [ - { - key: peerId, - name: "Self", - connected: true, - you: true, - }, - ], - }), - ); - - expect(participantColor(peerId)).toBe(color); - expect(html).toContain(color); - expect(html).not.toContain("var(--you)"); - }); - - it("shows all Viewer primary metrics in one compact selectable overview", () => { - const html = renderToStaticMarkup( - createElement(ViewerOverview, { - entries: [ - { - key: "viewer-a", - name: "Alice", - connected: true, - statusLabel: "Connected", - route: "p2p", - metrics: { - ...EMPTY_METRICS, - resolution: "1920x1080", - framesPerSecond: 59.8, - bitrateKbps: 5120, - packetLossPercent: 0.4, - rttMs: 18, - }, - }, - { - key: "viewer-b", - name: "Bob", - connected: false, - statusLabel: "Routing", - route: null, - metrics: null, - }, - ], - selectedKey: "viewer-a", - onSelect: () => undefined, - }), - ); - - expect(html).toContain("lr-viewer-overview-row is-selected"); - expect(html).toContain("Alice"); - expect(html).toContain("1920x1080"); - expect(html).toContain("59.8"); - expect(html).toContain("5120"); - expect(html).toContain("18 ms"); - expect(html).toContain("Bob"); - }); - - it("uses route glyphs without visible route text in visual mode", () => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement(ViewerOverview, { - entries: [ - { - key: "viewer-p2p", - name: "Alice", - connected: true, - statusLabel: "Connected", - route: "p2p", - metrics: null, - }, - ], - selectedKey: null, - onSelect: () => undefined, - }), - ); - - expect(html).not.toContain("P2P"); - expect(html).toContain(t("en", "state.route.p2p")); - }); -}); diff --git a/tests/local-server-config.test.ts b/tests/local-server-config.test.ts index 53b35e3..b075ff4 100644 --- a/tests/local-server-config.test.ts +++ b/tests/local-server-config.test.ts @@ -45,6 +45,8 @@ describe("local server configuration", () => { SCREENER_CLIENT_LOCAL_PASSWORD: "persistent-local-password", SCREENER_CLIENT_PUBLIC_ORIGIN: "https://small-bright-room.trycloudflare.com", STUN_URLS: "stun:stun.example:3478, stun:stun.example:3479", + SCREENER_CLIENT_NAT_PREDICTION_STUN_URLS: + "stun:survey-a.example:3478, stun:survey-b.example:3478", }); expect(config.port).toBe(9234); @@ -59,6 +61,30 @@ describe("local server configuration", () => { "stun:stun.example:3478", "stun:stun.example:3479", ]); + expect(config.natPredictionEnabled).toBe(true); + expect(config.natPredictionStunUrls).toEqual([ + "stun:survey-a.example:3478", + "stun:survey-b.example:3478", + ]); + }); + + it("leaves the Local site open when no password is supplied", () => { + const config = createLocalServerConfig({ + port: 9235, + publicAddress: "192.168.1.10", + }); + + expect(config.siteAccessPassword).toBeUndefined(); + }); + + it("accepts an empty Client password from the process environment", () => { + const config = loadLocalServerConfig({ + SCREENER_CLIENT_PORT: "9236", + SCREENER_CLIENT_LAN_ADDRESS: "192.168.50.4", + SCREENER_CLIENT_LOCAL_PASSWORD: "", + }); + + expect(config.siteAccessPassword).toBeUndefined(); }); it.each([ diff --git a/tests/metric-cells.test.ts b/tests/metric-cells.test.ts deleted file mode 100644 index c67747c..0000000 --- a/tests/metric-cells.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it } from "vitest"; - -import { MetricCells } from "../src/client/components/living/Metrics.tsx"; -import { setCopy, t } from "../src/client/ui/copy.ts"; -import { - EMPTY_METRICS, - type ConnectionMetrics, -} from "../src/client/types.ts"; - -setCopy({ lang: "zh", vis: false }); -afterEach(() => setCopy({ lang: "zh", vis: false })); - -const metrics = { - ...EMPTY_METRICS, - bitrateKbps: 4_200, - framesPerSecond: 59.8, - resolution: "1920x1080", - rttMs: 21, - packetLossPercent: 0.4, - availableOutgoingKbps: 8_100, - iceProtocol: "udp", - localCandidateType: "srflx", - remoteCandidateType: "host", - localCandidateAddress: "192.0.2.10", - localCandidatePort: 50_000, - remoteCandidateAddress: "2001:db8::10", - remoteCandidatePort: 50_001, - selectedCandidatePairId: "candidate-pair-7", - codec: "video/VP8", - codecParameters: "max-fs=8160", - captureWidth: 1_920, - captureHeight: 1_080, - captureFramesPerSecond: 60, - mediaSourceFramesPerSecond: 58.5, - rtpRid: "h", - audioBitrateKbps: 128, - audioCodec: "audio/opus", - encoderImplementation: "ExternalEncoder", - intervalFramesEncoded: 116, - intervalEncodeTimeMs: 371.2, - intervalEncodeMs: 3.2, - qualityLimitationReason: "bandwidth", -} satisfies ConnectionMetrics; - -describe("MetricCells progressive disclosure", () => { - it("keeps visual metric names available to assistive technology", () => { - setCopy({ lang: "en", vis: true }); - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics, - direction: "receive", - expanded: true, - onToggle: () => undefined, - }), - ); - - for (const key of ["stats.resolution", "stats.fps", "stats.loss", "stats.rtt"] as const) { - expect(html).toContain( - `${t("en", key)}`, - ); - } - }); - - it("keeps one-glance picture results above an accessible detail expansion", () => { - const collapsed = renderToStaticMarkup( - createElement(MetricCells, { - metrics, - direction: "send", - expanded: false, - onToggle: () => undefined, - }), - ); - - for (const label of ["分辨率", "帧率", "码率", "丢包率"]) { - expect(collapsed).toContain(label); - } - expect(collapsed).toContain('aria-expanded="false"'); - expect(collapsed).toContain('title="详细指标"'); - expect(collapsed).not.toContain("可用上行"); - - const expanded = renderToStaticMarkup( - createElement(MetricCells, { - metrics, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - expect(expanded).toContain('aria-expanded="true"'); - for (const label of [ - "RTT", - "视频编码", - "可用上行", - "质量状态", - "捕获设置", - "编码输入帧率", - "编码器", - "编码耗时/帧", - "音频码率", - ]) { - expect(expanded).toContain(label); - } - expect(expanded).toContain("58.5 fps"); - expect(expanded).toContain("VP8"); - }); - - it("shows the negotiated video codec but omits browser-internal transport details", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - - expect(html).toContain("VP8"); - for (const omitted of [ - "音频 Codec", - "候选路径", - "本地候选地址", - "远端候选地址", - "候选对 ID", - "STUN 响应", - "candidate-pair-7", - "192.0.2.10", - "2001:db8::10", - "当前 RID", - "max-fs=8160", - "请求 / 应用", - ]) { - expect(html).not.toContain(omitted); - } - }); - - it("shows codecs only when the negotiated contract is unexpected", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics: { - ...metrics, - codec: "video/VP9", - audioCodec: "audio/PCMU", - }, - direction: "receive", - expanded: false, - onToggle: () => undefined, - }), - ); - - expect(html).toContain("视频编码为 VP9,预期 H264 或 VP8"); - expect(html).toContain("音频编码为 PCMU,预期 Opus"); - }); - - it("does not expose an unknown browser quality-limitation value", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics: { - ...metrics, - qualityLimitationReason: "browser-internal-sentinel", - }, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - - expect(html).toContain("未分类限制"); - expect(html).not.toContain("browser-internal-sentinel"); - }); - - it("shows available inbound playback and repair evidence only for receivers", () => { - const receiverMetrics = { - ...metrics, - jitterMs: 4.5, - framesDropped: 2, - intervalDecodeMs: 2.1, - intervalFreezeCount: 1, - intervalFreezeDurationMs: 120, - audioVideoPlayoutDeltaMs: -12.5, - videoJitterBufferDelayMs: 24.5, - audioJitterBufferDelayMs: 18.5, - audioConcealedSamplesPercent: 1.25, - intervalAudioConcealmentEvents: 3, - } satisfies ConnectionMetrics; - const receiveHtml = renderToStaticMarkup( - createElement(MetricCells, { - metrics: receiverMetrics, - direction: "receive", - expanded: true, - onToggle: () => undefined, - }), - ); - const sendHtml = renderToStaticMarkup( - createElement(MetricCells, { - metrics: receiverMetrics, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - - for (const label of [ - "网络抖动", - "丢帧", - "解码耗时/帧", - "画面冻结", - "冻结时长", - "音视频播放差", - "视频缓冲", - "音频缓冲", - "音频补偿率", - "音频补偿", - ]) { - expect(receiveHtml).toContain(label); - expect(sendHtml).not.toContain(label); - } - expect(receiveHtml).toContain("-12.5 ms"); - expect(receiveHtml).toContain("1.3%"); - }); - - it("does not render an empty second level", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics: EMPTY_METRICS, - direction: "receive", - expanded: false, - onToggle: () => undefined, - }), - ); - - expect(html).not.toContain("详细指标"); - expect(html).not.toContain("aria-expanded"); - }); - - it("omits capture input when it matches the delivered picture", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics: { - ...metrics, - resolution: "1920x1080", - framesPerSecond: 60, - captureWidth: 1920, - captureHeight: 1080, - captureFramesPerSecond: 60, - }, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - - expect(html).not.toContain("捕获设置"); - }); - - it("shows only the capture dimension that differs from delivery", () => { - const html = renderToStaticMarkup( - createElement(MetricCells, { - metrics: { - ...metrics, - resolution: "1280x720", - framesPerSecond: 60, - captureWidth: 1920, - captureHeight: 1080, - captureFramesPerSecond: 60, - }, - direction: "send", - expanded: true, - onToggle: () => undefined, - }), - ); - - expect(html).toContain("1920x1080"); - expect(html).not.toContain("1920x1080 · 60.0 fps"); - }); -}); diff --git a/tests/nat-prediction.test.ts b/tests/nat-prediction.test.ts index 7dd9a21..0aa3d1e 100644 --- a/tests/nat-prediction.test.ts +++ b/tests/nat-prediction.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { addRemoteIceCandidate, iceServersWithNatPrediction, + isNativeNatSurveyCandidate, MAX_NAT_PREDICTION_CANDIDATES, NatPredictionCandidateBatch, NatPredictionCandidateEmitter, @@ -43,6 +44,20 @@ function rtcCandidate(port: number): RTCIceCandidate { } describe("NAT prediction ICE adapter", () => { + it("uses only explicit Native survey candidates as prediction evidence", () => { + const survey = candidate(40_000); + if (survey) { + survey.candidate = survey.candidate.replace( + "candidate:base", + "candidate:ns1", + ); + } + + expect(isNativeNatSurveyCandidate(survey)).toBe(true); + expect(isNativeNatSurveyCandidate(candidate(40_000))).toBe(false); + expect(isNativeNatSurveyCandidate(hostCandidate())).toBe(false); + }); + it("ignores only rejected predicted candidates", async () => { const error = new Error("candidate rejected"); const connection = { @@ -85,27 +100,27 @@ describe("NAT prediction ICE adapter", () => { ).toEqual(servers); }); - it("adds only configured self-hosted auxiliary listeners", () => { + it("adds only the configured survey destinations", () => { const servers = [{ urls: "stun:share.example.test:3478" }]; const auxiliary = [ - "stun:share.example.test:3479", - "stun:share.example.test:3480", + "stun:survey-a.example.test:3478", + "stun:survey-b.example.test:3478", ]; expect( iceServersWithNatPrediction(servers, true, auxiliary), ).toEqual([ ...servers, - { urls: "stun:share.example.test:3479" }, - { urls: "stun:share.example.test:3480" }, + { urls: "stun:survey-a.example.test:3478" }, + { urls: "stun:survey-b.example.test:3478" }, ]); expect( natPredictionSurveyUrls(servers, auxiliary), ).toEqual( new Set([ "stun:share.example.test:3478", - "stun:share.example.test:3479", - "stun:share.example.test:3480", + "stun:survey-a.example.test:3478", + "stun:survey-b.example.test:3478", ]), ); expect( @@ -266,8 +281,8 @@ describe("NAT prediction ICE adapter", () => { (value) => sent.push(value), new Set([ "stun:share.example.test:3478", - "stun:share.example.test:3479", - "stun:share.example.test:3480", + "stun:survey-a.example.test:3478", + "stun:survey-b.example.test:3478", ]), ); diff --git a/tests/native-capture-selection.test.ts b/tests/native-capture-selection.test.ts index f71111b..c732e18 100644 --- a/tests/native-capture-selection.test.ts +++ b/tests/native-capture-selection.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CaptureSourcePicker } from "../src/client/components/living/CaptureSourcePicker"; import { defaultNativeCapturePath, @@ -38,6 +42,47 @@ describe("native capture source selection", () => { }), ).toBe("display:2"); }); + + it("separates windows from displays and locks Browser switching during native sharing", () => { + const html = renderToStaticMarkup(createElement(CaptureSourcePicker, { + nativeSources: { + kind: "ready", + sources: [game, { kind: "display", sourceId: "2", title: "Display 1" }], + processAudio: false, + systemAudio: true, + }, + browserAvailable: false, + audioLocked: true, + onBrowser: () => {}, + onNative: () => {}, + onPreview: async () => null, + onRefresh: () => {}, + onCancel: () => {}, + })); + expect(html).toContain('data-source-tab="browser"'); + expect(html).toMatch(/data-source-tab="browser"[^>]*disabled=""/); + expect(html).toMatch(/data-native-source="window:1:10:123456"[^>]*disabled=""/); + expect(html).not.toContain('data-native-source="display:2"'); + expect(html.match(/role="tab"/g)).toHaveLength(3); + }); + + it("retains the platform-owned combined source picker", () => { + const html = renderToStaticMarkup(createElement(CaptureSourcePicker, { + nativeSources: { + kind: "ready", + sources: [{ kind: "picker", sourceId: "1", title: "System picker" }], + processAudio: false, + systemAudio: true, + }, + onBrowser: () => {}, + onNative: () => {}, + onPreview: async () => null, + onRefresh: () => {}, + onCancel: () => {}, + })); + expect(html).toContain('data-native-source="picker:1"'); + expect(html).toContain('aria-checked="true"'); + }); }); describe("native adapter selection", () => { @@ -58,4 +103,14 @@ describe("native adapter selection", () => { }); expect(defaultNativeCapturePath([adapters[0]!])).toBeNull(); }); + + it("uses software VP8 only when that capture capability exists", () => { + expect(defaultNativeCapturePath(adapters, "vp8", true)).toEqual({ + adapterIndex: 0, encoderIndex: 0, + }); + expect(defaultNativeCapturePath(adapters, "vp8", false)).toBeNull(); + expect(defaultNativeCapturePath(adapters, "auto", true)?.adapterIndex).toBe(1); + expect(defaultNativeCapturePath([adapters[0]!], "auto", true)?.adapterIndex).toBe(0); + expect(defaultNativeCapturePath([adapters[0]!], "h264", true)).toBeNull(); + }); }); diff --git a/tests/native-client.test.ts b/tests/native-client.test.ts index 8471871..ab90425 100644 --- a/tests/native-client.test.ts +++ b/tests/native-client.test.ts @@ -1,17 +1,131 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NativeClient, notifyNativePresentation } from "../src/client/native/client"; import { nativeEventSchema, nativeHealthSchema, nativeCaptureTargetSchema, + shareSourceReplacedResponseSchema, + shareStartedResponseSchema, + shareUpdatedResponseSchema, sourcePreviewResponseSchema, + receiveAnswerResponseSchema, } from "../src/client/native/wire"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("native Client private wire", () => { + it("notifies only the current presentation request after Client discovery", async () => { + vi.stubGlobal("window", { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + const fetcher = vi.fn(async (url: string) => url.endsWith("/health") + ? new Response(JSON.stringify({ + protocol: 8, service: "screener-client", port: 39_721, + instanceToken: "a".repeat(43), + nativeMedia: {video: true, processAudio: false, systemAudio: true, hardwareH264: true, softwareVP8: true}, + }), {status: 200}) + : new Response(null, {status: 204})); + vi.stubGlobal("fetch", fetcher); + const stale = new AbortController(); + const staleNotification = notifyNativePresentation("zh", stale.signal); + stale.abort(); + await staleNotification; + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:39721/health", expect.anything()); + const current = new AbortController(); + await notifyNativePresentation("vis", current.signal); + expect(fetcher).toHaveBeenLastCalledWith("http://127.0.0.1:39721/presentation", expect.objectContaining({ + method: "POST", body: JSON.stringify({language: "vis"}), signal: current.signal, + })); + }); + + it("notifies the owner once on an unexpected close and stays silent on cleanup", async () => { + const sockets: FakeWebSocket[] = []; + const token = "a".repeat(43); + class FakeWebSocket extends EventTarget { + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readyState = FakeWebSocket.OPEN; + protocol = `screener-client-v8.${token}`; + readonly close = vi.fn(() => { + this.readyState = FakeWebSocket.CLOSING; + }); + + constructor(readonly url: string, readonly protocols: string[]) { + super(); + sockets.push(this); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + + send(payload: string): void { + const request = JSON.parse(payload) as { id: string; type: string }; + if (request.type !== "hello") return; + queueMicrotask(() => { + const event = new Event("message"); + Object.defineProperty(event, "data", { + value: JSON.stringify({ + version: 8, + id: request.id, + type: "ready", + }), + }); + this.dispatchEvent(event); + }); + } + + emitUnexpectedClose(): void { + this.readyState = FakeWebSocket.CLOSED; + this.dispatchEvent(new Event("close")); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("window", { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + vi.stubGlobal("fetch", vi.fn(async () => + new Response(JSON.stringify({ + protocol: 8, + service: "screener-client", + port: 39_721, + instanceToken: token, + nativeMedia: { + video: true, + processAudio: false, + systemAudio: false, + hardwareH264: true, + softwareVP8: true, + }, + }), { status: 200 }), + )); + + const client = await NativeClient.connect(); + expect(client).not.toBeNull(); + const unexpected = vi.fn(); + client!.onClose(unexpected); + sockets[0]!.emitUnexpectedClose(); + sockets[0]!.emitUnexpectedClose(); + expect(unexpected).toHaveBeenCalledOnce(); + + const cleanClient = await NativeClient.connect(); + expect(cleanClient).not.toBeNull(); + const intentional = vi.fn(); + cleanClient!.onClose(intentional); + cleanClient!.close(); + sockets[1]!.emitUnexpectedClose(); + expect(intentional).not.toHaveBeenCalled(); + }); + it("keeps public discovery capability-only", () => { expect( nativeHealthSchema.parse({ - protocol: 5, + protocol: 8, service: "screener-client", port: 39_721, instanceToken: "a".repeat(43), @@ -20,12 +134,13 @@ describe("native Client private wire", () => { processAudio: false, systemAudio: true, hardwareH264: true, + softwareVP8: true, }, }), ).toMatchObject({ nativeMedia: { processAudio: false } }); expect( nativeHealthSchema.safeParse({ - protocol: 5, + protocol: 7, service: "screener-client", port: 39_721, instanceToken: "a".repeat(43), @@ -34,6 +149,7 @@ describe("native Client private wire", () => { processAudio: true, systemAudio: true, hardwareH264: true, + softwareVP8: false, }, adapters: ["private"], }).success, @@ -66,11 +182,18 @@ describe("native Client private wire", () => { title: "Display 1", }).success, ).toBe(true); + expect( + nativeCaptureTargetSchema.safeParse({ + kind: "picker", + sourceId: "1", + title: "System picker", + }).success, + ).toBe(true); }); it("fences native events by share and connection identity", () => { const event = { - version: 5, + version: 8, type: "edge-state", shareId: "share_123456", connectionId: "edge_1234567", @@ -83,11 +206,23 @@ describe("native Client private wire", () => { expect( nativeEventSchema.safeParse({ ...event, routeRevision: 1 }).success, ).toBe(false); + const path = { + version: 8, + type: "edge-path", + shareId: "share_123456", + connectionId: "edge_1234567", + localType: "host", + remoteType: "srflx", + natTraversalPath: "predicted", + }; + expect(nativeEventSchema.safeParse(path).success).toBe(true); + const { natTraversalPath: _missing, ...incompletePath } = path; + expect(nativeEventSchema.safeParse(incompletePath).success).toBe(false); }); it("keeps an unavailable preview advisory instead of treating it as media failure", () => { const preview = { - version: 5, + version: 8, id: "request_preview", type: "source-preview", sourceKey: "display:65537", @@ -95,11 +230,77 @@ describe("native Client private wire", () => { data: "", }; expect(sourcePreviewResponseSchema.safeParse(preview).success).toBe(true); + expect(sourcePreviewResponseSchema.safeParse({ + ...preview, + data: Buffer.alloc(54 + 320 * 180 * 3).toString("base64"), + }).success).toBe(true); + expect(sourcePreviewResponseSchema.safeParse({ + ...preview, + data: "A".repeat(256 * 1024 + 1), + }).success).toBe(false); + }); + + it("keeps native share lifecycle responses as strict acknowledgements", () => { + const profile = { + resolution: "1440p", + maxFramerate: 60, + maxBitrate: 12_000_000, + degradationPreference: "maintain-framerate", + screenAudioQuality: "very-high", + }; + expect(shareStartedResponseSchema.safeParse({ + version: 8, + id: "request_start", + type: "share-started", + shareId: "share_123456", + audio: true, + codec: "h264", + }).success).toBe(true); + expect(shareUpdatedResponseSchema.safeParse({ + version: 8, + id: "request_update", + type: "share-updated", + shareId: "share_123456", + }).success).toBe(true); + expect(shareSourceReplacedResponseSchema.safeParse({ + version: 8, + id: "request_source", + type: "share-source-replaced", + shareId: "share_123456", + }).success).toBe(true); + expect(shareUpdatedResponseSchema.safeParse({ + version: 8, + id: "request_update", + type: "share-updated", + shareId: "share_123456", + profile, + }).success).toBe(false); + }); + + it("requires the actual Native codec instead of reporting Auto as media", () => { + const response = { + version: 8, + id: "request_receive", + type: "receive-answer", + shareId: "share_123456", + connectionId: "edge_1234567", + sdp: "v=0\r\n", + audio: false, + }; + for (const codec of ["h264", "vp8"]) { + expect(receiveAnswerResponseSchema.safeParse({ ...response, codec }).success).toBe(true); + expect(shareStartedResponseSchema.safeParse({ + version: 8, id: "request_start", type: "share-started", + shareId: response.shareId, audio: false, codec, + }).success).toBe(true); + } + expect(receiveAnswerResponseSchema.safeParse(response).success).toBe(false); + expect(receiveAnswerResponseSchema.safeParse({ ...response, codec: "auto" }).success).toBe(false); }); it("accepts only internally consistent native quality evidence", () => { const event = { - version: 5, + version: 8, type: "edge-quality", shareId: "share_123456", connectionId: "edge_1234567", diff --git a/tests/native-media-bridge.test.ts b/tests/native-media-bridge.test.ts index 5d7ce71..a878a22 100644 --- a/tests/native-media-bridge.test.ts +++ b/tests/native-media-bridge.test.ts @@ -67,7 +67,7 @@ function fixture(options: { NativeMediaBridgeControl["prepareLocalEdge"] >(async (_shareId, connectionId) => { listener?.({ - version: 5, + version: 8, type: "edge-candidate", shareId: "share_123456", connectionId, @@ -199,14 +199,14 @@ describe("native media bridge", () => { await starting; current.emit({ - version: 5, + version: 8, type: "edge-state", shareId: "share_123456", connectionId: current.bridge.connectionId, state: "failed", }); current.emit({ - version: 5, + version: 8, type: "edge-state", shareId: "share_123456", connectionId: current.bridge.connectionId, diff --git a/tests/native-media-ingress.test.ts b/tests/native-media-ingress.test.ts new file mode 100644 index 0000000..654d9d9 --- /dev/null +++ b/tests/native-media-ingress.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_QUALITY_SETTINGS, type SignalPayload } from "../src/shared/protocol"; +import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types"; +import type { HostPeerEvents } from "../src/client/webrtc/host-peer"; +import { NativeMediaIngress, type NativeMediaIngressControl } from "../src/client/native/media-ingress"; +import type { NativeClientEvent } from "../src/client/native/wire"; + +const senders = vi.hoisted(() => ({ current: null as HostPeerEvents | null })); +vi.mock("../src/client/webrtc/host-peer", () => ({ + HostPeer: class { + constructor(readonly peerId: string, _ice: unknown, _stream: unknown, _profile: unknown, readonly events: HostPeerEvents) { + senders.current = events; + } + async start() { + this.events.sendSignal(this.peerId, { kind: "candidate", connectionId: this.peerId, candidate: null }); + return this.events.sendSignal(this.peerId, { + kind: "description", connectionId: this.peerId, description: { type: "offer", sdp: "v=0\r\n" }, + }); + } + async acceptSignal(payload: SignalPayload) { + if (payload.kind === "description") this.events.onUpdate(snapshot("connected")); + } + dispose() {} + }, +})); + +function snapshot(connectionState: RTCPeerConnectionState): PeerSnapshot { + return { + peerId: "ingress-peer", connectionId: "ingress-connection", connectionState, + iceConnectionState: "connected", metrics: { ...EMPTY_METRICS }, error: null, + senderParameters: null, audioSenderParameters: null, qualityWarning: null, qualityWarningKind: null, + }; +} + +function fixture(pending = false) { + vi.stubGlobal("window", globalThis); + let listener: ((event: NativeClientEvent) => void) | null = null; + let resolveAnswer!: (value: Awaited>) => void; + const answer = { answer: { type: "answer" as const, sdp: "v=0\r\n" }, audio: false }; + const control: NativeMediaIngressControl = { + receiveOffer: vi.fn(async () => pending ? new Promise((resolve) => { resolveAnswer = resolve; }) : answer), + addReceiveCandidate: vi.fn(async () => undefined), + closeReceiver: vi.fn(async () => undefined), + onEvent: (next) => { listener = next; return () => { listener = null; }; }, + }; + const onFailed = vi.fn(); + const ingress = new NativeMediaIngress("share_123456", control, onFailed); + const stream = { getAudioTracks: () => [] } as unknown as MediaStream; + return { ingress, control, stream, onFailed, answer: () => resolveAnswer(answer), emit: (event: NativeClientEvent) => listener?.(event) }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("Browser capture Native ingress", () => { + it("queues candidates until the Native receiver exists", async () => { + const current = fixture(true); + const starting = current.ingress.start(current.stream, DEFAULT_QUALITY_SETTINGS); + expect(current.control.addReceiveCandidate).not.toHaveBeenCalled(); + current.answer(); + await starting; + expect(current.control.addReceiveCandidate).toHaveBeenCalledWith("share_123456", current.ingress.connectionId, null); + current.ingress.dispose(); + }); + + it("retires a late receiver after cancellation without reviving the owner", async () => { + const current = fixture(true); + const starting = current.ingress.start(current.stream, DEFAULT_QUALITY_SETTINGS); + const rejected = expect(starting).rejects.toThrow("closed"); + current.ingress.dispose(); + current.answer(); + await rejected; + expect(current.control.closeReceiver).toHaveBeenCalledTimes(2); + expect(current.control.addReceiveCandidate).not.toHaveBeenCalled(); + expect(current.onFailed).not.toHaveBeenCalled(); + }); + + it("reports loss of the active ingress once", async () => { + const current = fixture(); + await current.ingress.start(current.stream, DEFAULT_QUALITY_SETTINGS); + senders.current!.onUpdate(snapshot("failed")); + senders.current!.onUpdate(snapshot("failed")); + expect(current.onFailed).toHaveBeenCalledOnce(); + expect(current.control.closeReceiver).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/native-host-edge.test.ts b/tests/native-sender-edge.test.ts similarity index 63% rename from tests/native-host-edge.test.ts rename to tests/native-sender-edge.test.ts index 309d26b..40854aa 100644 --- a/tests/native-host-edge.test.ts +++ b/tests/native-sender-edge.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { SignalPayload } from "../src/shared/protocol"; import { - NativeHostEdge, + NativeSenderEdge, type NativeEdgeControl, -} from "../src/client/native/host-edge"; +} from "../src/client/native/native-sender-edge"; import type { NativeClientEvent } from "../src/client/native/wire"; -function fixture() { +function fixture(natPrediction = false) { let listener: ((event: NativeClientEvent) => void) | null = null; const prepareEdge = vi.fn(async () => ({ type: "offer", @@ -26,11 +26,20 @@ function fixture() { }; const sent: SignalPayload[] = []; const states: RTCPeerConnectionState[] = []; - const edge = new NativeHostEdge( + const edge = new NativeSenderEdge( "viewer_123456", "edge_12345678", "share_1234567", - { iceServers: [], natPredictionStunUrls: [] }, + natPrediction + ? { + iceServers: [{ urls: "stun:share.example.test:3478" }], + natPredictionStunUrls: [ + "stun:share.example.test:3479", + "stun:share.example.test:3480", + ], + } + : { iceServers: [], natPredictionStunUrls: [] }, + natPrediction, control, { sendSignal: (_peerId, payload) => { @@ -49,13 +58,13 @@ function fixture() { }; } -describe("native Host edge adapter", () => { +describe("native sender edge adapter", () => { it("sends the offer before candidates gathered during preparation", async () => { const current = fixture(); const prepare = vi.mocked(current.control.prepareEdge); prepare.mockImplementationOnce(async () => { current.emit({ - version: 5, + version: 8, type: "edge-candidate", shareId: "share_1234567", connectionId: "edge_12345678", @@ -76,7 +85,7 @@ describe("native Host edge adapter", () => { const current = fixture(); expect(await current.edge.start()).toBe(true); current.emit({ - version: 5, + version: 8, type: "edge-state", shareId: "share_1234567", connectionId: "edge_12345678", @@ -96,4 +105,29 @@ describe("native Host edge adapter", () => { }); expect(current.control.acceptSignal).toHaveBeenCalledOnce(); }); + + it("reuses the Site survey for Native candidate prediction", async () => { + const current = fixture(true); + expect(await current.edge.start()).toBe(true); + expect(vi.mocked(current.control.prepareEdge).mock.calls[0]?.[2].iceServers) + .toHaveLength(3); + for (const [index, port] of [40_000, 40_003, 40_006].entries()) { + current.emit({ + version: 8, + type: "edge-candidate", + shareId: "share_1234567", + connectionId: "edge_12345678", + candidate: { + candidate: `candidate:ns${index + 1} 1 udp 1 203.0.113.7 ${port} typ srflx`, + }, + }); + } + const candidates = current.sent.flatMap((payload) => + payload.kind === "candidate" && payload.candidate + ? [payload.candidate.candidate] + : [], + ); + expect(candidates.filter((candidate) => /^candidate:s[pm]\d+ /.test(candidate))) + .toHaveLength(8); + }); }); diff --git a/tests/native-host-peer.test.ts b/tests/native-sender-peer.test.ts similarity index 64% rename from tests/native-host-peer.test.ts rename to tests/native-sender-peer.test.ts index 3b29ba2..228c4ca 100644 --- a/tests/native-host-peer.test.ts +++ b/tests/native-sender-peer.test.ts @@ -4,13 +4,16 @@ import { invalidateSenderQualityEvidence, senderQualityEvidenceFromSnapshot, } from "../src/client/media/sender-quality-evidence"; -import { NativeHostPeer } from "../src/client/native/native-host-peer"; -import type { NativeEdgeControl } from "../src/client/native/host-edge"; +import { + NativeSenderPeer, + shouldUseBrowserQualityCandidate, +} from "../src/client/native/native-sender-peer"; +import type { NativeEdgeControl } from "../src/client/native/native-sender-edge"; import type { NativeClientEvent } from "../src/client/native/wire"; import type { PeerSnapshot } from "../src/client/types"; describe("native Host peer quality", () => { - it("maps exact Pion quality windows into the existing sender evidence", async () => { + it.each(["h264", "vp8"] as const)("maps %s Pion quality windows into the existing sender evidence", async (codec) => { invalidateSenderQualityEvidence(); let listener: (event: NativeClientEvent) => void = () => undefined; const control: NativeEdgeControl = { @@ -28,20 +31,40 @@ describe("native Host peer quality", () => { }), }; const snapshots: PeerSnapshot[] = []; - const peer = new NativeHostPeer( + const peer = new NativeSenderPeer( "viewer_123456", "edge_12345678", "share_1234567", { iceServers: [], natPredictionStunUrls: [] }, + false, control, { sendSignal: () => true, onUpdate: (snapshot) => snapshots.push(snapshot), }, + codec, ); expect(await peer.start()).toBe(true); + expect(shouldUseBrowserQualityCandidate(peer, { + childPeerId: "viewer_123456", + connectionId: "candidate_123456", + transport: "direct", + qualityProbe: true, + })).toBe(true); + expect(shouldUseBrowserQualityCandidate(peer, { + childPeerId: "viewer_123456", + connectionId: "candidate_123456", + transport: "direct", + qualityProbe: false, + })).toBe(false); + expect(shouldUseBrowserQualityCandidate(undefined, { + childPeerId: "viewer_123456", + connectionId: "candidate_123456", + transport: "direct", + qualityProbe: true, + })).toBe(false); listener({ - version: 5, + version: 8, type: "edge-state", shareId: "share_1234567", connectionId: "edge_12345678", @@ -49,7 +72,7 @@ describe("native Host peer quality", () => { }); const quality = { - version: 5 as const, + version: 8 as const, type: "edge-quality" as const, shareId: "share_1234567", connectionId: "edge_12345678", @@ -71,6 +94,12 @@ describe("native Host peer quality", () => { "unknown", ); listener({ ...quality, sampleTimestampMs: 12_000 }); + expect(snapshots.at(-1)?.metrics).toMatchObject({ + codec: `video/${codec.toUpperCase()}`, + codecProfile: null, + codecParameters: null, + powerEfficientEncoder: codec === "h264", + }); expect(senderQualityEvidenceFromSnapshot(snapshots.at(-1)!, 7)).toMatchObject({ state: "degraded", diagnostics: { diff --git a/tests/native-viewer-peer.test.ts b/tests/native-viewer-peer.test.ts new file mode 100644 index 0000000..80583df --- /dev/null +++ b/tests/native-viewer-peer.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { + NativeCapableViewerPeer, + offerHasNativeVideoCodec, +} from "../src/client/native/native-viewer-peer"; + +it("admits one active sending H264 or VP8 video section, not unrelated SDP codec text", () => { + const video = (codec: string, direction = "sendonly", port = 9, payload = 96) => [ + `m=video ${port} UDP/TLS/RTP/SAVPF ${payload}`, + `a=${direction}`, + `a=rtpmap:96 ${codec}/90000`, + "", + ].join("\r\n"); + expect(offerHasNativeVideoCodec(video("H264"))).toBe(true); + expect(offerHasNativeVideoCodec(video("VP8"))).toBe(true); + expect(offerHasNativeVideoCodec(video("VP8", "sendrecv"))).toBe(true); + for (const offer of [ + video("VP8", "recvonly"), video("VP8", "inactive"), + video("VP8", "sendonly", 0), video("VP8", "sendonly", 9, 97), + video("VP9"), video("H264") + video("VP8"), + "m=audio 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 VP8/90000\r\n", + ]) expect(offerHasNativeVideoCodec(offer)).toBe(false); +}); + +it("does not create a browser backend after deferred native discovery is disposed", async () => { + let resolveClient: (value: null) => void = () => undefined; + const nativeClient = new Promise((resolve) => { + resolveClient = resolve; + }); + const peer = new NativeCapableViewerPeer( + { iceServers: [] }, + { + sendSignal: () => true, + sendRestartRequest: () => true, + onStream: () => undefined, + onUpdate: () => undefined, + }, + {}, + nativeClient, + "session", + 2, + ); + const accepted = peer.acceptSignal("parent", { + kind: "description", + connectionId: "connection", + description: { + type: "offer", + sdp: [ + "v=0", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=sendonly", + "a=rtpmap:96 H264/90000", + "", + ].join("\r\n"), + }, + }); + peer.dispose(); + resolveClient(null); + await accepted; + expect(peer.getConnectionIdentity()).toBeNull(); +}); diff --git a/tests/peer-assisted-benchmark.test.ts b/tests/peer-assisted-benchmark.test.ts deleted file mode 100644 index 6df4f74..0000000 --- a/tests/peer-assisted-benchmark.test.ts +++ /dev/null @@ -1,1714 +0,0 @@ -import { runInNewContext } from "node:vm"; -import { describe, expect, it, vi } from "vitest"; -import { WebSocketServer } from "ws"; - -import { - CdpConnection, - activeVideoEdgeCount, - buildBenchmarkFailureEvidence, - buildBenchmarkInitScript, - buildRecoveryCheck, - buildRouteTimingCheck, - buildRunChecks, - captureRecoveryViewerBaselines, - createPage, - everyViewerAdvanced, - everyViewerRecoveredMedia, - joinViewerBurst, - mergeRecoveryHostPeaks, - parseBenchmarkCanaryMode, - parseBenchmarkCodecMode, - parseBenchmarkConfig, - parseExpectedEndpointCap, - parseExternalServerTarget, - parsePagePerformanceMetrics, - parseViewerCounts, - sanitizeFailurePageEvidence, - summarizeBenchmarkRouteTiming, - summarizePageRuntimeResources, - summarizeSamples, - summarizeViewerExperienceByDepth, -} from "../scripts/peer-assisted-benchmark"; -import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../src/shared/protocol"; -import type { - MediaRouteUpstream, - ParticipantRouteAssignment, -} from "../src/shared/protocol"; - -const lowQualitySettings = { - resolution: "720p", - maxFramerate: 30, - maxBitrate: 3_000_000, - degradationPreference: "maintain-resolution", -} as const; - -interface Deferred { - promise: Promise; - resolve: (value: T) => void; -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function page( - role: "host" | "viewer", - label: string, - sendEdges: number, - receiveEdges: number, -) { - return { - label, - role, - viewerIndex: role === "viewer" ? Number(label.split("-")[1]) : null, - roomId: "1", - peerId: `${label}-peer`, - authenticatedAtEpochMs: 1_000, - authenticateSentAtEpochMs: 900, - signalingConnected: true, - qualitySettings: lowQualitySettings, - maxActiveOutboundMediaEdges: sendEdges, - maxAssignedChildren: sendEdges, - routeRevision: 1 as number | null, - routeAssignment: { - upstream: - role === "viewer" - ? ({ kind: "peer", peerId: "parent-peer" } as MediaRouteUpstream) - : ({ kind: "none" } as MediaRouteUpstream), - childPeerIds: Array.from( - { length: sendEdges }, - (_, index) => `child-peer-${index}`, - ), - sfuPublicationGeneration: null as string | null, - }, - firstDecodedAtEpochMs: role === "viewer" ? 1_500 : null, - firstRenderedAtEpochMs: role === "viewer" ? 1_550 : null, - renderedFrames: role === "viewer" ? 10 : 0, - connections: [ - ...Array.from({ length: sendEdges }, (_, index) => ({ - index, - createdAtEpochMs: 1_000, - connectionId: `send-${index}` as string | null, - remotePeerId: `child-${index}` as string | null, - connectionState: "connected", - iceConnectionState: "connected", - hasOutboundVideo: true, - hasInboundVideo: false, - send: {}, - receive: null, - sendTotals: { id: `send-${index}`, framesTotal: 10, bytesTotal: 100 }, - receiveTotals: null, - error: undefined as string | undefined, - })), - ...Array.from({ length: receiveEdges }, (_, index) => ({ - index: sendEdges + index, - createdAtEpochMs: 1_000, - connectionId: `receive-${index}` as string | null, - remotePeerId: "parent-peer", - connectionState: "connected", - iceConnectionState: "connected", - hasOutboundVideo: false, - hasInboundVideo: true, - send: null, - receive: {}, - sendTotals: null, - receiveTotals: { id: `receive-${index}`, framesTotal: 10, bytesTotal: 100 }, - error: undefined as string | undefined, - })), - ], - }; -} - -class FakeWebSocket { - readonly sent: string[] = []; - private readonly listeners = new Map< - string, - Array<(event: { data?: string }) => void> - >(); - - send(data: string): void { - this.sent.push(data); - } - - addEventListener( - type: string, - listener: (event: { data?: string }) => void, - ): void { - const listeners = this.listeners.get(type) ?? []; - listeners.push(listener); - this.listeners.set(type, listeners); - } - - emitMessage(message: unknown): void { - for (const listener of this.listeners.get("message") ?? []) { - listener({ data: JSON.stringify(message) }); - } - } - - emitClose(): void { - for (const listener of this.listeners.get("close") ?? []) { - listener({}); - } - } -} - -interface ObserverSnapshot { - peerId: string | null; - signalingConnected: boolean; - routeRevision: number | null; - routeAssignment: ParticipantRouteAssignment | null; - maxAssignedChildren: number; -} - -function createObserverHarness( - expectedEndpointCap = 2, - role: "host" | "viewer" = "host", -) { - const context: Record = { - WebSocket: FakeWebSocket, - RTCPeerConnection: class FakePeerConnection {}, - navigator: { mediaDevices: {} }, - document: { querySelector: () => null }, - localStorage: { removeItem: () => undefined }, - performance: { timeOrigin: 0 }, - setInterval: () => 1, - clearInterval: () => undefined, - queueMicrotask, - }; - const source = buildBenchmarkInitScript({ - label: role === "host" ? "host-1" : "viewer-1", - role, - viewerIndex: role === "viewer" ? 1 : null, - clearHostRoom: false, - width: 1280, - height: 720, - frameRate: 30, - expectedEndpointCap, - }).replace( - 'const statsModule = import("/src/client/webrtc/stats.ts");', - "const statsModule = new Promise(() => undefined);", - ); - runInNewContext(source, context); - const WebSocketConstructor = context.WebSocket as new () => FakeWebSocket; - const api = context.__SCREENER_BENCHMARK__ as { - snapshot: () => ObserverSnapshot; - canarySnapshot: () => Record; - requestRouteDiagnosticSnapshot: () => boolean; - routeDiagnosticTimingSamples: () => Record< - string, - Array - > | null; - }; - return { - socket: () => new WebSocketConstructor(), - snapshot: () => structuredClone(api.snapshot()), - canarySnapshot: () => structuredClone(api.canarySnapshot()), - requestRouteDiagnosticSnapshot: () => - api.requestRouteDiagnosticSnapshot(), - routeDiagnosticTimingSamples: () => - structuredClone(api.routeDiagnosticTimingSamples()), - }; -} - -function authenticate( - socket: FakeWebSocket, - role: "host" | "viewer" = "host", -): void { - socket.send( - JSON.stringify({ - type: "authenticate", - roomId: "1", - role, - }), - ); -} - -function routeAssignment( - childPeerIds: string[], - generation: string | null = null, -): ParticipantRouteAssignment { - return { - upstream: { kind: "none" }, - childPeerIds, - sfuPublicationGeneration: generation, - }; -} - -function markSfuMedia(observation: ReturnType): void { - const connection = observation.connections.find((candidate) => - observation.role === "host" - ? candidate.hasOutboundVideo - : candidate.hasInboundVideo, - ); - if (!connection) { - throw new Error("An SFU media connection is required"); - } - connection.connectionId = null; - connection.remotePeerId = null; -} - -describe("peer topology loopback configuration", () => { - it("bounds a CDP command that never receives a response", async () => { - const server = new WebSocketServer({ port: 0 }); - await new Promise((resolveListening) => { - server.once("listening", resolveListening); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Unexpected WebSocket address"); - } - const cdp = await CdpConnection.connect( - `ws://127.0.0.1:${address.port}`, - 500, - ); - await expect(cdp.call("Never.responds")).rejects.toThrow( - "CDP command timed out: Never.responds", - ); - await cdp.close(); - await new Promise((resolveClose, rejectClose) => { - server.close((error) => error ? rejectClose(error) : resolveClose()); - }); - }); - - it("keeps the real-relay canary opt-in", () => { - expect(parseBenchmarkCanaryMode(undefined)).toBe("none"); - expect(parseBenchmarkCanaryMode("viewer-mbb")).toBe("viewer-mbb"); - expect(parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_CANARY: "viewer-mbb", - BENCHMARK_VIEWERS: "3", - }).canaryMode).toBe("viewer-mbb"); - expect(() => parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_CANARY: "viewer-mbb", - })).toThrow(/selected viewer count of 3/); - expect(() => parseBenchmarkCanaryMode("signaling")).toThrow(/BENCHMARK_CANARY/); - }); - - it("runs the exact 20-Viewer acceptance case by default", () => { - expect(parseViewerCounts(undefined)).toEqual([ - MAX_VIEWERS_PER_ROOM_LIMIT, - ]); - }); - - it("deduplicates configured viewer counts without changing their order", () => { - expect(parseViewerCounts("8, 3,3,1")).toEqual([8, 3, 1]); - }); - - it("accepts the room viewer ceiling and rejects values above it", () => { - expect(parseViewerCounts(String(MAX_VIEWERS_PER_ROOM_LIMIT))).toEqual([ - MAX_VIEWERS_PER_ROOM_LIMIT, - ]); - expect(() => - parseViewerCounts(String(MAX_VIEWERS_PER_ROOM_LIMIT + 1)), - ).toThrow( - new RegExp(`1 to ${MAX_VIEWERS_PER_ROOM_LIMIT}`), - ); - }); - - it("starts every Viewer join before waiting for burst media", async () => { - const creations = Array.from({ length: 3 }, () => - createDeferred(), - ); - const media = Array.from({ length: 3 }, () => createDeferred()); - const creationStarts: number[] = []; - const mediaStarts: number[] = []; - - const burst = joinViewerBurst( - 3, - (viewerIndex) => { - creationStarts.push(viewerIndex); - return creations[viewerIndex - 1]!.promise; - }, - (_viewer, viewerIndex) => { - mediaStarts.push(viewerIndex); - return media[viewerIndex - 1]!.promise; - }, - ); - expect(creationStarts).toEqual([1, 2, 3]); - expect(mediaStarts).toEqual([]); - - creations[0]!.resolve("viewer-1"); - creations[1]!.resolve("viewer-2"); - await Promise.resolve(); - expect(mediaStarts).toEqual([]); - creations[2]!.resolve("viewer-3"); - await vi.waitFor(() => expect(mediaStarts).toEqual([1, 2, 3])); - - media[2]!.resolve(); - media[0]!.resolve(); - media[1]!.resolve(); - await expect(burst).resolves.toEqual([ - "viewer-1", - "viewer-2", - "viewer-3", - ]); - }); - - it("summarizes all four route timing keys without a pass threshold", () => { - const values = Array.from({ length: 20 }, (_, index) => - index % 5 === 0 ? null : (20 - index) * 10, - ); - const summary = summarizeBenchmarkRouteTiming({ - queueWaitMs: values, - candidateStartMs: values, - firstDecodedFrameMs: values, - finalMs: values, - }); - - for (const timing of Object.values(summary)) { - expect(timing).toEqual({ - sampleCount: 16, - pendingCount: 4, - rawMs: [ - 10, 20, 30, 40, 60, 70, 80, 90, 110, 120, 130, 140, 160, - 170, 180, 190, - ], - p50Ms: 90, - p95Ms: 190, - maxMs: 190, - }); - expect(Object.keys(timing)).not.toContain("passed"); - } - expect(buildRouteTimingCheck("captured", summary)).toMatchObject({ - passed: true, - actual: 4, - }); - expect(buildRouteTimingCheck("unavailable", summary).passed).toBe(false); - - const incomplete = { - ...summary, - finalMs: { ...summary.finalMs, pendingCount: 3 }, - }; - expect(buildRouteTimingCheck("captured", incomplete)).toMatchObject({ - passed: false, - actual: 3, - }); - }); - - it("closes the exact CDP target when page initialization fails", async () => { - const call = vi.fn( - async (method: string): Promise> => { - if (method === "Target.createTarget") { - return { targetId: "target-viewer-17" }; - } - if (method === "Target.attachToTarget") { - return { sessionId: "session-viewer-17" }; - } - if (method === "Page.navigate") { - return { errorText: "navigation rejected" }; - } - return {}; - }, - ); - - await expect( - createPage( - { call } as unknown as Parameters[0], - "http://127.0.0.1:3000/r/1234", - { - label: "viewer-17", - role: "viewer", - viewerIndex: 17, - clearHostRoom: false, - width: 1280, - height: 720, - frameRate: 30, - expectedEndpointCap: 2, - }, - new AbortController().signal, - ), - ).rejects.toThrow("Navigation failed: navigation rejected"); - expect(call).toHaveBeenCalledWith("Target.closeTarget", { - targetId: "target-viewer-17", - }); - }); - - it("requires recovery to target one selected case", () => { - expect(() => - parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_VIEWERS: "1,3", - BENCHMARK_RECOVERY_VIEWERS: "5", - }), - ).toThrow(/selected viewer count/); - }); - - it("defaults to cap two and accepts the deployment range", () => { - expect(parseExpectedEndpointCap(undefined)).toBe(2); - expect(parseExpectedEndpointCap("1")).toBe(1); - expect(parseExpectedEndpointCap("3")).toBe(3); - for (const value of ["0", "2.5", "4"]) { - expect(() => parseExpectedEndpointCap(value)).toThrow(/integer from 1 to 3/); - } - }); - - it("uses one explicit benchmark codec mode", () => { - expect(parseBenchmarkCodecMode(undefined)).toBe("auto"); - expect(parseBenchmarkCodecMode("VP8")).toBe("vp8"); - expect(parseBenchmarkCodecMode("h264")).toBe("h264"); - expect(() => parseBenchmarkCodecMode("vp9")).toThrow( - /vp8, auto, or h264/, - ); - }); - - it("requires a relay-sized case for the optional quality control smoke", () => { - expect(() => - parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_VIEWERS: "1", - BENCHMARK_QUALITY_SMOKE: "1", - }), - ).toThrow(/at least 3/); - expect( - parseBenchmarkConfig({ - CHROME_PATH: "chrome", - BENCHMARK_VIEWERS: "3", - BENCHMARK_QUALITY_SMOKE: "1", - }).qualityControlSmoke, - ).toBe(true); - }); - - it("writes a report file by default and reserves '-' for stdout", () => { - expect(parseBenchmarkConfig({ CHROME_PATH: "chrome" }).outputPath).toBe( - "benchmark-results/peer-assisted.json", - ); - expect( - parseBenchmarkConfig({ CHROME_PATH: "chrome", BENCHMARK_OUTPUT: "-" }) - .outputPath, - ).toBeNull(); - }); - - it("accepts one plain external server origin without exposing its access value", () => { - expect(parseExternalServerTarget({ - BENCHMARK_SERVER_URL: "http://localhost:8787/", - BENCHMARK_SITE_ACCESS_PASSWORD: "local-access-password", - })).toEqual({ - origin: "http://localhost:8787", - accessPassword: "local-access-password", - }); - expect(parseExternalServerTarget({})).toBeNull(); - for (const value of [ - "file:///tmp/screener", - "https://user:pass@share.test", - "https://share.test/room", - ]) { - expect(() => parseExternalServerTarget({ - BENCHMARK_SERVER_URL: value, - })).toThrow(/HTTP or HTTPS origin/); - } - }); -}); - -describe("peer topology loopback observations", () => { - it("bounds failure evidence and removes media-path identifiers and raw errors", () => { - const unsafe = page("viewer", "viewer-11", 0, 1); - unsafe.roomId = "room-secret"; - unsafe.peerId = "peer-secret"; - unsafe.routeAssignment.upstream = { - kind: "peer", - peerId: "parent-secret", - }; - unsafe.connections = Array.from({ length: 9 }, (_, index) => ({ - ...structuredClone(unsafe.connections[0]!), - index, - connectionId: `connection-secret-${index}`, - remotePeerId: `remote-secret-${index}`, - error: `private-error-${index}`, - })); - - const failure = buildBenchmarkFailureEvidence([unsafe], 21); - expect(failure).toMatchObject({ - expectedPageCount: 21, - observedPageCount: 1, - pages: [ - { - label: "viewer-11", - authenticated: true, - upstreamKind: "peer", - connectionCount: 9, - }, - ], - }); - expect(failure.pages[0]?.connections).toHaveLength(6); - expect(sanitizeFailurePageEvidence(unsafe)).toEqual(failure.pages[0]); - const serialized = JSON.stringify(failure); - for (const secret of [ - "room-secret", - "peer-secret", - "parent-secret", - "connection-secret", - "remote-secret", - "private-error", - ]) { - expect(serialized).not.toContain(secret); - } - }); - - it("requests one Host-only route snapshot and retains only timing samples", () => { - const observer = createObserverHarness(); - const socket = observer.socket(); - authenticate(socket); - socket.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 1, - routeAssignment: routeAssignment([]), - qualitySettings: lowQualitySettings, - }); - - expect(observer.requestRouteDiagnosticSnapshot()).toBe(true); - expect(observer.requestRouteDiagnosticSnapshot()).toBe(false); - expect( - socket.sent - .map((value) => JSON.parse(value) as { type?: string }) - .filter((message) => message.type === "request-route-diagnostic"), - ).toHaveLength(1); - - const children = Array.from( - { length: MAX_VIEWERS_PER_ROOM_LIMIT }, - (_, index) => ({ - ordinal: index + 1, - ...(index === 0 - ? { - peerId: "must_not_escape", - connectionId: "must_not_escape", - } - : {}), - queueWaitMs: index === 0 ? 30 : null, - candidateStartMs: index === 0 ? 50 : index === 1 ? 20 : null, - firstDecodedFrameMs: index === 0 ? 80 : null, - finalMs: index === 0 ? 90 : null, - }), - ); - socket.emitMessage({ - type: "route-diagnostic-snapshot", - snapshot: { children }, - }); - - const timing = observer.routeDiagnosticTimingSamples(); - expect(timing?.queueWaitMs).toHaveLength(MAX_VIEWERS_PER_ROOM_LIMIT); - expect(timing?.queueWaitMs.slice(0, 2)).toEqual([30, null]); - expect(timing?.candidateStartMs.slice(0, 2)).toEqual([50, 20]); - expect(timing?.firstDecodedFrameMs.slice(0, 2)).toEqual([80, null]); - expect(timing?.finalMs.slice(0, 2)).toEqual([90, null]); - expect( - JSON.stringify(observer.routeDiagnosticTimingSamples()), - ).not.toContain("must_not_escape"); - }); - - it("captures a partial failure snapshot without satisfying exact 20", () => { - const observer = createObserverHarness(); - const socket = observer.socket(); - authenticate(socket); - socket.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 1, - routeAssignment: routeAssignment([]), - qualitySettings: lowQualitySettings, - }); - expect(observer.requestRouteDiagnosticSnapshot()).toBe(true); - socket.emitMessage({ - type: "route-diagnostic-snapshot", - snapshot: { - children: Array.from( - { length: MAX_VIEWERS_PER_ROOM_LIMIT - 1 }, - (_, index) => ({ - ordinal: index + 1, - queueWaitMs: null, - candidateStartMs: null, - firstDecodedFrameMs: null, - finalMs: null, - }), - ), - }, - }); - const timing = observer.routeDiagnosticTimingSamples(); - expect(timing?.queueWaitMs).toHaveLength( - MAX_VIEWERS_PER_ROOM_LIMIT - 1, - ); - expect( - buildRouteTimingCheck( - "captured", - summarizeBenchmarkRouteTiming( - timing as Parameters[0], - ), - ).passed, - ).toBe(false); - }); - - it("does not expose the route diagnostic request on a Viewer page", () => { - const observer = createObserverHarness(2, "viewer"); - const socket = observer.socket(); - authenticate(socket, "viewer"); - socket.emitMessage({ - type: "authenticated", - role: "viewer", - peerId: "viewer_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 1, - routeAssignment: { - upstream: { kind: "peer", peerId: "host_peer_0001" }, - childPeerIds: [], - sfuPublicationGeneration: null, - }, - qualitySettings: lowQualitySettings, - }); - - expect(observer.requestRouteDiagnosticSnapshot()).toBe(false); - expect( - socket.sent - .map((value) => JSON.parse(value) as { type?: string }) - .some((message) => message.type === "request-route-diagnostic"), - ).toBe(false); - }); - - it.each([ - ["an SFU upstream without a generation", { kind: "sfu" }, null], - [ - "a peer upstream with a generation", - { kind: "peer", peerId: "host_peer_0001" }, - "publication_generation_12345678", - ], - ])("rejects %s in the v9 route observer", (_label, upstream, generation) => { - const observer = createObserverHarness(2, "viewer"); - const socket = observer.socket(); - authenticate(socket, "viewer"); - socket.emitMessage({ - type: "authenticated", - role: "viewer", - peerId: "viewer_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 1, - routeAssignment: { - upstream, - childPeerIds: [], - sfuPublicationGeneration: generation, - }, - qualitySettings: lowQualitySettings, - }); - - expect(observer.snapshot()).toMatchObject({ - routeRevision: null, - routeAssignment: null, - }); - }); - - it("counts only active media connections in the requested direction", () => { - const host = page("host", "host", 2, 0); - host.connections[0]!.connectionState = "closed"; - expect(activeVideoEdgeCount(host, "send")).toBe(1); - expect(activeVideoEdgeCount(host, "receive")).toBe(0); - }); - - it("summarizes raw fanout and first-frame fields without a score", () => { - const initialPages = [ - page("host", "host", 2, 0), - page("viewer", "viewer-1", 1, 1), - page("viewer", "viewer-2", 0, 1), - page("viewer", "viewer-3", 0, 1), - ]; - const finalPages = structuredClone(initialPages); - for (const viewer of finalPages.filter((entry) => entry.role === "viewer")) { - viewer.connections.at(-1)!.receiveTotals!.framesTotal = 20; - } - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initialPages }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: finalPages }, - ], - 3, - ); - expect(summary.maxHostActiveMediaEdges).toBe(2); - expect(summary.maxRelayActiveMediaEdges).toBe(1); - expect(summary.everyViewerDecoded).toBe(true); - expect(summary.firstFrames[0]?.decodedAfterAuthenticateMs).toBe(600); - expect(summary.maxFirstDecodedAfterAuthenticateMs).toBe(600); - expect(summary.senderEvidence.host.bitrateKbps.mean).toBeNull(); - expect(summary.browserProcessResources.measuredCpuTimeSeconds).toBeNull(); - expect( - summary.finalTopology.every( - (entry) => - JSON.stringify(entry.qualitySettings) === - JSON.stringify(lowQualitySettings), - ), - ).toBe(true); - }); - - it("summarizes identified sender evidence and rejects reset process intervals", () => { - const makePages = (bitrate: number, fps: number, intervalFrames: number | null, intervalTime: number | null, reason: string) => { - const pages = [page("host", "host", 1, 0), page("viewer", "viewer-1", 1, 1)]; - pages[0]!.connections[0]!.send = { - rtpStatsId: "host-rtp", - bitrateKbps: bitrate, - framesPerSecond: fps, - captureFramesPerSecond: 30, - mediaSourceFramesPerSecond: 29, - resolution: fps === 25 ? "640x360" : "1280x720", - codec: "video/VP8", - encoderImplementation: "test-encoder", - powerEfficientEncoder: true, - scalabilityMode: "L1T2", - availableOutgoingKbps: bitrate * 4, - intervalFramesEncoded: intervalFrames, - intervalEncodeTimeMs: intervalTime, - qualityLimitationReason: reason, - }; - pages[1]!.connections[0]!.send = { - ...pages[0]!.connections[0]!.send, - rtpStatsId: "relay-rtp", - bitrateKbps: bitrate / 2, - }; - return pages; - }; - const processSample = (browserCpu: number, rendererCpu: number) => ({ processes: [ - { type: "browser", id: 1, cpuTimeSeconds: browserCpu }, { type: "renderer", id: 2, cpuTimeSeconds: rendererCpu }, - ] }); - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: makePages(1_000, 25, null, null, "none"), browserProcesses: processSample(10, 2) }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: makePages(1_200, 30, 20, 40, "none"), browserProcesses: processSample(10.4, 2.6) }, - { atEpochMs: 6_000, elapsedMs: 4_000, pages: makePages(1_400, 30, 30, 75, "cpu"), browserProcesses: processSample(9, 3) }, - ], - 1, - ); - - expect(summary.senderEvidence.host).toMatchObject({ - uniqueSenderCount: 1, - unknownIdentitySamples: 0, - bitrateKbps: { sampleCount: 3, min: 1_000, max: 1_400, mean: 1_200 }, - framesPerSecond: { sampleCount: 3, min: 25, max: 30 }, - captureFramesPerSecond: { sampleCount: 3, min: 30, max: 30 }, - mediaSourceFramesPerSecond: { sampleCount: 3, min: 29, max: 29 }, - resolutions: ["1280x720", "640x360"], - codecs: ["video/VP8"], - encoderImplementations: ["test-encoder"], - scalabilityModes: ["L1T2"], - powerEfficientEncoderSamples: { true: 3, false: 0, unknown: 0 }, - encodeIntervals: { sampleCount: 2, framesEncoded: 50, encodeTimeMs: 115 }, - qualityLimitationReasonSamples: { none: 2, cpu: 1 }, - }); - expect(summary.senderEvidence.host.encodeIntervals.meanEncodeMsPerFrame).toBe(2.3); - expect(summary.senderEvidence.relay.bitrateKbps.mean).toBe(600); - expect(summary.browserProcessResources).toMatchObject({ - validCpuIntervals: 1, - invalidCpuIntervals: 1, - measuredWallTimeSeconds: 2, - peakResidentSetBytes: null, - }); - expect(summary.browserProcessResources.measuredCpuTimeSeconds).toBeCloseTo(1); - expect(summary.browserProcessResources.averageCpuUtilizationPercent).toBeCloseTo(50); - }); - - it("parses and summarizes per-page main-thread resource deltas", () => { - const metrics = ( - timestamp: number, - task: number, - script: number, - layout: number, - recalc: number, - heap: number, - ) => ({ - timestampSeconds: timestamp, - taskDurationSeconds: task, - scriptDurationSeconds: script, - layoutDurationSeconds: layout, - recalcStyleDurationSeconds: recalc, - jsHeapUsedBytes: heap, - }); - expect( - parsePagePerformanceMetrics([ - { name: "TaskDuration", value: 1 }, - { name: "Timestamp", value: 10 }, - { name: "ScriptDuration", value: 0.4 }, - { name: "LayoutDuration", value: 0.1 }, - { name: "RecalcStyleDuration", value: 0.05 }, - { name: "JSHeapUsedSize", value: 10_000 }, - { name: "UnrelatedMetric", value: 99 }, - ]), - ).toEqual(metrics(10, 1, 0.4, 0.1, 0.05, 10_000)); - expect(parsePagePerformanceMetrics([])).toBeNull(); - - const before = [ - { - ...page("host", "host", 2, 0), - performance: metrics(10, 1, 0.4, 0.1, 0.05, 10_000), - }, - { - ...page("viewer", "viewer-1", 0, 1), - performance: metrics(10, 2, 0.8, 0.2, 0.1, 20_000), - }, - { - ...page("viewer", "viewer-2", 1, 1), - performance: metrics(10, 3, 1.2, 0.3, 0.15, 30_000), - }, - { ...page("viewer", "viewer-3", 0, 1), performance: null }, - ]; - const after = structuredClone(before); - after[0]!.performance = metrics(12, 1.4, 0.55, 0.12, 0.07, 12_000); - after[1]!.performance = metrics(12, 2.1, 0.84, 0.21, 0.11, 22_000); - after[2]!.performance = metrics(12, 3.3, 1.3, 0.33, 0.17, 33_000); - - const summary = summarizePageRuntimeResources([ - { atEpochMs: 2_000, elapsedMs: 0, pages: before }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: after }, - ]); - expect(summary.host.validIntervals).toBe(1); - expect(summary.host.averageTaskUtilizationPercent).toBeCloseTo(20); - expect(summary.host.peakJsHeapUsedBytes).toBe(12_000); - expect(summary.viewer.averageTaskUtilizationPercent).toBeCloseTo(5); - expect(summary.viewer.invalidIntervals).toBe(0); - expect(summary.relay.averageTaskUtilizationPercent).toBeCloseTo(15); - expect(summary.host.scriptTimeSeconds).toBeCloseTo(0.15); - expect(summary.host.layoutTimeSeconds).toBeCloseTo(0.02); - expect(summary.host.recalcStyleTimeSeconds).toBeCloseTo(0.02); - }); - - it("groups exact committed receive quality by Peer depth and SFU route", () => { - const host = page("host", "host", 1, 0); - const root = page("viewer", "viewer-1", 1, 1); - const leaf = page("viewer", "viewer-2", 0, 1); - const sfu = page("viewer", "viewer-3", 0, 1); - const unresolved = page("viewer", "viewer-4", 0, 1); - root.routeAssignment.upstream = { - kind: "peer", - peerId: host.peerId, - }; - leaf.routeAssignment.upstream = { - kind: "peer", - peerId: root.peerId, - }; - sfu.routeAssignment = { - upstream: { kind: "sfu" }, - childPeerIds: [], - sfuPublicationGeneration: "sfu-generation", - }; - unresolved.routeAssignment.upstream = { - kind: "peer", - peerId: "missing-peer", - }; - const receive = ( - entry: ReturnType, - remotePeerId: string | null, - metrics: Record, - ) => { - const connection = entry.connections.find( - (candidate) => candidate.hasInboundVideo, - )!; - connection.remotePeerId = remotePeerId; - connection.connectionId = remotePeerId ? connection.connectionId : null; - connection.receive = metrics; - }; - receive(root, host.peerId, { - framesPerSecond: 30, - bitrateKbps: 3_000, - frameWidth: 1280, - frameHeight: 720, - intervalFreezeCount: 0, - intervalFreezeDurationMs: 0, - intervalDecodeMs: 2, - videoJitterBufferDelayMs: 20, - codec: "video/VP8", - }); - receive(leaf, root.peerId, { - framesPerSecond: 20, - bitrateKbps: 1_800, - frameWidth: 960, - frameHeight: 540, - intervalFreezeCount: 1, - intervalFreezeDurationMs: 120, - intervalDecodeMs: 4, - videoJitterBufferDelayMs: 35, - codec: "video/VP8", - }); - receive(sfu, null, { - framesPerSecond: 25, - bitrateKbps: 2_400, - frameWidth: 1280, - frameHeight: 720, - intervalFreezeCount: 0, - intervalFreezeDurationMs: 0, - codec: "video/H264", - }); - - const summary = summarizeViewerExperienceByDepth([ - { - atEpochMs: 2_000, - elapsedMs: 0, - pages: [host, root, leaf, sfu, unresolved], - }, - ]); - expect(summary.unresolvedRouteSamples).toBe(1); - expect(summary.cohorts).toMatchObject([ - { - route: "peer", - depth: 1, - viewerCount: 1, - routeSampleCount: 1, - metricSampleCount: 1, - missingMetricSamples: 0, - framesPerSecond: { mean: 30 }, - resolutions: ["1280x720"], - freezeCount: 0, - }, - { - route: "peer", - depth: 2, - viewerCount: 1, - framesPerSecond: { mean: 20 }, - resolutions: ["960x540"], - freezeWindows: 1, - freezeCount: 1, - freezeDurationMs: 120, - }, - { - route: "sfu", - depth: null, - viewerCount: 1, - framesPerSecond: { mean: 25 }, - codecs: ["video/H264"], - }, - ]); - }); - - it("checks one endpoint cap for Host and Viewer senders", () => { - const initial = [ - page("host", "host", 2, 0), - ...Array.from({ length: 3 }, (_, index) => - page("viewer", `viewer-${index + 1}`, index === 0 ? 1 : 0, 1), - ), - ]; - const final = structuredClone(initial); - for (const viewer of final.filter((entry) => entry.role === "viewer")) { - viewer.connections.at(-1)!.receiveTotals!.framesTotal = 20; - } - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 3, - ); - const checks = buildRunChecks(summary, 3, "720p30", 2); - expect(checks.find((check) => check.name === "host-assigned-children")?.passed).toBe(true); - expect(checks.find((check) => check.name === "relay-active-media-edges")?.passed).toBe(true); - const overused = { - ...summary, - maxRelayActiveMediaEdges: 3, - }; - expect( - buildRunChecks(overused, 3, "720p30", 2).find( - (check) => check.name === "relay-active-media-edges", - )?.passed, - ).toBe(false); - }); - - it("fails continuity when frames freeze or stats collection errors", () => { - const frozenInitial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - const frozenFinal = structuredClone(frozenInitial); - expect( - summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: frozenInitial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: frozenFinal }, - ], - 1, - ).everyViewerDecoded, - ).toBe(false); - - const errorFinal = structuredClone(frozenFinal); - errorFinal[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - errorFinal[1]!.connections[0]!.error = "getStats failed"; - expect( - summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: frozenInitial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: errorFinal }, - ], - 1, - ).everyViewerDecoded, - ).toBe(false); - }); - - it("accepts an authoritative SFU root and records its route generation", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - initial[0]!.routeRevision = 7; - initial[0]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - initial[1]!.routeRevision = 7; - initial[1]!.routeAssignment.upstream = { kind: "sfu" }; - initial[1]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - markSfuMedia(initial[0]!); - markSfuMedia(initial[1]!); - - const final = structuredClone(initial); - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ); - - expect(summary.everyViewerDecoded).toBe(true); - expect(summary.viewerContinuity[0]).toMatchObject({ - assigned: true, - routeRevision: 7, - upstreamKind: "sfu", - }); - expect(summary.finalTopology).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - role: "host", - routeRevision: 7, - routeUpstream: { kind: "none" }, - sfuPublicationGeneration: "publication_generation_12345678", - }), - expect.objectContaining({ - role: "viewer", - parentPeerId: null, - routeRevision: 7, - routeUpstream: { kind: "sfu" }, - }), - ]), - ); - }); - - it("accepts admitted SFU subscriptions and detects excess Host media edges", () => { - const generation = "publication_generation_12345678"; - const host = page("host", "host", 2, 0); - const direct = page("viewer", "viewer-1", 0, 1); - const firstRoot = page("viewer", "viewer-2", 0, 1); - const secondRoot = page("viewer", "viewer-3", 0, 1); - host.routeRevision = 7; - host.routeAssignment.childPeerIds = [direct.peerId]; - host.routeAssignment.sfuPublicationGeneration = generation; - host.maxAssignedChildren = 1; - direct.routeRevision = 7; - direct.routeAssignment.upstream = { - kind: "peer", - peerId: host.peerId, - }; - for (const root of [firstRoot, secondRoot]) { - root.routeRevision = 7; - root.routeAssignment.upstream = { kind: "sfu" }; - root.routeAssignment.sfuPublicationGeneration = generation; - } - for (const participant of [host, firstRoot, secondRoot]) { - markSfuMedia(participant); - } - - const initial = [host, direct, firstRoot, secondRoot]; - const final = structuredClone(initial); - for (const viewer of final.slice(1)) { - viewer.connections.at(-1)!.receiveTotals!.framesTotal = 20; - } - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 3, - ); - const checks = buildRunChecks(summary, 3, "720p30"); - - expect(summary).toMatchObject({ - maxHostActiveMediaEdges: 2, - maxHostAssignedChildren: 1, - sfuRootCount: 2, - sfuPublicationCoherent: true, - everyViewerDecoded: true, - }); - expect( - checks.find((check) => check.name === "sfu-route-consistency")?.passed, - ).toBe(true); - expect( - checks.find((check) => check.name === "host-active-media-edges")?.passed, - ).toBe(true); - - const thirdRoot = page("viewer", "viewer-4", 0, 1); - thirdRoot.routeRevision = 7; - thirdRoot.routeAssignment.upstream = { kind: "sfu" }; - thirdRoot.routeAssignment.sfuPublicationGeneration = generation; - markSfuMedia(thirdRoot); - const threeRootInitial = [...structuredClone(initial), thirdRoot]; - const threeRootFinal = structuredClone(threeRootInitial); - for (const viewer of threeRootFinal.slice(1)) { - viewer.connections.at(-1)!.receiveTotals!.framesTotal = 20; - } - const threeRootSummary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: threeRootInitial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: threeRootFinal }, - ], - 4, - ); - expect(threeRootSummary.sfuRootCount).toBe(3); - expect( - buildRunChecks(threeRootSummary, 4, "720p30").find( - (check) => check.name === "sfu-route-consistency", - )?.passed, - ).toBe(true); - - const excessiveHostInitial = structuredClone(initial); - const excessiveHostFinal = structuredClone(final); - excessiveHostInitial[0]!.maxActiveOutboundMediaEdges = 3; - excessiveHostFinal[0]!.maxActiveOutboundMediaEdges = 3; - const excessiveHostSummary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: excessiveHostInitial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: excessiveHostFinal }, - ], - 3, - ); - expect( - buildRunChecks(excessiveHostSummary, 3, "720p30").find( - (check) => check.name === "host-active-media-edges", - )?.passed, - ).toBe(false); - }); - - it("fails SFU continuity without one same-revision Host publication", () => { - function summarizeSfu( - hostGeneration: string | null, - hostRevision: number, - viewerRevision: number, - viewerGeneration: string | null = null, - ) { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - initial[0]!.routeRevision = hostRevision; - initial[0]!.routeAssignment.sfuPublicationGeneration = hostGeneration; - initial[1]!.routeRevision = viewerRevision; - initial[1]!.routeAssignment.upstream = { kind: "sfu" }; - initial[1]!.routeAssignment.sfuPublicationGeneration = viewerGeneration; - markSfuMedia(initial[0]!); - markSfuMedia(initial[1]!); - const final = structuredClone(initial); - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - return summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ); - } - - const missing = summarizeSfu(null, 7, 7); - expect(missing.sfuPublicationCoherent).toBe(false); - expect(missing.everyViewerDecoded).toBe(false); - - const mismatchedRevision = summarizeSfu( - "publication_generation_12345678", - 7, - 8, - ); - expect(mismatchedRevision.sfuPublicationCoherent).toBe(false); - expect(mismatchedRevision.everyViewerDecoded).toBe(false); - - const mismatchedOwner = summarizeSfu( - "publication_generation_12345678", - 7, - 7, - "viewer_generation_12345678", - ); - expect(mismatchedOwner.sfuPublicationCoherent).toBe(false); - expect(mismatchedOwner.everyViewerDecoded).toBe(false); - }); - - it("requires every SFU-rooted participant to share the active revision", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 1, 1), - page("viewer", "viewer-2", 0, 1), - ]; - initial[0]!.routeRevision = 7; - initial[0]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - initial[1]!.routeRevision = 7; - initial[1]!.routeAssignment.upstream = { kind: "sfu" }; - initial[1]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - initial[2]!.routeRevision = 6; - initial[2]!.routeAssignment.upstream = { - kind: "peer", - peerId: initial[1]!.peerId, - }; - markSfuMedia(initial[0]!); - markSfuMedia(initial[1]!); - - const final = structuredClone(initial); - final[1]!.connections.at(-1)!.receiveTotals!.framesTotal = 20; - final[2]!.connections.at(-1)!.receiveTotals!.framesTotal = 20; - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 2, - ); - - expect(summary.sfuPublicationObserved).toBe(true); - expect(summary.sfuPublicationCoherent).toBe(false); - expect(summary.everyViewerDecoded).toBe(false); - }); - - it("does not treat old peer frames as active SFU media", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - initial[0]!.routeRevision = 7; - initial[0]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - initial[1]!.routeRevision = 7; - initial[1]!.routeAssignment.upstream = { kind: "sfu" }; - initial[1]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - markSfuMedia(initial[0]!); - - const final = structuredClone(initial); - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ); - - expect(summary.sfuPublicationObserved).toBe(true); - expect(summary.sfuPublicationCoherent).toBe(false); - expect(summary.everyViewerDecoded).toBe(false); - }); - - it("reports only the no-orphan invariant when no SFU root is active", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - const final = structuredClone(initial); - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ); - const checks = buildRunChecks(summary, 1, "720p30"); - - expect(summary.sfuPublicationObserved).toBe(false); - expect(checks.some((check) => check.name === "sfu-route-consistency")).toBe( - false, - ); - expect(checks).toContainEqual( - expect.objectContaining({ - name: "no-orphan-sfu-publication", - passed: true, - }), - ); - - const orphaned = structuredClone(final); - orphaned[0]!.routeAssignment.sfuPublicationGeneration = - "publication_generation_12345678"; - const orphanedSummary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: orphaned }, - ], - 1, - ); - expect(orphanedSummary.sfuPublicationObserved).toBe(false); - expect(orphanedSummary.sfuPublicationCoherent).toBe(false); - expect( - buildRunChecks(orphanedSummary, 1, "720p30").find( - (check) => check.name === "no-orphan-sfu-publication", - )?.passed, - ).toBe(false); - }); - - it("keeps loopback timing diagnostic while gating topology and decoding", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - initial[1]!.authenticateSentAtEpochMs = 1_000; - const final = structuredClone(initial); - final[1]!.firstDecodedAtEpochMs = 31_000; - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - const summary = summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 32_000, elapsedMs: 30_000, pages: final }, - ], - 1, - ); - const checks = buildRunChecks(summary, 1, "720p30"); - - expect(summary.maxFirstDecodedAfterAuthenticateMs).toBe(30_000); - expect( - checks.some((check) => check.name === "first-decoded-frame"), - ).toBe(false); - expect( - checks.find((check) => check.name === "all-viewers-decoded")?.passed, - ).toBe(true); - expect( - checks - .filter((check) => check.name.endsWith("media-edges")) - .every((check) => check.passed), - ).toBe(true); - }); - - it("gates recovery on resumed decoding and the two-edge bounds, not elapsed time", () => { - const recovered = { - triggered: true, - recoveredAtEpochMs: 60_000, - recoveryMs: 50_000, - maxHostActiveMediaEdges: 2, - maxHostAssignedChildren: 2, - }; - - expect(buildRecoveryCheck(recovered).passed).toBe(true); - expect( - buildRecoveryCheck({ ...recovered, recoveredAtEpochMs: undefined }).passed, - ).toBe(false); - expect( - buildRecoveryCheck({ ...recovered, maxHostActiveMediaEdges: 3 }).passed, - ).toBe(false); - expect( - buildRecoveryCheck({ ...recovered, maxHostAssignedChildren: 3 }).passed, - ).toBe(false); - expect( - buildRecoveryCheck({ - triggered: true, - recoveredAtEpochMs: 60_000, - }).passed, - ).toBe(false); - }); - - it("requires unaffected viewers to keep decoding after relay reassignment", () => { - const before = [ - page("viewer", "viewer-1", 1, 1), - page("viewer", "viewer-2", 0, 1), - ]; - const baselines = captureRecoveryViewerBaselines(before); - expect(baselines).not.toBeNull(); - const after = structuredClone(before); - after[0]!.connections.at(-1)!.receiveTotals!.framesTotal = 20; - - expect(everyViewerRecoveredMedia(baselines, after)).toBe(false); - - after[1]!.connections.at(-1)!.receiveTotals!.framesTotal = 20; - expect(everyViewerRecoveredMedia(baselines, after)).toBe(true); - - after[1]!.connections.at(-1)!.connectionId = "replacement-connection"; - expect(everyViewerRecoveredMedia(baselines, after)).toBe(false); - after[1]!.connections.at(-1)!.connectionId = "receive-0"; - after[1]!.connections.at(-1)!.receiveTotals!.id = "replacement-rtp"; - expect(everyViewerRecoveredMedia(baselines, after)).toBe(false); - }); - - it("does not treat an unavailable recovery baseline as decoded growth", () => { - const missing = [page("viewer", "viewer-1", 0, 1)]; - missing[0]!.connections = []; - const current = [page("viewer", "viewer-1", 0, 1)]; - - expect(captureRecoveryViewerBaselines(missing)).toBeNull(); - expect(everyViewerRecoveredMedia(null, current)).toBe(false); - - const rebased = captureRecoveryViewerBaselines(current); - expect(rebased).not.toBeNull(); - expect( - everyViewerRecoveredMedia(rebased, structuredClone(current)), - ).toBe(false); - - current[0]!.connections[0]!.connectionId = null; - expect(captureRecoveryViewerBaselines(current)).not.toBeNull(); - - const errored = structuredClone(current); - errored[0]!.connections[0]!.receiveTotals = null; - errored[0]!.connections[0]!.error = "getStats failed"; - expect(captureRecoveryViewerBaselines(errored)).toBeNull(); - }); - - it("retains a probe-observed host peak above the current edge count", () => { - const host = page("host", "host", 2, 0); - host.maxActiveOutboundMediaEdges = 3; - host.maxAssignedChildren = 3; - - const peaks = mergeRecoveryHostPeaks({}, host); - - expect(peaks).toEqual({ - maxHostActiveMediaEdges: 3, - maxHostAssignedChildren: 3, - }); - expect( - buildRecoveryCheck({ - triggered: true, - recoveredAtEpochMs: 60_000, - ...peaks, - }).passed, - ).toBe(false); - }); - - it("fails closed when decoded media has no authoritative upstream", () => { - const initial = [ - page("host", "host", 1, 0), - page("viewer", "viewer-1", 0, 1), - ]; - initial[1]!.routeAssignment.upstream = { kind: "none" }; - const final = structuredClone(initial); - final[1]!.connections[0]!.receiveTotals!.framesTotal = 20; - - expect( - summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ).everyViewerDecoded, - ).toBe(false); - - final[1]!.routeAssignment.upstream = { - kind: "peer", - peerId: "parent-peer", - }; - final[1]!.routeRevision = null; - expect( - summarizeSamples( - [ - { atEpochMs: 2_000, elapsedMs: 0, pages: initial }, - { atEpochMs: 4_000, elapsedMs: 2_000, pages: final }, - ], - 1, - ).everyViewerDecoded, - ).toBe(false); - }); - - it("requires every viewer to decode and render after a quality change", () => { - const before = [ - page("host", "host", 2, 0), - page("viewer", "viewer-1", 1, 1), - page("viewer", "viewer-2", 0, 1), - ]; - const after = structuredClone(before); - for (const viewer of after.filter((entry) => entry.role === "viewer")) { - viewer.connections.at(-1)!.receiveTotals!.framesTotal += 1; - viewer.renderedFrames += 1; - } - expect(everyViewerAdvanced(before, after)).toBe(true); - - after[2]!.renderedFrames = before[2]!.renderedFrames; - expect(everyViewerAdvanced(before, after)).toBe(false); - after[2]!.renderedFrames += 1; - after[1]!.connections.at(-1)!.receiveTotals!.framesTotal = - before[1]!.connections.at(-1)!.receiveTotals!.framesTotal; - expect(everyViewerAdvanced(before, after)).toBe(false); - }); - - it("observes only active authoritative route transitions", () => { - const observer = createObserverHarness(); - const owner = observer.socket(); - const outsider = observer.socket(); - const activeRoute = routeAssignment(["child_peer_0001"]); - const preparedRoute = routeAssignment( - ["child_peer_0001", "child_peer_0002"], - "publication_generation_12345678", - ); - const conflictingRoute = routeAssignment(["conflict_peer_01"]); - - outsider.emitMessage({ - type: "authenticated", - role: "host", - peerId: "outsider_peer_01", - mediaMode: "peer-assisted", - routeRevision: 99, - routeAssignment: conflictingRoute, - qualitySettings: lowQualitySettings, - }); - expect(observer.snapshot().peerId).toBeNull(); - - authenticate(owner); - owner.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 4, - routeAssignment: activeRoute, - qualitySettings: lowQualitySettings, - }); - expect(observer.snapshot()).toMatchObject({ - routeRevision: 4, - routeAssignment: activeRoute, - maxAssignedChildren: 1, - }); - - owner.emitMessage({ - type: "route-update", - revision: 5, - phase: "prepare", - assignment: preparedRoute, - }); - expect(observer.snapshot()).toMatchObject({ - routeRevision: 4, - routeAssignment: activeRoute, - maxAssignedChildren: 1, - }); - - owner.emitMessage({ - type: "route-update", - revision: 3, - phase: "active", - assignment: preparedRoute, - }); - owner.emitMessage({ - type: "route-update", - revision: 5, - phase: "active", - assignment: conflictingRoute, - }); - expect(observer.snapshot().routeAssignment).toEqual(activeRoute); - - owner.emitMessage({ - type: "route-update", - revision: 5, - phase: "active", - assignment: preparedRoute, - }); - expect(observer.snapshot()).toMatchObject({ - routeRevision: 5, - routeAssignment: preparedRoute, - maxAssignedChildren: 2, - }); - - owner.emitMessage({ - type: "route-update", - revision: 5, - phase: "prepare", - assignment: preparedRoute, - }); - owner.emitMessage({ - type: "route-update", - revision: 5, - phase: "active", - assignment: activeRoute, - }); - expect(observer.snapshot()).toMatchObject({ - routeRevision: 5, - routeAssignment: preparedRoute, - maxAssignedChildren: 2, - }); - }); - - it("accepts a three-child route only for an explicit cap3 observer", () => { - const assignment = routeAssignment(["child_peer_0001", "child_peer_0002", "child_peer_0003"]); - for (const [cap, accepted] of [[2, false], [3, true]] as const) { - const observer = createObserverHarness(cap); - const socket = observer.socket(); - authenticate(socket); - socket.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 1, - routeAssignment: assignment, - qualitySettings: lowQualitySettings, - }); - expect(observer.snapshot().routeAssignment !== null).toBe(accepted); - } - }); - - it("clears hybrid state only from the current authenticated socket", () => { - const observer = createObserverHarness(); - const previous = observer.socket(); - const current = observer.socket(); - const assignment = routeAssignment(["child_peer_0001"]); - - authenticate(previous); - previous.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - mediaMode: "peer-assisted", - routeRevision: 4, - routeAssignment: assignment, - qualitySettings: lowQualitySettings, - }); - authenticate(current); - expect(observer.snapshot()).toMatchObject({ - signalingConnected: false, - routeRevision: 4, - routeAssignment: assignment, - }); - current.emitMessage({ - type: "authenticated", - role: "host", - peerId: "host_peer_0001", - viewerPeerIds: [], - }); - expect(observer.snapshot()).toMatchObject({ - signalingConnected: true, - routeRevision: null, - routeAssignment: null, - }); - - previous.emitMessage({ - type: "route-update", - revision: 6, - phase: "active", - assignment, - }); - previous.emitClose(); - expect(observer.snapshot()).toMatchObject({ - signalingConnected: true, - routeRevision: null, - routeAssignment: null, - }); - - current.emitClose(); - expect(observer.snapshot().signalingConnected).toBe(false); - }); - - it("injects the requested deterministic capture dimensions", () => { - const source = buildBenchmarkInitScript({ - label: "host-1", - role: "host", - viewerIndex: null, - clearHostRoom: true, - width: 1280, - height: 720, - frameRate: 30, - expectedEndpointCap: 2, - }); - expect(source).toContain('"width":1280'); - expect(source).toContain('"frameRate":30'); - expect(source).toContain("getDisplayMedia"); - expect(source).toContain("collectConnectionMetrics"); - expect(source).toContain("set-quality-settings"); - expect(source).toContain("renderedFrames"); - }); -}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 2f88400..d623324 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -16,6 +16,7 @@ import { decodeClientMessage, normalizeDisplayName, participantRouteAssignmentSchema, + preparedRouteCandidateSchema, replaceRoomRequestSchema, roomAccessUpdateRequestSchema, roomAccessUpdateResponseSchema, @@ -87,7 +88,7 @@ const qualityEvidence = { describe("client signaling protocol", () => { it("uses the current strict signaling generation", () => { - expect(SIGNALING_PROTOCOL).toBe("screener-v19"); + expect(SIGNALING_PROTOCOL).toBe("screener-v20"); }); it("keeps signaling challenges strict and sequence-only", () => { @@ -1283,7 +1284,7 @@ describe("server signaling protocol", () => { expect(serverMessageSchema.safeParse(viewer).success).toBe(false); }); - it("requires the v19 NAT observation configuration", () => { + it("requires the v20 NAT observation configuration", () => { const message = authenticatedMessage(8); const { natPredictionStunUrls: _urls, ...legacyIceConfig } = message.iceConfig; @@ -1509,6 +1510,26 @@ describe("server signaling protocol", () => { ).toBe(false); }); + it("accepts only bounded connection-attempt progress on a prepared route", () => { + const candidate = { + childPeerId: "child_12345678", connectionId: "connection_12345678", + transport: "direct", qualityProbe: false, + }; + for (const current of [1, 2, 3]) { + expect(preparedRouteCandidateSchema.safeParse({ + ...candidate, connectionAttempt: { current, total: 3 }, + }).success).toBe(true); + } + for (const connectionAttempt of [ + { current: 0, total: 3 }, { current: 4, total: 3 }, + { current: 1, total: 4 }, { current: 1.5, total: 3 }, + ]) { + expect(preparedRouteCandidateSchema.safeParse({ + ...candidate, connectionAttempt, + }).success).toBe(false); + } + }); + it("keeps hybrid route messages strict and separate from ordinary P2P auth", () => { const assignment = { upstream: { kind: "peer", peerId: "parent_12345678" }, diff --git a/tests/quality.test.ts b/tests/quality.test.ts index adbe29b..b56486f 100644 --- a/tests/quality.test.ts +++ b/tests/quality.test.ts @@ -253,6 +253,28 @@ describe("realtime quality controls", () => { ]); }); + it("does not scale a remote source that already owns the room profile", async () => { + let applied = { encodings: [{}] } as unknown as RTCRtpSendParameters; + const sender = { + track: { + getCapabilities: () => ({}), + getSettings: () => ({ width: 2560, height: 1440 }), + }, + getParameters: () => applied, + setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => { + applied = parameters; + }), + } as unknown as RTCRtpSender; + + const readback = await configureVideoSender( + sender, + QUALITY_PROFILES["720p30"], + ); + + expect(readback.requested.scaleResolutionDownBy).toBe(1); + expect(applied.encodings[0]?.scaleResolutionDownBy).toBe(1); + }); + it.each([ ["saver", 64_000], ["music", 128_000], diff --git a/tests/room-route-controller.test.ts b/tests/room-route-controller.test.ts index fc6bf27..ced8759 100644 --- a/tests/room-route-controller.test.ts +++ b/tests/room-route-controller.test.ts @@ -25,6 +25,7 @@ function controller( sfuEnabled?: boolean; qualityConvergenceEnabled?: boolean; operationTimeoutMs?: number; + natPredictionEnabled?: boolean; } = {}, ) { const routes = new RoomRouteController({ @@ -4979,3 +4980,142 @@ describe("RoomRouteController", () => { expect(routes.snapshot().upstreamByViewer.has(A)).toBe(false); }); }); + +describe("bounded NAT candidate acquisition", () => { + function preparePeer(routes: RoomRouteController, nowMs: number, id: string) { + const operation = routes.reconcile(nowMs).operation!; + const prepared = beginCandidate(routes, { + nowMs, + connectionId: id, + reservation: { kind: "direct" }, + }).operation!; + const guard: CandidateGuard = { + childPeerId: prepared.childPeerId, + childSessionId: prepared.childSessionId, + revision: prepared.current!.revision, + connectionId: id, + }; + return { operation, prepared, guard }; + } + + it.each(["failed", "timeout"] as const)("runs three real generations on %s, then exhausts until a new session", (failure) => { + const routes = controller(2, { + natPredictionEnabled: true, + operationTimeoutMs: 20_000, + }); + addViewer(routes, A, 0); + let nowMs = 0; + let previous: CandidateGuard | undefined; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const { operation, prepared, guard } = preparePeer(routes, nowMs, `nat_${attempt}`); + expect(prepared.current?.connectionAttempt).toEqual({ current: attempt, total: 3 }); + expect(operation.deadlineAtMs - nowMs).toBe(20_000); + expect(prepared.wakeAtMs).toBe(operation.deadlineAtMs); + if (previous) expect(routes.candidateFailed(previous, nowMs).accepted).toBe(false); + expect(routes.snapshot().operation?.current?.connectionId).toBe(`nat_${attempt}`); + nowMs = failure === "timeout" ? operation.deadlineAtMs : nowMs + 1; + const result = failure === "timeout" + ? routes.operationExpired(nowMs) + : routes.candidateFailed(guard, nowMs); + expect(result.failedPeerIds).toEqual(attempt === 3 ? [A] : []); + expect(routes.candidateFailed(guard, nowMs).accepted).toBe(false); + previous = guard; + nowMs += 1; + } + expect(routes.reconcile(nowMs).operation).toBeUndefined(); + routes.upsertParticipant({ + peerId: A, role: "viewer", sessionId: "replacement_session", + effectiveDownstreamCapacity: 0, + }, nowMs); + const { prepared, guard } = preparePeer(routes, nowMs + 1, "new_session"); + expect(prepared.current?.connectionAttempt?.current).toBe(1); + expect(routes.candidateReady(guard, nowMs + 2).committed).toBe(true); + }); + + it("gives waiting viewers their first opportunity before another viewer retries", () => { + const routes = controller(2, { natPredictionEnabled: true }); + addViewer(routes, A, 0); + addViewer(routes, B, 0); + for (const [index, childPeerId] of [A, B, A, B, A, B].entries()) { + const { prepared, guard } = preparePeer(routes, index * 2, `fair_${index}`); + expect(prepared.childPeerId).toBe(childPeerId); + expect(prepared.current?.connectionAttempt?.current).toBe(Math.floor(index / 2) + 1); + routes.candidateFailed(guard, index * 2 + 1); + } + expect(routes.reconcile(20).operation).toBeUndefined(); + }); + + it("tries a newly usable parent before spending another attempt on an old parent", () => { + const routes = controller(2, { natPredictionEnabled: true }); + addViewer(routes, B, 2); + routes.hydrateEdge(B, peerEdge(HOST, "existing_b")); + addViewer(routes, A, 0); + const first = preparePeer(routes, 0, "first_host"); + expect(first.prepared.current?.tuple).toMatchObject({ parentPeerId: HOST }); + routes.candidateFailed(first.guard, 1); + const second = preparePeer(routes, 2, "first_b"); + expect(second.prepared.current?.tuple).toMatchObject({ parentPeerId: B }); + routes.candidateFailed(second.guard, 3); + addViewer(routes, C, 2, 4); + routes.hydrateEdge(C, peerEdge(HOST, "existing_c")); + const third = preparePeer(routes, 5, "first_c"); + expect(third.prepared.current?.tuple).toMatchObject({ parentPeerId: C }); + expect(third.prepared.current?.connectionAttempt?.current).toBe(1); + }); + + it("keeps SFU's foreground timing and shares the attempt budget with round-robin direct continuations", () => { + const routes = controller(2, { + natPredictionEnabled: true, sfuEnabled: true, operationTimeoutMs: 20_000, + }); + let nowMs = 0; + for (const childPeerId of [A, B]) { + addViewer(routes, childPeerId, 0, nowMs); + const direct = preparePeer(routes, nowMs, `${childPeerId}_first`); + expect(direct.prepared.wakeAtMs).toBe(nowMs + 5_000); + nowMs += 5_000; + expect(routes.operationExpired(nowMs).failedPeerIds).toEqual([]); + const sfu = beginCandidate(routes, { + nowMs, + connectionId: `${childPeerId}_sfu`, + publicationGeneration: "sfu_generation", + publicationConnectionId: "sfu_publisher", + reservation: childPeerId === A + ? { kind: "sfu-create", edge: "subscription_a", publication: "publication" } + : { kind: "sfu-reuse", edge: "subscription_b" }, + }).operation!; + expect(sfu.current?.connectionAttempt).toBeUndefined(); + routes.candidateReady({ + childPeerId, childSessionId: sfu.childSessionId, + revision: sfu.current!.revision, connectionId: `${childPeerId}_sfu`, + }, ++nowMs); + nowMs += 1; + } + for (const [index, childPeerId] of [A, B, A, B].entries()) { + const retry = preparePeer(routes, nowMs, `background_${index}`); + expect(retry.prepared).toMatchObject({ + childPeerId, reason: "direct-convergence", + current: { connectionAttempt: { current: Math.floor(index / 2) + 2, total: 3 } }, + }); + expect(retry.prepared.wakeAtMs - nowMs).toBe(20_000); + expect(routes.candidateFailed(retry.guard, ++nowMs).failedPeerIds).toEqual([]); + expect(routes.snapshot().upstreamByViewer.get(childPeerId)).toMatchObject({ kind: "sfu", usable: true }); + nowMs += 1; + } + expect(routes.reconcile(nowMs).operation).toBeUndefined(); + }); + + it("does not retry without NAT policy or when no candidate was created", () => { + const ordinary = controller(); + addViewer(ordinary, A, 0); + const first = preparePeer(ordinary, 0, "ordinary"); + expect(first.prepared.current?.connectionAttempt).toBeUndefined(); + expect(ordinary.candidateFailed(first.guard, 1).failedPeerIds).toEqual([A]); + expect(ordinary.reconcile(2).operation).toBeUndefined(); + + const routes = controller(2, { natPredictionEnabled: true }); + addViewer(routes, A, 0); + const operation = routes.reconcile(0).operation!; + routes.skipCurrentCandidate(cursorGuard(operation), 1, "candidate-failed"); + expect(routes.reconcile(2).operation).toBeUndefined(); + }); +}); diff --git a/tests/route-tree.test.ts b/tests/route-tree.test.ts index cea6577..1884dcf 100644 --- a/tests/route-tree.test.ts +++ b/tests/route-tree.test.ts @@ -223,13 +223,13 @@ describe("RouteTree", () => { { role: "host", peerId: hostPeerId, - displayName: "👑-abc123", + displayName: "👑 (abc123)", upstream: { kind: "none" }, }, { role: "viewer", peerId: viewerPeerId, - displayName: "👤-def456", + displayName: "👤 (def456)", upstream: { kind: "peer", peerId: hostPeerId }, mediaReady: true, }, @@ -253,8 +253,8 @@ describe("RouteTree", () => { expect(html).toContain(">abc123"); expect(html).toContain(">def456"); expect(html).toContain(">👤-custom"); - expect(html).not.toContain(">👑-abc123"); - expect(html).not.toContain(">👤-def456"); + expect(html).not.toContain(">👑 (abc123)"); + expect(html).not.toContain(">👤 (def456)"); expect(html).toContain("scale(0.82)"); }); diff --git a/tsconfig.tools.json b/tsconfig.tools.json index cf8c3ff..4b8e4f0 100644 --- a/tsconfig.tools.json +++ b/tsconfig.tools.json @@ -26,6 +26,7 @@ "scripts/client-native-sfu-page.ts", "scripts/client-loopback-gate.ts", "scripts/client-local-gate.ts", + "scripts/client-link-gate.ts", "tests" ] }