From 4b942bfda0a3ed3e6994260dfabab800086e54c9 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Thu, 20 Aug 2026 11:32:05 +0800
Subject: [PATCH] feat(native): add provisional fixed-high sender candidate
(#61)
---
.github/workflows/repository-hygiene.yml | 11 +
.../0006-fixed-high-native-sender-canary.md | 27 +-
docs/project-memory.md | 2 +-
docs/research/native-shared-encode-sender.md | 34 +-
docs/status.md | 7 +-
native/sender/cmd/screener-sender/main.go | 55 ++
native/sender/go.mod | 32 +
native/sender/go.sum | 56 ++
native/sender/internal/app/security.go | 59 ++
native/sender/internal/app/security_test.go | 60 ++
native/sender/internal/app/server.go | 496 ++++++++++
native/sender/internal/app/server_test.go | 383 ++++++++
native/sender/internal/app/ui/app.js | 528 +++++++++++
native/sender/internal/app/ui/index.html | 60 ++
native/sender/internal/app/ui/styles.css | 68 ++
native/sender/internal/media/fanout.go | 257 ++++++
native/sender/internal/media/fanout_test.go | 179 ++++
native/sender/internal/media/queue.go | 157 ++++
native/sender/internal/media/queue_test.go | 93 ++
native/sender/internal/remote/admission.go | 96 ++
.../sender/internal/remote/admission_test.go | 83 ++
native/sender/internal/remote/peer.go | 300 +++++++
native/sender/internal/remote/peer_test.go | 77 ++
native/sender/internal/remote/session.go | 848 ++++++++++++++++++
native/sender/internal/remote/session_test.go | 784 ++++++++++++++++
native/sender/internal/remote/wire.go | 429 +++++++++
native/sender/internal/remote/wire_test.go | 222 +++++
src/server/app.ts | 5 +-
src/server/room-store.ts | 103 ++-
src/server/signaling.ts | 2 +-
src/shared/protocol.ts | 3 +
tests/protocol.test.ts | 25 +
tests/room-store.test.ts | 127 +++
tests/server-http.test.ts | 25 +-
tests/server-signal.test.ts | 46 +-
35 files changed, 5699 insertions(+), 40 deletions(-)
create mode 100644 native/sender/cmd/screener-sender/main.go
create mode 100644 native/sender/go.mod
create mode 100644 native/sender/go.sum
create mode 100644 native/sender/internal/app/security.go
create mode 100644 native/sender/internal/app/security_test.go
create mode 100644 native/sender/internal/app/server.go
create mode 100644 native/sender/internal/app/server_test.go
create mode 100644 native/sender/internal/app/ui/app.js
create mode 100644 native/sender/internal/app/ui/index.html
create mode 100644 native/sender/internal/app/ui/styles.css
create mode 100644 native/sender/internal/media/fanout.go
create mode 100644 native/sender/internal/media/fanout_test.go
create mode 100644 native/sender/internal/media/queue.go
create mode 100644 native/sender/internal/media/queue_test.go
create mode 100644 native/sender/internal/remote/admission.go
create mode 100644 native/sender/internal/remote/admission_test.go
create mode 100644 native/sender/internal/remote/peer.go
create mode 100644 native/sender/internal/remote/peer_test.go
create mode 100644 native/sender/internal/remote/session.go
create mode 100644 native/sender/internal/remote/session_test.go
create mode 100644 native/sender/internal/remote/wire.go
create mode 100644 native/sender/internal/remote/wire_test.go
diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml
index e91ba1e..571c243 100644
--- a/.github/workflows/repository-hygiene.yml
+++ b/.github/workflows/repository-hygiene.yml
@@ -21,6 +21,17 @@ jobs:
run: sh scripts/check-project-state.sh
env:
SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: 1.26.6
+ cache-dependency-path: native/sender/go.sum
+ - name: Check native sender
+ working-directory: native/sender
+ run: |
+ go test ./...
+ go vet ./...
+ GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o "$RUNNER_TEMP/screener-sender.exe" ./cmd/screener-sender
- name: Set up Node.js
uses: actions/setup-node@v6
with:
diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md
index 2256993..88390c0 100644
--- a/docs/adr/0006-fixed-high-native-sender-canary.md
+++ b/docs/adr/0006-fixed-high-native-sender-canary.md
@@ -17,7 +17,7 @@ Any renewed product-wiring canary stays deliberately narrow:
- one fixed VP8 `HIGH` representation at 1280x720, 30 fps, and a 3 Mbps ceiling;
- one WebCodecs encoder object feeding at most two independent Pion WebRTC legs;
- the current strict ordinary-P2P signaling wire and unmodified browser viewers;
-- independent ICE/TURN, RTP/RTCP, congestion, and lifecycle state per leg;
+- independent STUN-backed ICE, RTP/RTCP, congestion, and lifecycle state per leg;
- read-only feedback, with PLI/FIR limited to requesting a shared keyframe; and
- bounded local queues, fail-closed critical errors, and sanitized local-only
diagnostics.
@@ -36,6 +36,26 @@ failure. No viewer-1 acceptance means there is no two-viewer, third-viewer
FIFO, or direct/TURN proof. The attempted native branch must not be opened or
merged as product code from this state.
+## 2026-08-20 Current-Wire Checkpoint
+
+A clean local delivery candidate now targets only `screener-v2`: it exchanges
+`HOST_ADMISSION_PASSWORD` for the bounded HttpOnly Host-admission cookie, uses
+that cookie to create an explicit `private-link` room, carries it on the Host
+WebSocket upgrade, and strictly decodes the current ordinary authenticated and
+read-only Viewer-evidence shapes. There is no v1 parser, translator, raw-grant
+persistence, or Bearer room-creation shortcut. Its fixed 300-second provisional
+request always creates a random transient room in memory, even when SQLite is
+configured. A connected current Host suppresses that reclaim deadline; only its
+generation-matched disconnect resets the five-minute window, while the ordinary
+transient `ROOM_TTL_SECONDS` cap always remains. A pre-auth connection or
+authentication failure gets one retry with the same room/token/client generation
+and never another room POST. Server restart drops the room; SQLite stays at v2
+and ordinary Web rooms are unchanged. No claim-confirm message, durable native token, or recovery
+subsystem is added. Focused Go/TypeScript tests and static checks pass. The unused
+generic probe framework is deliberately not retained.
+No runtime/browser revalidation has run, so the product gate remains no-go and
+this local branch is not a product availability claim.
+
## Staged Revalidation
A new run requires separate authorization and must stop at the first failed
@@ -52,8 +72,9 @@ stage while retaining a bounded final-negative snapshot:
5. Add viewer 3, prove no third edge and a host-side waiting observation while
the viewer keeps its existing waiting state, then close viewer 1 and prove
FIFO promotion plus decoding/rendering.
-6. If the isolated environment has valid TURN configuration, prove one direct
- and one forced-TURN leg; otherwise record the gap once without retrying.
+6. Keep this candidate STUN-only. If a selected-edge TURN contract is later
+ accepted, prove it in a separate bounded gate rather than widening ordinary
+ native ICE or blocking the direct-path canary.
Only after these stages pass may separate performance, quality, loss,
reconnect, browser, audio/A-V-sync, packaging, and license gates begin. Do not
diff --git a/docs/project-memory.md b/docs/project-memory.md
index 381c948..47d9797 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -23,7 +23,7 @@ Last updated: 2026-08-20
- Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass.
- Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost.
- Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
-- ADR-0006 fixed-`HIGH` is no-go-unclassified after timing out before Viewer evidence; any revisit starts at its staged gate.
+- ADR-0006 stays no-go. Its v2 room is memory-only, capped by `ROOM_TTL` and rearmed 300s after Host disconnect; SQLite/Web unchanged. Pre-auth retries once in-room without another POST.
- Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit.
- ADR-0005 accepts SFU roots as the primary central fallback after direct/peer UDP. The candidate has no TURN consumer or credential wire; any future exceptional-edge grant is a complete separate change. PR #12 is superseded.
- Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback.
diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md
index 8cdc924..2b5e926 100644
--- a/docs/research/native-shared-encode-sender.md
+++ b/docs/research/native-shared-encode-sender.md
@@ -165,6 +165,24 @@ strict ordinary signaling wire, unmodified viewers, read-only feedback, and no
audio or automatic `LOW`. This is a proposed test boundary, not current product
behavior or hardware-encoder evidence.
+The 2026-08-20 local delivery checkpoint rebases that candidate onto the sole
+current access contract: `screener-v2`, Host-admission Cookie authentication,
+explicit `private-link` creation, fragment-only Viewer grants returned to the
+local Host, and STUN-only ordinary ICE. Native creation alone requests a fixed
+300-second provisional room. It remains random and memory-only even with SQLite
+configured: the current Host session suppresses the reclaim deadline, its
+generation-matched disconnect resets the five-minute window, the ordinary
+transient room TTL still caps the room, and restart drops it. A pre-auth
+connection or authentication failure gets one same-room/token/client-generation
+retry without a second room POST. SQLite remains v2 and ordinary Web creation is
+unchanged.
+HTTP statuses, Cookie attributes, create-room responses, Host authentication,
+and ignored Viewer evidence are strict and bounded; errors retain only fixed categories.
+Focused Go and TypeScript checks pass, but no Node/Chrome runtime gate has run.
+The overbroad unused gate/probe framework was deleted; a future authorized run
+must add only its executable minimum. This static checkpoint does not change the
+no-go result below.
+
The sole authorized 2026-08-19 local product gate is
`no-go-unclassified`:
@@ -286,12 +304,13 @@ No stacked PR is connected to Screener's product path, capture path, or
audio path. Before product consideration, a target native sender must still
prove one physical encoder invocation on representative hardware, live
PLI/FIR-to-encoder control, heterogeneous downstream estimates, bounded burst
-and sustained loss, audio/A-V synchronization, mixed direct/TURN edges,
-reconnect isolation, browser diversity, lifecycle, and sustained CPU/GPU,
+and sustained loss, audio/A-V synchronization, reconnect isolation, browser
+diversity, lifecycle, and sustained CPU/GPU,
memory, latency, quality, and upload measurements. A third host edge, custom
RTP/SRTP, or a custom congestion-control framework remains out of scope. The
#28 minimum-of-two policy is also out of scope for product code; retaining its
-evidence does not retain its policy.
+evidence does not retain its policy. Selected-edge TURN is a separate future
+contract and gate, not a prerequisite silently added to this STUN-only canary.
## Primary Sources And License Boundary
@@ -331,6 +350,9 @@ evidence does not retain its policy.
- [Apple required hardware encoder key](https://developer.apple.com/documentation/videotoolbox/kvtvideoencoderspecification_requirehardwareacceleratedvideoencoder)
- [Apple hardware encoder readback](https://developer.apple.com/documentation/videotoolbox/kvtcompressionpropertykey_usinghardwareacceleratedvideoencoder)
-libwebrtc uses a BSD-style license and Pion uses MIT, but distribution still
-requires a full libwebrtc third-party notice review and an H.264 patent/licensing
-review. No source code from these projects was copied into Screener.
+The current Go candidate directly uses Pion (MIT) and coder/websocket (ISC).
+Before distributing any sender executable, choose the project license and
+generate and verify notices for those direct and all transitive dependencies.
+libwebrtc/H.264 remains a separate future route requiring its own third-party
+notice and patent/licensing review. No source code from these projects was
+copied into Screener.
diff --git a/docs/status.md b/docs/status.md
index 177a441..6401e18 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -17,6 +17,7 @@ The v1 DB/old env pair is only for the deeper pre-access `9610032` rollback.
- Capture precedes room creation; live source/quality changes preserve peers and picture pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options.
- Production requires `HOST_ADMISSION_PASSWORD` only for creation/Host role and uses `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Viewers never need the Host secret; there is no account/session table or old parser.
- SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row. The stopped migration retained four rooms and locked each old room private without minting a raw grant.
+- Native v2 rooms are memory-only, reclaim 300s after Host disconnect, obey `ROOM_TTL`, and leave SQLite/Web unchanged.
- PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP.
- Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky.
- After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified.
@@ -43,7 +44,7 @@ The v1 DB/old env pair is only for the deeper pre-access `9610032` rollback.
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
- Real audio and heterogeneous clients remain unverified. Production reports poor film audio and voice-call self-echo under system capture; there is no app audio ceiling or Web process isolation. Diagnose A/B/C and sync, then gate Windows 11 game-process audio; Windows 10 stays unresolved without system fallback.
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
-- Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats.
+- ADR-0006 stays no-go: its v2 memory-only candidate passed static checks only; render, two-edge/FIFO and later TURN remain unverified.
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
- PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
@@ -58,8 +59,8 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate
SFU; it never runs three identical retries or abandons progressing P2P early.
Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root
-evacuation. In parallel, migrate the native staged gate to v2;
-username/roster stays separate. Audio A/B/C may run without displacing P0.
+evacuation. Native v2 remains no-go pending an authorized runtime gate; Audio
+A/B/C may proceed without displacing P0.
TURN remains a future selected-edge change, not this canary.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
diff --git a/native/sender/cmd/screener-sender/main.go b/native/sender/cmd/screener-sender/main.go
new file mode 100644
index 0000000..2177cf6
--- /dev/null
+++ b/native/sender/cmd/screener-sender/main.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/signal"
+ "runtime"
+ "syscall"
+
+ "github.com/TNTcraftHIM/Screener/native/sender/internal/app"
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "Screener sender stopped:", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ sender, err := app.New()
+ if err != nil {
+ return err
+ }
+ defer sender.Close()
+ launchURL, err := sender.Start()
+ if err != nil {
+ return err
+ }
+
+ if err = openBrowser(launchURL); err != nil {
+ return fmt.Errorf("open the local sender UI: %w", err)
+ }
+ fmt.Println("Screener sender is running. Close this window to stop sharing.")
+ <-ctx.Done()
+ return nil
+}
+
+func openBrowser(url string) error {
+ var command *exec.Cmd
+ switch runtime.GOOS {
+ case "windows":
+ command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ case "darwin":
+ command = exec.Command("open", url)
+ default:
+ command = exec.Command("xdg-open", url)
+ }
+ return command.Start()
+}
diff --git a/native/sender/go.mod b/native/sender/go.mod
new file mode 100644
index 0000000..f8bbc89
--- /dev/null
+++ b/native/sender/go.mod
@@ -0,0 +1,32 @@
+module github.com/TNTcraftHIM/Screener/native/sender
+
+go 1.24.0
+
+require (
+ github.com/coder/websocket v1.8.15
+ github.com/pion/rtcp v1.2.17
+ github.com/pion/rtp v1.10.5
+ github.com/pion/webrtc/v4 v4.2.18
+)
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/pion/datachannel v1.6.2 // indirect
+ github.com/pion/dtls/v3 v3.1.5 // indirect
+ github.com/pion/ice/v4 v4.4.0 // indirect
+ github.com/pion/interceptor v0.1.47 // indirect
+ github.com/pion/logging v0.2.4 // indirect
+ github.com/pion/mdns/v2 v2.1.0 // indirect
+ github.com/pion/randutil v0.1.0 // indirect
+ github.com/pion/sctp v1.11.1 // indirect
+ github.com/pion/sdp/v3 v3.0.19 // indirect
+ github.com/pion/srtp/v3 v3.0.12 // indirect
+ github.com/pion/stun/v3 v3.1.6 // indirect
+ github.com/pion/transport/v4 v4.0.2 // indirect
+ github.com/pion/turn/v5 v5.0.12 // indirect
+ github.com/wlynxg/anet v0.0.5 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
+)
diff --git a/native/sender/go.sum b/native/sender/go.sum
new file mode 100644
index 0000000..409adbe
--- /dev/null
+++ b/native/sender/go.sum
@@ -0,0 +1,56 @@
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
+github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
+github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
+github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
+github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
+github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
+github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
+github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
+github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
+github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
+github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
+github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
+github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
+github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
+github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
+github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
+github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
+github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
+github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
+github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
+github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
+github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
+github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
+github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
+github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
+github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
+github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
+github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
+github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
+github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/native/sender/internal/app/security.go b/native/sender/internal/app/security.go
new file mode 100644
index 0000000..7afea92
--- /dev/null
+++ b/native/sender/internal/app/security.go
@@ -0,0 +1,59 @@
+package app
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+const processTokenBytes = 32
+
+func newOpaqueToken() (string, error) {
+ buffer := make([]byte, processTokenBytes)
+ if _, err := rand.Read(buffer); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buffer), nil
+}
+
+func tokenMatches(actual, expected string) bool {
+ if len(actual) != len(expected) {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
+}
+
+func normalizeRemoteBase(raw string) (*url.URL, error) {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return nil, errors.New("server URL is invalid")
+ }
+ if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
+ (parsed.Path != "" && parsed.Path != "/") || parsed.Hostname() == "" {
+ return nil, errors.New("server URL must contain only an origin")
+ }
+ if parsed.Scheme != "https" {
+ if parsed.Scheme != "http" || !isLoopbackHost(parsed.Hostname()) {
+ return nil, errors.New("server URL must use HTTPS (HTTP is allowed only for loopback development)")
+ }
+ }
+ parsed.Path = ""
+ return parsed, nil
+}
+
+func isLoopbackHost(host string) bool {
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func exactRequest(request *http.Request, expectedHost, expectedOrigin string) bool {
+ return request.Host == expectedHost && request.Header.Get("Origin") == expectedOrigin
+}
diff --git a/native/sender/internal/app/security_test.go b/native/sender/internal/app/security_test.go
new file mode 100644
index 0000000..7c60e30
--- /dev/null
+++ b/native/sender/internal/app/security_test.go
@@ -0,0 +1,60 @@
+package app
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestNormalizeRemoteBaseAllowsHTTPSAndLoopbackHTTPOnly(t *testing.T) {
+ valid := []string{
+ "https://share.example.test",
+ "http://127.0.0.1:3000",
+ "http://[::1]:3000",
+ "http://localhost:3000",
+ }
+ for _, value := range valid {
+ if _, err := normalizeRemoteBase(value); err != nil {
+ t.Fatalf("valid remote base %q: %v", value, err)
+ }
+ }
+ invalid := []string{
+ "http://share.example.test",
+ "https://user@example.test",
+ "https://example.test/path",
+ "https://example.test/?query=1",
+ "https://example.test/#fragment",
+ }
+ for _, value := range invalid {
+ if _, err := normalizeRemoteBase(value); err == nil {
+ t.Fatalf("invalid remote base %q was accepted", value)
+ }
+ }
+}
+
+func TestExactRequestRequiresBothHostAndOrigin(t *testing.T) {
+ request := httptest.NewRequest("GET", "http://127.0.0.1:9000/media", nil)
+ request.Host = "127.0.0.1:9000"
+ request.Header.Set("Origin", "http://127.0.0.1:9000")
+ if !exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") {
+ t.Fatal("exact loopback request was rejected")
+ }
+ request.Header.Set("Origin", "http://localhost:9000")
+ if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") {
+ t.Fatal("alternate loopback origin was accepted")
+ }
+ request.Header.Set("Origin", "http://127.0.0.1:9000")
+ request.Host = "localhost:9000"
+ if exactRequest(request, "127.0.0.1:9000", "http://127.0.0.1:9000") {
+ t.Fatal("alternate Host was accepted")
+ }
+}
+
+func TestProcessTokenComparisonIsExact(t *testing.T) {
+ token := "0123456789abcdef0123456789abcdef0123456789abcdef"
+ if !tokenMatches(token, token) {
+ t.Fatal("equal token did not match")
+ }
+ if tokenMatches(token+"x", token) || tokenMatches(token[:len(token)-1]+"x", token) {
+ t.Fatal("different token matched")
+ }
+}
diff --git a/native/sender/internal/app/server.go b/native/sender/internal/app/server.go
new file mode 100644
index 0000000..9f80d99
--- /dev/null
+++ b/native/sender/internal/app/server.go
@@ -0,0 +1,496 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/TNTcraftHIM/Screener/native/sender/internal/media"
+ "github.com/TNTcraftHIM/Screener/native/sender/internal/remote"
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+)
+
+const (
+ processTokenHeader = "X-Screener-Process-Token"
+ maxLocalJSONBytes = 8 << 10
+ localWriteTimeout = 2 * time.Second
+ mediaAttachTimeout = 10 * time.Second
+ mediaConfigTimeout = 5 * time.Second
+)
+
+//go:embed ui/index.html ui/app.js ui/styles.css
+var uiFiles embed.FS
+
+type App struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ token string
+ attachTimeout time.Duration
+
+ mu sync.Mutex
+ writeMu sync.Mutex
+ server *http.Server
+ listener net.Listener
+ expectedHost string
+ origin string
+ starting bool
+ generation uint64
+ startCancel context.CancelFunc
+ startFailure string
+ session *remote.Session
+ sessionCancel context.CancelFunc
+ room remote.Room
+ mediaConn *websocket.Conn
+ mediaClaimed bool
+ mediaClaimID uint64
+ nextClaimID uint64
+ attachTimer *time.Timer
+ configTimeout time.Duration
+ activeViewers int
+ waitingViewers int
+}
+
+type startRequest struct {
+ ServerURL string `json:"serverUrl"`
+ Password string `json:"password"`
+}
+
+type encoderConfig struct {
+ Kind string `json:"kind"`
+ Codec string `json:"codec"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FPS int `json:"fps"`
+ Bitrate int `json:"bitrate"`
+ EncoderInstances int `json:"encoderInstances"`
+}
+
+func New() (*App, error) {
+ token, err := newOpaqueToken()
+ if err != nil {
+ return nil, fmt.Errorf("create process token: %w", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ return &App{
+ ctx: ctx, cancel: cancel, token: token,
+ attachTimeout: mediaAttachTimeout,
+ configTimeout: mediaConfigTimeout,
+ }, nil
+}
+
+func (app *App) Start() (string, error) {
+ listener, err := net.Listen("tcp4", "127.0.0.1:0")
+ if err != nil {
+ return "", fmt.Errorf("listen on loopback: %w", err)
+ }
+ address, ok := listener.Addr().(*net.TCPAddr)
+ if !ok {
+ listener.Close()
+ return "", errors.New("loopback listener returned an unexpected address")
+ }
+ app.listener = listener
+ app.expectedHost = fmt.Sprintf("127.0.0.1:%d", address.Port)
+ app.origin = "http://" + app.expectedHost
+ app.server = &http.Server{
+ Handler: app,
+ ReadHeaderTimeout: 5 * time.Second,
+ IdleTimeout: 30 * time.Second,
+ }
+ go func() {
+ _ = app.server.Serve(listener)
+ }()
+ return app.origin + "/#token=" + app.token, nil
+}
+
+func (app *App) Close() {
+ app.stopActive()
+ app.cancel()
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if app.server != nil {
+ _ = app.server.Shutdown(ctx)
+ }
+}
+
+func (app *App) ServeHTTP(response http.ResponseWriter, request *http.Request) {
+ setSecurityHeaders(response)
+ if request.Host != app.expectedHost {
+ http.NotFound(response, request)
+ return
+ }
+ switch request.URL.Path {
+ case "/":
+ app.serveAsset(response, request, "ui/index.html", "text/html; charset=utf-8")
+ case "/app.js":
+ app.serveAsset(response, request, "ui/app.js", "text/javascript; charset=utf-8")
+ case "/styles.css":
+ app.serveAsset(response, request, "ui/styles.css", "text/css; charset=utf-8")
+ case "/api/start":
+ app.handleStart(response, request)
+ case "/api/stop":
+ app.handleStop(response, request)
+ case "/media":
+ app.handleMedia(response, request)
+ default:
+ http.NotFound(response, request)
+ }
+}
+
+func (app *App) serveAsset(response http.ResponseWriter, request *http.Request, name, contentType string) {
+ if request.Method != http.MethodGet {
+ response.Header().Set("Allow", http.MethodGet)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ contents, err := uiFiles.ReadFile(name)
+ if err != nil {
+ http.Error(response, "asset unavailable", http.StatusInternalServerError)
+ return
+ }
+ response.Header().Set("Content-Type", contentType)
+ response.Header().Set("Content-Length", fmt.Sprint(len(contents)))
+ response.WriteHeader(http.StatusOK)
+ _, _ = response.Write(contents)
+}
+
+func (app *App) handleStart(response http.ResponseWriter, request *http.Request) {
+ if !app.authorizeAPI(response, request, http.MethodPost) {
+ return
+ }
+ var input startRequest
+ if err := decodeLocalJSON(request, &input); err != nil {
+ writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ baseURL, err := normalizeRemoteBase(input.ServerURL)
+ if err != nil {
+ writeJSON(response, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ app.mu.Lock()
+ if app.starting || app.session != nil {
+ app.mu.Unlock()
+ writeJSON(response, http.StatusConflict, map[string]string{"error": "A sharing session is already active"})
+ return
+ }
+ app.starting = true
+ app.generation++
+ generation := app.generation
+ app.startFailure = ""
+ startContext, startCancel := context.WithCancel(app.ctx)
+ app.startCancel = startCancel
+ app.mu.Unlock()
+
+ session, room, err := remote.Start(startContext, remote.StartOptions{
+ BaseURL: baseURL,
+ HostAdmissionPassword: strings.TrimSpace(input.Password),
+ OnEvent: func(event remote.Event) { app.emit(generation, event) },
+ })
+ app.mu.Lock()
+ app.starting = false
+ app.startCancel = nil
+ startFailure := app.startFailure
+ app.startFailure = ""
+ startCanceled := startContext.Err() != nil
+ currentGeneration := app.generation == generation
+ if err == nil && currentGeneration && !startCanceled && startFailure == "" {
+ app.session = session
+ app.sessionCancel = startCancel
+ app.room = room
+ app.attachTimer = time.AfterFunc(app.attachTimeout, func() {
+ app.mu.Lock()
+ expired := app.generation == generation && app.session == session && app.mediaConn == nil
+ app.mu.Unlock()
+ if expired {
+ session.Fail(errors.New("local media did not attach before the bounded deadline"))
+ }
+ })
+ }
+ app.mu.Unlock()
+ if err == nil && (!currentGeneration || startCanceled) {
+ err = errors.New("sharing start was canceled")
+ }
+ if err == nil && startFailure != "" {
+ err = errors.New(startFailure)
+ }
+ if err != nil {
+ startCancel()
+ if session != nil {
+ session.Close()
+ }
+ writeJSON(response, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(response, http.StatusCreated, room)
+}
+
+func (app *App) handleStop(response http.ResponseWriter, request *http.Request) {
+ if !app.authorizeAPI(response, request, http.MethodPost) {
+ return
+ }
+ app.stopActive()
+ response.WriteHeader(http.StatusNoContent)
+}
+
+func (app *App) handleMedia(response http.ResponseWriter, request *http.Request) {
+ if !exactRequest(request, app.expectedHost, app.origin) || request.Method != http.MethodGet {
+ http.Error(response, "forbidden", http.StatusForbidden)
+ return
+ }
+ protocol := "screener.token." + app.token
+ app.mu.Lock()
+ if app.session == nil || app.mediaClaimed {
+ app.mu.Unlock()
+ http.Error(response, "media session unavailable", http.StatusConflict)
+ return
+ }
+ session := app.session
+ generation := app.generation
+ app.nextClaimID++
+ claimID := app.nextClaimID
+ app.mediaClaimed = true
+ app.mediaClaimID = claimID
+ app.mu.Unlock()
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{
+ Subprotocols: []string{protocol},
+ })
+ if err != nil {
+ app.releaseMediaClaim(generation, claimID, nil)
+ return
+ }
+ if connection.Subprotocol() != protocol {
+ connection.CloseNow()
+ app.releaseMediaClaim(generation, claimID, nil)
+ return
+ }
+ connection.SetReadLimit(media.MaxFrameBytes + media.FrameHeaderBytes)
+ app.mu.Lock()
+ if app.generation != generation || app.session != session || app.mediaClaimID != claimID {
+ app.mu.Unlock()
+ connection.CloseNow()
+ app.releaseMediaClaim(generation, claimID, nil)
+ return
+ }
+ app.mediaConn = connection
+ if app.attachTimer != nil {
+ app.attachTimer.Stop()
+ app.attachTimer = nil
+ }
+ room := app.room
+ activeViewers := app.activeViewers
+ waitingViewers := app.waitingViewers
+ app.mu.Unlock()
+ defer func() {
+ app.releaseMediaClaim(generation, claimID, connection)
+ app.stopActiveGeneration(generation)
+ }()
+ if session == nil {
+ return
+ }
+ if err = app.writeLocal(connection, map[string]any{
+ "kind": "ready",
+ "roomId": room.ID,
+ "inviteUrl": room.InviteURL,
+ "activeViewers": activeViewers,
+ "waitingViewers": waitingViewers,
+ }); err != nil {
+ session.Fail(fmt.Errorf("write local media readiness: %w", err))
+ return
+ }
+
+ configContext, cancelConfig := context.WithTimeout(app.ctx, app.configTimeout)
+ messageType, payload, err := connection.Read(configContext)
+ cancelConfig()
+ if err != nil {
+ session.Fail(fmt.Errorf("read local encoder configuration: %w", err))
+ return
+ }
+ if messageType != websocket.MessageText {
+ session.Fail(errors.New("local encoder configuration was not text"))
+ return
+ }
+ var config encoderConfig
+ if err = decodeLocalPayload(payload, &config); err != nil || !validEncoderConfig(config) {
+ session.Fail(errors.New("local encoder configuration is invalid"))
+ _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoder config")
+ return
+ }
+ if err = app.writeLocal(connection, map[string]string{"kind": "config-accepted"}); err != nil {
+ session.Fail(fmt.Errorf("acknowledge local encoder configuration: %w", err))
+ return
+ }
+ for {
+ messageType, payload, err = connection.Read(app.ctx)
+ if err != nil {
+ return
+ }
+ if messageType != websocket.MessageBinary {
+ session.Fail(errors.New("local media bridge received a non-binary frame"))
+ _ = connection.Close(websocket.StatusUnsupportedData, "binary frames required")
+ return
+ }
+ frame, decodeErr := media.DecodeFrame(payload)
+ if decodeErr != nil {
+ session.Fail(fmt.Errorf("decode local encoded frame: %w", decodeErr))
+ _ = connection.Close(websocket.StatusPolicyViolation, "invalid encoded frame")
+ return
+ }
+ if err = session.WriteFrame(frame); err != nil {
+ return
+ }
+ }
+}
+
+func (app *App) authorizeAPI(response http.ResponseWriter, request *http.Request, method string) bool {
+ if !exactRequest(request, app.expectedHost, app.origin) ||
+ !tokenMatches(request.Header.Get(processTokenHeader), app.token) {
+ http.Error(response, "not found", http.StatusNotFound)
+ return false
+ }
+ if request.Method != method {
+ response.Header().Set("Allow", method)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return false
+ }
+ return true
+}
+
+func (app *App) emit(generation uint64, event remote.Event) {
+ app.mu.Lock()
+ if app.generation != generation {
+ app.mu.Unlock()
+ return
+ }
+ if event.Kind == "viewer-count" {
+ app.activeViewers = event.ActiveViewers
+ app.waitingViewers = event.WaitingViewers
+ }
+ if event.Kind == "fatal" && app.starting && app.session == nil {
+ app.startFailure = event.Message
+ }
+ connection := app.mediaConn
+ session := app.session
+ app.mu.Unlock()
+ if connection != nil {
+ if err := app.writeLocal(connection, event); err != nil && event.Kind != "fatal" && session != nil {
+ go session.Fail(fmt.Errorf("write local sender event: %w", err))
+ }
+ }
+ if event.Kind == "fatal" {
+ go app.stopActiveGeneration(generation)
+ }
+}
+
+func (app *App) writeLocal(connection *websocket.Conn, value any) error {
+ app.writeMu.Lock()
+ defer app.writeMu.Unlock()
+ ctx, cancel := context.WithTimeout(app.ctx, localWriteTimeout)
+ defer cancel()
+ return wsjson.Write(ctx, connection, value)
+}
+
+func (app *App) releaseMediaClaim(generation, claimID uint64, connection *websocket.Conn) {
+ app.mu.Lock()
+ defer app.mu.Unlock()
+ if app.generation == generation && app.mediaClaimed && app.mediaClaimID == claimID &&
+ (connection == nil || app.mediaConn == connection) {
+ app.mediaConn = nil
+ app.mediaClaimed = false
+ app.mediaClaimID = 0
+ }
+}
+
+func (app *App) stopActive() {
+ app.stopActiveGeneration(0)
+}
+
+func (app *App) stopActiveGeneration(expectedGeneration uint64) {
+ app.mu.Lock()
+ if expectedGeneration != 0 && app.generation != expectedGeneration {
+ app.mu.Unlock()
+ return
+ }
+ app.generation++
+ startCancel := app.startCancel
+ app.startCancel = nil
+ app.startFailure = ""
+ session := app.session
+ app.session = nil
+ sessionCancel := app.sessionCancel
+ app.sessionCancel = nil
+ connection := app.mediaConn
+ app.mediaConn = nil
+ app.mediaClaimed = false
+ app.mediaClaimID = 0
+ if app.attachTimer != nil {
+ app.attachTimer.Stop()
+ app.attachTimer = nil
+ }
+ app.room = remote.Room{}
+ app.activeViewers = 0
+ app.waitingViewers = 0
+ app.mu.Unlock()
+ if startCancel != nil {
+ startCancel()
+ }
+ if session != nil {
+ session.Close()
+ }
+ if sessionCancel != nil {
+ sessionCancel()
+ }
+ if connection != nil {
+ _ = connection.Close(websocket.StatusNormalClosure, "sharing stopped")
+ }
+}
+
+func validEncoderConfig(config encoderConfig) bool {
+ return config.Kind == "config" && config.Codec == "vp8" &&
+ config.Width == 1280 && config.Height == 720 && config.FPS == 30 &&
+ config.Bitrate == 3_000_000 && config.EncoderInstances == 1
+}
+
+func decodeLocalJSON(request *http.Request, target any) error {
+ payload, err := io.ReadAll(io.LimitReader(request.Body, maxLocalJSONBytes+1))
+ if err != nil || len(payload) > maxLocalJSONBytes {
+ return errors.New("request is too large")
+ }
+ return decodeLocalPayload(payload, target)
+}
+
+func decodeLocalPayload(payload []byte, target any) error {
+ decoder := json.NewDecoder(bytes.NewReader(payload))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return errors.New("payload is not valid JSON")
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return errors.New("payload contains multiple JSON values")
+ }
+ return nil
+}
+
+func writeJSON(response http.ResponseWriter, status int, value any) {
+ payload, _ := json.Marshal(value)
+ response.Header().Set("Content-Type", "application/json; charset=utf-8")
+ response.WriteHeader(status)
+ _, _ = response.Write(payload)
+}
+
+func setSecurityHeaders(response http.ResponseWriter) {
+ response.Header().Set("Cache-Control", "no-store")
+ response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
+ response.Header().Set("Referrer-Policy", "no-referrer")
+ response.Header().Set("X-Content-Type-Options", "nosniff")
+ response.Header().Set("X-Frame-Options", "DENY")
+}
diff --git a/native/sender/internal/app/server_test.go b/native/sender/internal/app/server_test.go
new file mode 100644
index 0000000..3e722ec
--- /dev/null
+++ b/native/sender/internal/app/server_test.go
@@ -0,0 +1,383 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Screener/native/sender/internal/remote"
+ "github.com/coder/websocket"
+)
+
+func TestEncoderConfigUsesTheFixedHighContract(t *testing.T) {
+ config := encoderConfig{
+ Kind: "config", Codec: "vp8", Width: 1280, Height: 720,
+ FPS: 30, Bitrate: 3_000_000, EncoderInstances: 1,
+ }
+ if !validEncoderConfig(config) {
+ t.Fatal("fixed 720p30/3 Mbps HIGH config was rejected")
+ }
+ config.Bitrate = 4_000_000
+ if validEncoderConfig(config) {
+ t.Fatal("unowned 4 Mbps config was accepted")
+ }
+}
+
+func TestDecodeLocalPayloadIsStrictAndSingleValued(t *testing.T) {
+ valid := []byte(`{"kind":"config","codec":"vp8","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1}`)
+ var config encoderConfig
+ if err := decodeLocalPayload(valid, &config); err != nil {
+ t.Fatal(err)
+ }
+ invalid := [][]byte{
+ []byte(`{"kind":"config","codec":"vp8","width":1280,"height":720,"fps":30,"bitrate":3000000,"encoderInstances":1,"extra":true}`),
+ append(append([]byte(nil), valid...), valid...),
+ }
+ for _, payload := range invalid {
+ if err := decodeLocalPayload(payload, &encoderConfig{}); err == nil {
+ t.Fatalf("invalid local config was accepted: %s", payload)
+ }
+ }
+}
+
+func TestStaleSessionGenerationCannotMutateOrStopTheCurrentSession(t *testing.T) {
+ application := &App{generation: 7, activeViewers: 1, waitingViewers: 2}
+ application.emit(6, remote.Event{Kind: "viewer-count", ActiveViewers: 9, WaitingViewers: 9})
+ application.emit(6, remote.Event{Kind: "fatal", Message: "stale failure"})
+ application.stopActiveGeneration(6)
+ application.mu.Lock()
+ defer application.mu.Unlock()
+ if application.generation != 7 || application.activeViewers != 1 || application.waitingViewers != 2 {
+ t.Fatalf("stale generation mutated current state: generation=%d active=%d waiting=%d",
+ application.generation, application.activeViewers, application.waitingViewers)
+ }
+}
+
+func TestStaleMediaClaimCannotReleaseTheCurrentClaim(t *testing.T) {
+ application := &App{generation: 7, mediaClaimed: true, mediaClaimID: 11}
+ application.releaseMediaClaim(6, 11, nil)
+ application.releaseMediaClaim(7, 10, nil)
+ application.mu.Lock()
+ if !application.mediaClaimed || application.mediaClaimID != 11 {
+ application.mu.Unlock()
+ t.Fatal("stale media claim released the current claim")
+ }
+ application.mu.Unlock()
+ application.releaseMediaClaim(7, 11, nil)
+ application.mu.Lock()
+ defer application.mu.Unlock()
+ if application.mediaClaimed || application.mediaClaimID != 0 {
+ t.Fatal("current media claim was not released")
+ }
+}
+
+func TestMediaAttachDeadlineAbandonsTheFreshRoom(t *testing.T) {
+ remoteDone := make(chan []byte, 1)
+ remoteServer := newAppRemoteServer(t, remoteDone)
+ defer remoteServer.Close()
+
+ application, err := New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ application.attachTimeout = 25 * time.Millisecond
+ _, err = application.Start()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer application.Close()
+
+ body, _ := json.Marshal(startRequest{ServerURL: remoteServer.URL})
+ request, err := http.NewRequest(http.MethodPost, application.origin+"/api/start", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Origin", application.origin)
+ request.Header.Set(processTokenHeader, application.token)
+ request.Header.Set("Content-Type", "application/json")
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.StatusCode != http.StatusCreated {
+ t.Fatalf("start status = %d", response.StatusCode)
+ }
+
+ select {
+ case payload := <-remoteDone:
+ if string(payload) != `{"type":"abandon-room"}` {
+ t.Fatalf("deadline terminal wire = %q", payload)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("media attach deadline did not abandon the room")
+ }
+}
+
+func TestSuccessfulStartKeepsTheSessionAliveUntilStop(t *testing.T) {
+ remoteDone := make(chan []byte, 1)
+ remoteServer := newAppRemoteServer(t, remoteDone)
+ defer remoteServer.Close()
+
+ application := startTestApplication(t, remoteServer.URL, time.Second, time.Second)
+ select {
+ case payload := <-remoteDone:
+ t.Fatalf("live session ended before stop: %q", payload)
+ case <-time.After(50 * time.Millisecond):
+ }
+ stopTestApplication(t, application)
+ assertAbandonRoom(t, remoteDone)
+}
+
+func TestInvalidMediaSubprotocolLeavesTheAttachDeadlineArmed(t *testing.T) {
+ remoteDone := make(chan []byte, 1)
+ remoteServer := newAppRemoteServer(t, remoteDone)
+ defer remoteServer.Close()
+
+ application := startTestApplication(t, remoteServer.URL, 25*time.Millisecond, time.Second)
+ connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{
+ HTTPHeader: http.Header{"Origin": []string{application.origin}},
+ Subprotocols: []string{"screener.token.wrong"},
+ })
+ if err == nil {
+ connection.CloseNow()
+ }
+ assertAbandonRoom(t, remoteDone)
+}
+
+func TestAcceptedMediaWithoutConfigFailsWithinTheHandshakeDeadline(t *testing.T) {
+ remoteDone := make(chan []byte, 1)
+ remoteServer := newAppRemoteServer(t, remoteDone)
+ defer remoteServer.Close()
+
+ application := startTestApplication(t, remoteServer.URL, time.Second, 25*time.Millisecond)
+ connection, _, err := websocket.Dial(context.Background(), application.origin+"/media", &websocket.DialOptions{
+ HTTPHeader: http.Header{"Origin": []string{application.origin}},
+ Subprotocols: []string{"screener.token." + application.token},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer connection.CloseNow()
+ readContext, cancelRead := context.WithTimeout(context.Background(), time.Second)
+ defer cancelRead()
+ if _, _, err = connection.Read(readContext); err != nil {
+ t.Fatalf("read local ready message: %v", err)
+ }
+ assertAbandonRoom(t, remoteDone)
+}
+
+func TestStopDuringDelayedStartCannotResurrectTheSession(t *testing.T) {
+ authReceived := make(chan struct{})
+ releaseAuthentication := make(chan struct{})
+ remoteServer := newDelayedAppRemoteServer(t, authReceived, releaseAuthentication)
+ defer remoteServer.Close()
+
+ application, err := New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = application.Start()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer application.Close()
+
+ type requestResult struct {
+ status int
+ err error
+ }
+ startResult := make(chan requestResult, 1)
+ go func() {
+ status, requestErr := doLocalPost(application, "/api/start", startRequest{ServerURL: remoteServer.URL})
+ startResult <- requestResult{status: status, err: requestErr}
+ }()
+ select {
+ case <-authReceived:
+ case <-time.After(2 * time.Second):
+ t.Fatal("remote signaling authentication was not received")
+ }
+ if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent {
+ t.Fatalf("stop status = %d", status)
+ }
+ close(releaseAuthentication)
+ select {
+ case result := <-startResult:
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ if result.status == http.StatusCreated {
+ t.Fatal("a canceled delayed start returned success")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("canceled delayed start did not finish")
+ }
+ application.mu.Lock()
+ defer application.mu.Unlock()
+ if application.session != nil || application.sessionCancel != nil || application.starting {
+ t.Fatal("canceled delayed start resurrected an active session")
+ }
+}
+
+func startTestApplication(t *testing.T, remoteURL string, attachTimeout, configTimeout time.Duration) *App {
+ t.Helper()
+ application, err := New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ application.attachTimeout = attachTimeout
+ application.configTimeout = configTimeout
+ _, err = application.Start()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(application.Close)
+ if status := postLocal(t, application, "/api/start", startRequest{ServerURL: remoteURL}); status != http.StatusCreated {
+ t.Fatalf("start status = %d", status)
+ }
+ return application
+}
+
+func stopTestApplication(t *testing.T, application *App) {
+ t.Helper()
+ if status := postLocal(t, application, "/api/stop", nil); status != http.StatusNoContent {
+ t.Fatalf("stop status = %d", status)
+ }
+}
+
+func postLocal(t *testing.T, application *App, path string, body any) int {
+ t.Helper()
+ status, err := doLocalPost(application, path, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return status
+}
+
+func doLocalPost(application *App, path string, body any) (int, error) {
+ var payload []byte
+ if body != nil {
+ payload, _ = json.Marshal(body)
+ }
+ request, err := http.NewRequest(http.MethodPost, application.origin+path, bytes.NewReader(payload))
+ if err != nil {
+ return 0, err
+ }
+ request.Header.Set("Origin", application.origin)
+ request.Header.Set(processTokenHeader, application.token)
+ request.Header.Set("Content-Type", "application/json")
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ return 0, err
+ }
+ defer response.Body.Close()
+ return response.StatusCode, nil
+}
+
+func assertAbandonRoom(t *testing.T, terminal <-chan []byte) {
+ t.Helper()
+ select {
+ case payload := <-terminal:
+ if string(payload) != `{"type":"abandon-room"}` {
+ t.Fatalf("terminal wire = %q", payload)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("fresh room was not abandoned")
+ }
+}
+
+func newAppRemoteServer(t *testing.T, terminal chan<- []byte) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ server := httptest.NewServer(mux)
+ mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) {
+ _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true})
+ })
+ mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL))
+ })
+ mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) {
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ return
+ }
+ defer connection.CloseNow()
+ if _, _, err = connection.Read(request.Context()); err != nil {
+ return
+ }
+ authenticated := []byte(`{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`)
+ if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil {
+ return
+ }
+ for {
+ messageType, payload, readErr := connection.Read(request.Context())
+ if readErr != nil {
+ return
+ }
+ if messageType != websocket.MessageText {
+ continue
+ }
+ var message struct {
+ Type string `json:"type"`
+ }
+ _ = json.Unmarshal(payload, &message)
+ if message.Type == "abandon-room" {
+ terminal <- append([]byte(nil), payload...)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ _ = connection.Write(ctx, websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`))
+ cancel()
+ return
+ }
+ }
+ })
+ return server
+}
+
+func newDelayedAppRemoteServer(
+ t *testing.T,
+ authReceived chan<- struct{},
+ releaseAuthentication <-chan struct{},
+) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ server := httptest.NewServer(mux)
+ mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) {
+ _ = json.NewEncoder(response).Encode(map[string]any{"required": false, "authenticated": true})
+ })
+ mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(response).Encode(appRoomResponse(server.URL))
+ })
+ mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) {
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ return
+ }
+ defer connection.CloseNow()
+ if _, _, err = connection.Read(request.Context()); err != nil {
+ return
+ }
+ close(authReceived)
+ <-releaseAuthentication
+ writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second)
+ defer cancelWrite()
+ _ = connection.Write(writeContext, websocket.MessageText, []byte(`{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`))
+ })
+ return server
+}
+
+func appRoomResponse(origin string) map[string]any {
+ expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
+ grant := "g1.1." + strconv.FormatInt(expiresAt.Unix(), 10) + ".aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ return map[string]any{
+ "roomId": "1", "hostToken": "0123456789abcdef0123456789abcdef",
+ "inviteUrl": origin + "/r/1#v=" + grant,
+ "viewerPolicy": "private-link", "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339),
+ }
+}
diff --git a/native/sender/internal/app/ui/app.js b/native/sender/internal/app/ui/app.js
new file mode 100644
index 0000000..a1c110e
--- /dev/null
+++ b/native/sender/internal/app/ui/app.js
@@ -0,0 +1,528 @@
+const WIDTH = 1280;
+const HEIGHT = 720;
+const FPS = 30;
+const BITRATE = 3_000_000;
+const FRAME_DURATION = Math.round(1_000_000 / FPS);
+const MAX_ENCODER_QUEUE = 2;
+const MAX_SOCKET_BUFFER = 512 * 1024;
+const MAX_FRAME_BYTES = 1 << 20;
+
+const fragment = new URLSearchParams(location.hash.slice(1));
+const processToken = fragment.get("token") || "";
+history.replaceState(null, "", location.pathname);
+
+const elements = {
+ serverURL: document.querySelector("#server-url"),
+ password: document.querySelector("#password"),
+ start: document.querySelector("#start"),
+ stop: document.querySelector("#stop"),
+ preview: document.querySelector("#preview"),
+ empty: document.querySelector("#empty-state"),
+ invite: document.querySelector("#invite"),
+ inviteLink: document.querySelector("#invite-link"),
+ copy: document.querySelector("#copy"),
+ status: document.querySelector("#status"),
+ viewerCount: document.querySelector("#viewer-count"),
+ error: document.querySelector("#error"),
+ captureDiagnostic: document.querySelector("#capture-diagnostic"),
+ encoderDiagnostic: document.querySelector("#encoder-diagnostic"),
+ bridgeDiagnostic: document.querySelector("#bridge-diagnostic"),
+ helperDiagnostic: document.querySelector("#helper-diagnostic"),
+ edgeDiagnostic: document.querySelector("#edge-diagnostic"),
+};
+
+let stream = null;
+let processorReader = null;
+let encoder = null;
+let mediaSocket = null;
+let sharing = false;
+let stopping = false;
+let startInFlight = false;
+let roomGeneration = 0;
+let forceKeyFrame = true;
+let frameNumber = 0;
+let captureSettings = null;
+let nativeDiagnostics = null;
+let metrics = emptyMetrics();
+let startGeneration = 0;
+let diagnosticsTimer = null;
+let pendingStartGeneration = 0;
+
+if (!processToken) {
+ showError("本地发送端令牌已失效,请重新启动 sender.exe");
+ elements.start.disabled = true;
+}
+
+elements.start.addEventListener("click", () => void startSharing());
+elements.stop.addEventListener("click", () => void stopSharing(startGeneration));
+elements.copy.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(elements.inviteLink.href);
+ elements.copy.textContent = "✓";
+ setTimeout(() => { elements.copy.textContent = "⧉"; }, 1200);
+ } catch {
+ showError("无法复制链接,请直接打开邀请链接");
+ }
+});
+
+async function startSharing() {
+ if (sharing || stopping || startInFlight) return;
+ showError("");
+ if (!("VideoEncoder" in window) || !("MediaStreamTrackProcessor" in window)) {
+ showError("此浏览器缺少 WebCodecs 屏幕编码支持,请使用当前版 Chrome 或 Edge");
+ return;
+ }
+ startInFlight = true;
+ elements.start.disabled = true;
+ const generation = ++startGeneration;
+ setStatus("选择屏幕");
+ try {
+ const capturedStream = await navigator.mediaDevices.getDisplayMedia({
+ video: {
+ width: { ideal: WIDTH, max: WIDTH },
+ height: { ideal: HEIGHT, max: HEIGHT },
+ frameRate: { ideal: FPS, max: FPS },
+ },
+ audio: false,
+ });
+ assertCurrentStart(generation, mediaSocket, capturedStream);
+ stream = capturedStream;
+ const videoTrack = stream.getVideoTracks()[0];
+ if (!videoTrack) throw new Error("没有可用的视频轨道");
+ await videoTrack.applyConstraints({ width: WIDTH, height: HEIGHT, frameRate: FPS });
+ assertCurrentStart(generation);
+ captureSettings = videoTrack.getSettings();
+ updateDiagnostics();
+ videoTrack.addEventListener("ended", () => void stopSharing(generation), { once: true });
+ elements.preview.srcObject = stream;
+ elements.empty.hidden = true;
+
+ setStatus("创建房间");
+ pendingStartGeneration = generation;
+ const room = await localRequest("/api/start", {
+ serverUrl: elements.serverURL.value,
+ password: elements.password.value,
+ });
+ assertCurrentStart(generation);
+ if (pendingStartGeneration === generation) pendingStartGeneration = 0;
+ roomGeneration = generation;
+ elements.password.value = "";
+ elements.inviteLink.href = room.inviteUrl;
+ elements.inviteLink.textContent = room.inviteUrl;
+ elements.invite.hidden = false;
+
+ await connectMediaSocket(generation);
+ assertCurrentStart(generation);
+ const activeEncoder = await startEncoder(generation);
+ assertCurrentStart(generation);
+ sharing = true;
+ elements.stop.hidden = false;
+ setStatus("正在分享");
+ void readFrames(videoTrack, generation, activeEncoder, mediaSocket);
+ } catch (error) {
+ if (generation === startGeneration && !(error instanceof StaleStartError)) {
+ showError(readableError(error, "无法开始分享"));
+ }
+ await stopSharing(generation);
+ } finally {
+ startInFlight = false;
+ elements.start.disabled = sharing || stopping;
+ }
+}
+
+async function connectMediaSocket(generation) {
+ const url = new URL("/media", location.href);
+ url.protocol = "ws:";
+ const socket = new WebSocket(url, [`screener.token.${processToken}`]);
+ mediaSocket = socket;
+ socket.binaryType = "arraybuffer";
+ socket.addEventListener("message", (event) => handleLocalEvent(generation, socket, event));
+ socket.addEventListener("close", () => {
+ if (generation === startGeneration && !stopping && mediaSocket === socket) {
+ void failSharing(generation, "本地媒体连接意外断开");
+ }
+ });
+
+ const ready = waitForLocalKind(socket, "ready", "等待本地媒体服务超时");
+ await waitForSocketOpen(socket);
+ assertCurrentStart(generation, socket);
+ await ready;
+ assertCurrentStart(generation, socket);
+ const accepted = waitForLocalKind(socket, "config-accepted", "本地编码配置确认超时");
+ socket.send(JSON.stringify({
+ kind: "config",
+ codec: "vp8",
+ width: WIDTH,
+ height: HEIGHT,
+ fps: FPS,
+ bitrate: BITRATE,
+ encoderInstances: 1,
+ }));
+ await accepted;
+ assertCurrentStart(generation, socket);
+}
+
+async function startEncoder(generation) {
+ const preferred = {
+ codec: "vp8",
+ width: WIDTH,
+ height: HEIGHT,
+ bitrate: BITRATE,
+ framerate: FPS,
+ latencyMode: "realtime",
+ bitrateMode: "variable",
+ hardwareAcceleration: "prefer-hardware",
+ };
+ let support = await VideoEncoder.isConfigSupported(preferred);
+ assertCurrentStart(generation, mediaSocket);
+ metrics.hardwarePreference = "requested";
+ if (!support.supported) {
+ const fallback = { ...preferred };
+ delete fallback.hardwareAcceleration;
+ support = await VideoEncoder.isConfigSupported(fallback);
+ assertCurrentStart(generation, mediaSocket);
+ metrics.hardwarePreference = "not accepted by capability check";
+ }
+ if (!support.supported) throw new Error("此设备不支持 VP8 720p30 编码");
+ let activeEncoder;
+ activeEncoder = new VideoEncoder({
+ output: (chunk) => sendEncodedChunk(generation, activeEncoder, chunk),
+ error: (error) => void failSharing(generation, `编码器失败:${error.message}`),
+ });
+ encoder = activeEncoder;
+ activeEncoder.configure(support.config);
+ updateDiagnostics();
+ return activeEncoder;
+}
+
+async function readFrames(videoTrack, generation, activeEncoder, activeSocket) {
+ const processor = new MediaStreamTrackProcessor({ track: videoTrack });
+ const reader = processor.readable.getReader();
+ if (generation !== startGeneration || encoder !== activeEncoder || mediaSocket !== activeSocket) {
+ await reader.cancel();
+ return;
+ }
+ processorReader = reader;
+ try {
+ while (generation === startGeneration && sharing && encoder === activeEncoder &&
+ mediaSocket === activeSocket && activeSocket?.readyState === WebSocket.OPEN) {
+ const { value: frame, done } = await reader.read();
+ if (done) {
+ if (generation === startGeneration && sharing && !stopping) {
+ await failSharing(generation, "屏幕画面读取意外结束");
+ }
+ return;
+ }
+ if (generation !== startGeneration || !sharing || encoder !== activeEncoder ||
+ mediaSocket !== activeSocket || processorReader !== reader) {
+ frame.close();
+ return;
+ }
+ metrics.framesRead += 1;
+ try {
+ const queueSize = activeEncoder.encodeQueueSize;
+ metrics.encoderQueuePeak = Math.max(metrics.encoderQueuePeak, queueSize);
+ if (activeEncoder.state === "configured" && queueSize < MAX_ENCODER_QUEUE) {
+ const keyFrame = forceKeyFrame || frameNumber % (FPS * 2) === 0;
+ forceKeyFrame = false;
+ activeEncoder.encode(frame, { keyFrame });
+ frameNumber += 1;
+ metrics.framesSubmitted += 1;
+ } else {
+ metrics.encoderQueueDrops += 1;
+ forceKeyFrame = true;
+ }
+ } finally {
+ frame.close();
+ }
+ scheduleDiagnostics();
+ }
+ } catch (error) {
+ if (generation === startGeneration && sharing && !stopping && encoder === activeEncoder) {
+ await failSharing(generation, readableError(error, "读取屏幕画面失败"));
+ }
+ }
+}
+
+function sendEncodedChunk(generation, activeEncoder, chunk) {
+ if (generation !== startGeneration || stopping || !sharing || encoder !== activeEncoder) return;
+ try {
+ if (!mediaSocket || mediaSocket.readyState !== WebSocket.OPEN) {
+ if (!stopping) void failSharing(generation, "本地媒体连接不可用");
+ return;
+ }
+ if (mediaSocket.bufferedAmount > MAX_SOCKET_BUFFER) {
+ void failSharing(generation, "本地媒体桥接积压超过安全上限");
+ return;
+ }
+ if (chunk.byteLength < 1 || chunk.byteLength > MAX_FRAME_BYTES) {
+ void failSharing(generation, "编码帧大小超出本地媒体契约");
+ return;
+ }
+ const data = new Uint8Array(chunk.byteLength);
+ chunk.copyTo(data);
+ const message = new ArrayBuffer(17 + data.byteLength);
+ const view = new DataView(message);
+ view.setUint8(0, chunk.type === "key" ? 1 : 2);
+ view.setBigUint64(1, BigInt(Math.max(0, chunk.timestamp)), false);
+ view.setBigUint64(9, BigInt(chunk.duration || FRAME_DURATION), false);
+ new Uint8Array(message, 17).set(data);
+ mediaSocket.send(message);
+ metrics.encoderOutputs += 1;
+ metrics.encodedBytes += data.byteLength;
+ metrics.socketBufferedPeak = Math.max(metrics.socketBufferedPeak, mediaSocket.bufferedAmount);
+ scheduleDiagnostics();
+ } catch (error) {
+ void failSharing(generation, readableError(error, "发送编码帧失败"));
+ }
+}
+
+function handleLocalEvent(generation, socket, event) {
+ if (generation !== startGeneration || socket !== mediaSocket) return;
+ if (typeof event.data !== "string") return;
+ const message = parseLocalMessage(event.data);
+ if (!message) return;
+ if (message.kind === "request-keyframe") forceKeyFrame = true;
+ if (message.kind === "viewer-count" || message.kind === "ready") {
+ showViewerCounts(message.activeViewers, message.waitingViewers);
+ }
+ if (message.kind === "diagnostics") {
+ nativeDiagnostics = message;
+ updateDiagnostics();
+ }
+ if (message.kind === "warning" && message.message) showError(message.message);
+ if (message.kind === "fatal") {
+ void failSharing(generation, message.message || "分享会话已失败并停止");
+ }
+}
+
+async function failSharing(generation, message) {
+ if (generation !== startGeneration || stopping) return;
+ showError(message);
+ await stopSharing(generation);
+}
+
+async function stopSharing(expectedGeneration = startGeneration) {
+ if (expectedGeneration !== startGeneration || stopping) return;
+ stopping = true;
+ startGeneration += 1;
+ sharing = false;
+ setStatus("正在停止");
+ let stopError = null;
+ const rememberError = (error) => {
+ if (stopError === null) stopError = error;
+ };
+ try {
+ const reader = processorReader;
+ processorReader = null;
+ try {
+ stream?.getTracks().forEach((track) => track.stop());
+ } catch (error) {
+ rememberError(error);
+ }
+ stream = null;
+ elements.preview.srcObject = null;
+ try {
+ await reader?.cancel();
+ } catch (error) {
+ rememberError(error);
+ }
+ try {
+ if (encoder && encoder.state !== "closed") encoder.close();
+ } catch (error) {
+ rememberError(error);
+ }
+ encoder = null;
+ const socket = mediaSocket;
+ mediaSocket = null;
+ try {
+ if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "sender stopped");
+ } catch (error) {
+ rememberError(error);
+ }
+ if (roomGeneration === expectedGeneration || pendingStartGeneration === expectedGeneration) {
+ try {
+ await stopLocalSession(expectedGeneration);
+ } catch (error) {
+ rememberError(error);
+ }
+ }
+ if (stopError !== null) showError(readableError(stopError, "停止分享失败"));
+ } finally {
+ frameNumber = 0;
+ forceKeyFrame = true;
+ captureSettings = null;
+ nativeDiagnostics = null;
+ metrics = emptyMetrics();
+ if (diagnosticsTimer !== null) {
+ clearTimeout(diagnosticsTimer);
+ diagnosticsTimer = null;
+ }
+ updateDiagnostics();
+ elements.empty.hidden = false;
+ elements.invite.hidden = true;
+ elements.stop.hidden = true;
+ elements.start.disabled = startInFlight;
+ showViewerCounts(0, 0);
+ setStatus("未开始");
+ stopping = false;
+ }
+}
+
+async function stopLocalSession(generation) {
+ if (roomGeneration === generation) roomGeneration = 0;
+ if (pendingStartGeneration === generation) pendingStartGeneration = 0;
+ await localRequest("/api/stop");
+}
+
+function waitForSocketOpen(socket) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("本地媒体连接超时")), 5000);
+ socket.addEventListener("open", () => {
+ clearTimeout(timeout);
+ resolve();
+ }, { once: true });
+ socket.addEventListener("error", () => {
+ clearTimeout(timeout);
+ reject(new Error("本地媒体连接失败"));
+ }, { once: true });
+ });
+}
+
+function waitForLocalKind(socket, kind, timeoutMessage) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => finish(reject, new Error(timeoutMessage)), 5000);
+ const onMessage = (event) => {
+ if (typeof event.data !== "string") return;
+ const message = parseLocalMessage(event.data);
+ if (!message) return;
+ if (message.kind === kind) finish(resolve, message);
+ if (message.kind === "fatal") finish(reject, new Error(message.message || "分享会话已失败"));
+ };
+ const onClose = () => finish(reject, new Error("本地媒体连接在确认前关闭"));
+ const finish = (callback, value) => {
+ clearTimeout(timeout);
+ socket.removeEventListener("message", onMessage);
+ socket.removeEventListener("close", onClose);
+ callback(value);
+ };
+ socket.addEventListener("message", onMessage);
+ socket.addEventListener("close", onClose, { once: true });
+ });
+}
+
+async function localRequest(path, body) {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "X-Screener-Process-Token": processToken,
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ if (response.status === 204) return undefined;
+ let result;
+ try {
+ result = await response.json();
+ } catch {
+ throw new Error(`本地发送端返回 HTTP ${response.status}`);
+ }
+ if (!response.ok) throw new Error(result.error || `本地发送端返回 HTTP ${response.status}`);
+ return result;
+}
+
+function showViewerCounts(activeValue, waitingValue) {
+ const active = boundedCount(activeValue);
+ const waiting = boundedCount(waitingValue);
+ elements.viewerCount.textContent = waiting > 0
+ ? `${active} 位已分配/连接中,${waiting} 位等待`
+ : `${active} 位已分配/连接中`;
+}
+
+function updateDiagnostics() {
+ const width = captureSettings?.width ?? "未知";
+ const height = captureSettings?.height ?? "未知";
+ const rate = captureSettings?.frameRate ?? "未知";
+ elements.captureDiagnostic.textContent = `${width} × ${height} · ${rate} FPS`;
+ elements.encoderDiagnostic.textContent = `VP8 · 3 Mbps 上限 · 单对象 · 硬件偏好 ${metrics.hardwarePreference}`;
+ elements.bridgeDiagnostic.textContent = `读取 ${metrics.framesRead} · 提交 ${metrics.framesSubmitted} · 输出 ${metrics.encoderOutputs} · 编码队列丢弃 ${metrics.encoderQueueDrops} · 编码队列峰值 ${metrics.encoderQueuePeak} 帧 · socket 峰值 ${metrics.socketBufferedPeak} B`;
+ const helper = nativeDiagnostics?.media;
+ const queue = helper?.queue;
+ elements.helperDiagnostic.textContent = helper && queue
+ ? `帧 ${helper.framesWritten} · 源 RTP ${helper.sourceRtpPacketsWritten} 包 / ${formatBytes(helper.sourceRtpBytesWritten)} · 队列 ${queue.depth}/${queue.capacity},峰值 ${queue.maxDepth} · 丢弃 ${queue.droppedFrames} · 过载 ${queue.overloadEvents} · 恢复 ${queue.completedRecoveries}`
+ : "尚无数据";
+ const peers = Array.isArray(nativeDiagnostics?.peers) ? nativeDiagnostics.peers : [];
+ elements.edgeDiagnostic.textContent = peers.length === 0
+ ? "无活动媒体边"
+ : peers.map((peer) => {
+ const sent = peer.bitrateBps === undefined ? "码率未知" : formatBitrate(peer.bitrateBps);
+ const rtt = peer.currentRoundTripTimeMs === undefined ? "RTT 未知" : `RTT ${peer.currentRoundTripTimeMs} ms`;
+ return `边 ${peer.slot}: ${peer.connectionState || "未知"} / ${peer.route || "未知路径"} / ${sent} / ${rtt} / PLI ${peer.pliRequests ?? 0} / FIR ${peer.firRequests ?? 0}`;
+ }).join(";");
+}
+
+function scheduleDiagnostics() {
+ if (diagnosticsTimer !== null) return;
+ diagnosticsTimer = setTimeout(() => {
+ diagnosticsTimer = null;
+ updateDiagnostics();
+ }, 500);
+}
+
+function formatBitrate(value) {
+ const bitrate = Number(value);
+ return Number.isFinite(bitrate) ? `${(bitrate / 1_000_000).toFixed(2)} Mbps` : "码率未知";
+}
+
+function formatBytes(value) {
+ const bytes = Number(value);
+ return Number.isFinite(bytes) ? `${(bytes / (1024 * 1024)).toFixed(2)} MiB` : "未知";
+}
+
+function parseLocalMessage(value) {
+ try {
+ const message = JSON.parse(value);
+ return message && typeof message === "object" ? message : null;
+ } catch {
+ return null;
+ }
+}
+
+function boundedCount(value) {
+ const number = Number(value);
+ return Number.isSafeInteger(number) && number >= 0 ? number : 0;
+}
+
+function emptyMetrics() {
+ return {
+ framesRead: 0,
+ framesSubmitted: 0,
+ encoderOutputs: 0,
+ encodedBytes: 0,
+ encoderQueueDrops: 0,
+ encoderQueuePeak: 0,
+ socketBufferedPeak: 0,
+ hardwarePreference: "未请求",
+ };
+}
+
+function readableError(error, fallback) {
+ return error instanceof Error && error.message ? error.message : fallback;
+}
+
+function assertCurrentStart(generation, socket = mediaSocket, staleStream = null) {
+ if (!isCurrentStart(generation, socket)) {
+ staleStream?.getTracks().forEach((track) => track.stop());
+ throw new StaleStartError();
+ }
+}
+
+function isCurrentStart(generation, socket = mediaSocket) {
+ return generation === startGeneration && !stopping && (!socket || socket === mediaSocket);
+}
+
+class StaleStartError extends Error {}
+
+function setStatus(value) { elements.status.textContent = value; }
+function showError(value) { elements.error.textContent = value; }
diff --git a/native/sender/internal/app/ui/index.html b/native/sender/internal/app/ui/index.html
new file mode 100644
index 0000000..0806c7c
--- /dev/null
+++ b/native/sender/internal/app/ui/index.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+ Screener Sender
+
+
+
+
+
+
+
+
+
+
+
diff --git a/native/sender/internal/app/ui/styles.css b/native/sender/internal/app/ui/styles.css
new file mode 100644
index 0000000..062d57a
--- /dev/null
+++ b/native/sender/internal/app/ui/styles.css
@@ -0,0 +1,68 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #f1f3f1;
+ background: #101310;
+ letter-spacing: 0;
+ --surface: #171b18;
+ --surface-raised: #1d221e;
+ --border: #343a35;
+ --muted: #a8afa9;
+ --green: #45d483;
+ --green-strong: #2fbd70;
+ --red: #ff9797;
+}
+
+* { box-sizing: border-box; }
+html, body { min-width: 320px; min-height: 100%; margin: 0; }
+body { min-height: 100vh; background: #101310; }
+button, input { font: inherit; letter-spacing: 0; }
+button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
+
+.app-header {
+ display: flex;
+ min-height: 58px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 9px clamp(16px, 3vw, 40px);
+ border-bottom: 1px solid var(--border);
+}
+.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; }
+.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #326645; border-radius: 6px; color: var(--green); background: #14271b; }
+.status { color: var(--muted); font-size: 13px; }
+
+main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; width: min(1400px, 100%); min-height: calc(100vh - 58px); margin: 0 auto; }
+.stage { min-width: 0; padding: 28px clamp(18px, 3vw, 42px) 40px; }
+.stage-heading { display: flex; min-height: 58px; align-items: flex-start; justify-content: space-between; gap: 16px; }
+h1 { margin: 0; font-size: 24px; }
+.stage-heading p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
+.preview-shell { position: relative; display: grid; width: 100%; margin-top: 18px; overflow: hidden; aspect-ratio: 16 / 9; place-items: center; border: 1px solid var(--border); border-radius: 6px; background: #080a08; }
+video { display: block; width: 100%; height: 100%; object-fit: contain; }
+.empty-state { position: absolute; color: var(--muted); font-size: 14px; }
+.invite { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 8px; margin-top: 14px; }
+.invite a { min-width: 0; overflow: hidden; padding: 11px 12px; border: 1px solid var(--border); border-radius: 6px; color: #c8f7d9; text-overflow: ellipsis; white-space: nowrap; }
+
+.controls { display: grid; align-content: start; gap: 18px; padding: 32px 24px; border-left: 1px solid var(--border); background: var(--surface); }
+label { display: grid; gap: 8px; color: var(--muted); font-size: 13px; }
+input { width: 100%; min-height: 44px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 6px; color: #f1f3f1; background: #101310; }
+.button, .icon-button { min-height: 44px; border: 1px solid transparent; border-radius: 6px; color: inherit; cursor: pointer; }
+.button:disabled { cursor: not-allowed; opacity: .5; }
+.primary { color: #07150c; font-weight: 700; background: var(--green); }
+.primary:hover { background: var(--green-strong); }
+.secondary, .icon-button { border-color: var(--border); background: var(--surface-raised); }
+.icon-button { font-size: 20px; }
+.notice { margin: -6px 0 0; color: #e9c979; font-size: 13px; line-height: 1.45; }
+.error { min-height: 20px; margin: 0; color: var(--red); font-size: 13px; line-height: 1.45; }
+details { border-top: 1px solid var(--border); padding-top: 16px; color: var(--muted); font-size: 13px; }
+summary { cursor: pointer; }
+dl { display: grid; gap: 10px; margin: 14px 0 0; }
+dl div { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 12px; }
+dt, dd { margin: 0; }
+dt { flex: 0 0 auto; }
+dd { min-width: 0; overflow-wrap: anywhere; color: #f1f3f1; text-align: right; }
+
+@media (max-width: 820px) {
+ main { grid-template-columns: 1fr; }
+ .controls { grid-row: 1; border-right: 0; border-bottom: 1px solid var(--border); border-left: 0; }
+ .stage { padding-top: 22px; }
+}
diff --git a/native/sender/internal/media/fanout.go b/native/sender/internal/media/fanout.go
new file mode 100644
index 0000000..d331dcd
--- /dev/null
+++ b/native/sender/internal/media/fanout.go
@@ -0,0 +1,257 @@
+package media
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+
+ "github.com/pion/rtp"
+ "github.com/pion/rtp/codecs"
+ "github.com/pion/webrtc/v4"
+)
+
+const (
+ FrameHeaderBytes = 17
+ MaxFrameBytes = 1 << 20
+ queueCapacity = 8
+ videoClockRate = 90_000
+)
+
+type Frame struct {
+ KeyFrame bool
+ TimestampMicros uint64
+ DurationMicros uint64
+ Data []byte
+}
+
+type Metrics struct {
+ Queue QueueMetrics `json:"queue"`
+ FramesWritten uint64 `json:"framesWritten"`
+ SourceRTPPacketsWritten uint64 `json:"sourceRtpPacketsWritten"`
+ SourceRTPBytesWritten uint64 `json:"sourceRtpBytesWritten"`
+ SourceTimestampGapMicros uint64 `json:"sourceTimestampGapMicros"`
+ LastSourceTimestampMicros uint64 `json:"lastSourceTimestampMicros"`
+}
+
+type rtpWriter interface {
+ WriteRTP(*rtp.Packet) error
+}
+
+type Fanout struct {
+ track *webrtc.TrackLocalStaticRTP
+ writer rtpWriter
+ queue *frameQueue
+ ctx context.Context
+ cancel context.CancelFunc
+ done chan struct{}
+ closeOnce sync.Once
+ fatalOnce sync.Once
+ requestKeyFrame func()
+ onFatal func(error)
+ metricsMu sync.Mutex
+ metrics Metrics
+}
+
+func NewFanout(requestKeyFrame func(), onFatal func(error)) (*Fanout, error) {
+ track, err := webrtc.NewTrackLocalStaticRTP(
+ webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: videoClockRate},
+ "screen",
+ "screener",
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create shared VP8 track: %w", err)
+ }
+ return newFanout(track, track, requestKeyFrame, onFatal), nil
+}
+
+func newFanout(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, requestKeyFrame func(), onFatal func(error)) *Fanout {
+ ctx, cancel := context.WithCancel(context.Background())
+ fanout := &Fanout{
+ track: track,
+ writer: writer,
+ queue: newFrameQueue(queueCapacity),
+ ctx: ctx,
+ cancel: cancel,
+ done: make(chan struct{}),
+ requestKeyFrame: requestKeyFrame,
+ onFatal: onFatal,
+ }
+ go fanout.writeLoop()
+ return fanout
+}
+
+func (fanout *Fanout) Track() *webrtc.TrackLocalStaticRTP {
+ return fanout.track
+}
+
+func (fanout *Fanout) Push(frame Frame) error {
+ if len(frame.Data) == 0 || len(frame.Data) > MaxFrameBytes || frame.DurationMicros == 0 {
+ return errors.New("encoded VP8 frame violates the bounded media contract")
+ }
+ result, err := fanout.queue.Push(frame)
+ if err != nil {
+ return err
+ }
+ if result.requestKeyFrame && fanout.requestKeyFrame != nil {
+ fanout.requestKeyFrame()
+ }
+ return nil
+}
+
+func (fanout *Fanout) Snapshot() Metrics {
+ fanout.metricsMu.Lock()
+ metrics := fanout.metrics
+ fanout.metricsMu.Unlock()
+ metrics.Queue = fanout.queue.Snapshot()
+ return metrics
+}
+
+func (fanout *Fanout) Close() {
+ fanout.stop()
+ <-fanout.done
+}
+
+func (fanout *Fanout) writeLoop() {
+ defer close(fanout.done)
+ timeline := newFrameTimeline()
+ for {
+ frame, ok, err := fanout.queue.Pop(fanout.ctx)
+ if err != nil {
+ if !errors.Is(err, context.Canceled) {
+ fanout.fail(fmt.Errorf("read encoded media queue: %w", err))
+ }
+ return
+ }
+ if !ok {
+ return
+ }
+ packets, gapMicros, err := timeline.Packetize(frame)
+ if err != nil {
+ fanout.fail(err)
+ return
+ }
+ var sourceBytes uint64
+ for _, packet := range packets {
+ if err = fanout.writer.WriteRTP(packet); err != nil {
+ fanout.fail(errors.New("write shared RTP packet failed"))
+ return
+ }
+ sourceBytes += uint64(packet.MarshalSize())
+ }
+ fanout.queue.MarkRecoveryWritten(frame.KeyFrame)
+ fanout.metricsMu.Lock()
+ fanout.metrics.FramesWritten++
+ fanout.metrics.SourceRTPPacketsWritten += uint64(len(packets))
+ fanout.metrics.SourceRTPBytesWritten += sourceBytes
+ fanout.metrics.SourceTimestampGapMicros += gapMicros
+ fanout.metrics.LastSourceTimestampMicros = frame.TimestampMicros
+ fanout.metricsMu.Unlock()
+ }
+}
+
+func (fanout *Fanout) fail(err error) {
+ fanout.fatalOnce.Do(func() {
+ fanout.stop()
+ if fanout.onFatal != nil {
+ go func() {
+ <-fanout.done
+ fanout.onFatal(err)
+ }()
+ }
+ })
+}
+
+func (fanout *Fanout) stop() {
+ fanout.closeOnce.Do(func() {
+ fanout.cancel()
+ fanout.queue.Close()
+ })
+}
+
+type frameTimeline struct {
+ packetizer rtp.Packetizer
+ hasPrevious bool
+ previousTimestampMicros uint64
+ previousDurationMicros uint64
+}
+
+func newFrameTimeline() *frameTimeline {
+ return &frameTimeline{packetizer: rtp.NewPacketizer(
+ 1200,
+ 96,
+ 0,
+ &codecs.VP8Payloader{},
+ rtp.NewRandomSequencer(),
+ videoClockRate,
+ )}
+}
+
+func (timeline *frameTimeline) Packetize(frame Frame) ([]*rtp.Packet, uint64, error) {
+ var gapMicros uint64
+ if timeline.hasPrevious {
+ if frame.TimestampMicros <= timeline.previousTimestampMicros {
+ return nil, 0, errors.New("encoded frame timestamp did not increase")
+ }
+ if timeline.previousTimestampMicros > math.MaxUint64-timeline.previousDurationMicros {
+ return nil, 0, errors.New("encoded frame timestamp overflowed")
+ }
+ expected := timeline.previousTimestampMicros + timeline.previousDurationMicros
+ if frame.TimestampMicros < expected {
+ return nil, 0, errors.New("encoded frame timestamps overlap")
+ }
+ gapMicros = frame.TimestampMicros - expected
+ gapSamples, err := microsToSamples(gapMicros)
+ if err != nil {
+ return nil, 0, err
+ }
+ timeline.packetizer.SkipSamples(gapSamples)
+ }
+ durationSamples, err := microsToSamples(frame.DurationMicros)
+ if err != nil {
+ return nil, 0, err
+ }
+ if durationSamples == 0 {
+ durationSamples = uint32(videoClockRate / 30)
+ }
+ packets := timeline.packetizer.Packetize(frame.Data, durationSamples)
+ timeline.hasPrevious = true
+ timeline.previousTimestampMicros = frame.TimestampMicros
+ timeline.previousDurationMicros = frame.DurationMicros
+ return packets, gapMicros, nil
+}
+
+func microsToSamples(micros uint64) (uint32, error) {
+ seconds := micros / 1_000_000
+ remainder := micros % 1_000_000
+ samples := seconds*videoClockRate + (remainder*videoClockRate+500_000)/1_000_000
+ if samples > math.MaxUint32 {
+ return 0, errors.New("encoded frame timestamp gap is too large")
+ }
+ return uint32(samples), nil
+}
+
+func DecodeFrame(payload []byte) (Frame, error) {
+ if len(payload) <= FrameHeaderBytes || len(payload)-FrameHeaderBytes > MaxFrameBytes {
+ return Frame{}, errors.New("encoded frame is empty, truncated, or too large")
+ }
+ frame := Frame{
+ TimestampMicros: binary.BigEndian.Uint64(payload[1:9]),
+ DurationMicros: binary.BigEndian.Uint64(payload[9:17]),
+ Data: append([]byte(nil), payload[FrameHeaderBytes:]...),
+ }
+ switch payload[0] {
+ case 1:
+ frame.KeyFrame = true
+ case 2:
+ default:
+ return Frame{}, errors.New("encoded frame type is invalid")
+ }
+ if frame.DurationMicros == 0 || frame.DurationMicros > uint64(time.Second/time.Microsecond) {
+ return Frame{}, errors.New("encoded frame duration is invalid")
+ }
+ return frame, nil
+}
diff --git a/native/sender/internal/media/fanout_test.go b/native/sender/internal/media/fanout_test.go
new file mode 100644
index 0000000..454d019
--- /dev/null
+++ b/native/sender/internal/media/fanout_test.go
@@ -0,0 +1,179 @@
+package media
+
+import (
+ "encoding/binary"
+ "errors"
+ "runtime"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pion/rtp"
+)
+
+func TestFrameTimelinePreservesDroppedSourceTime(t *testing.T) {
+ timeline := newFrameTimeline()
+ first, _, err := timeline.Packetize(Frame{
+ KeyFrame: true,
+ TimestampMicros: 0,
+ DurationMicros: 33_333,
+ Data: []byte{1, 2, 3},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, gap, err := timeline.Packetize(Frame{
+ TimestampMicros: 66_666,
+ DurationMicros: 33_333,
+ Data: []byte{4, 5, 6},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gap != 33_333 {
+ t.Fatalf("source gap = %d, want 33333 microseconds", gap)
+ }
+ if delta := second[0].Timestamp - first[0].Timestamp; delta != 6_000 {
+ t.Fatalf("RTP timestamp delta = %d, want 6000 samples", delta)
+ }
+}
+
+func TestFrameTimelineRejectsOverlappingSourceTime(t *testing.T) {
+ timeline := newFrameTimeline()
+ _, _, err := timeline.Packetize(Frame{
+ TimestampMicros: 100,
+ DurationMicros: 50,
+ Data: []byte{1},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, err = timeline.Packetize(Frame{
+ TimestampMicros: 149,
+ DurationMicros: 50,
+ Data: []byte{2},
+ })
+ if err == nil {
+ t.Fatal("overlapping source timestamps were accepted")
+ }
+}
+
+func TestFanoutPropagatesWriterFailure(t *testing.T) {
+ written := make(chan struct{}, 1)
+ fatal := make(chan error, 1)
+ fanout := newFanout(nil, rtpWriterFunc(func(*rtp.Packet) error {
+ written <- struct{}{}
+ return errors.New("write failed")
+ }), nil, func(err error) { fatal <- err })
+
+ if err := fanout.Push(Frame{
+ KeyFrame: true,
+ TimestampMicros: 1,
+ DurationMicros: 33_333,
+ Data: []byte{1},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-written:
+ case <-time.After(time.Second):
+ t.Fatal("writer was not called")
+ }
+ select {
+ case err := <-fatal:
+ if err == nil || err.Error() != "write shared RTP packet failed" {
+ t.Fatalf("fatal error = %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("writer failure was not propagated")
+ }
+ fanout.Close()
+}
+
+func TestFanoutCloseDropsBacklogWithoutLateWritesOrFatal(t *testing.T) {
+ writer := &blockingWriter{started: make(chan struct{}), release: make(chan struct{})}
+ fatal := make(chan error, 1)
+ fanout := newFanout(nil, writer, nil, func(err error) { fatal <- err })
+
+ if err := fanout.Push(Frame{KeyFrame: true, TimestampMicros: 1, DurationMicros: 1, Data: []byte{1}}); err != nil {
+ t.Fatal(err)
+ }
+ <-writer.started
+ for timestamp := uint64(2); timestamp <= 5; timestamp++ {
+ if err := fanout.Push(Frame{TimestampMicros: timestamp, DurationMicros: 1, Data: []byte{1}}); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ closed := make(chan struct{})
+ go func() {
+ fanout.Close()
+ close(closed)
+ }()
+ deadline := time.Now().Add(time.Second)
+ for fanout.Snapshot().Queue.ShutdownDroppedFrames != 4 {
+ if time.Now().After(deadline) {
+ t.Fatal("fanout close did not discard the queued backlog")
+ }
+ runtime.Gosched()
+ }
+ close(writer.release)
+ select {
+ case <-closed:
+ case <-time.After(time.Second):
+ t.Fatal("fanout close did not wait for the writer loop")
+ }
+ if writes := writer.Writes(); writes != 1 {
+ t.Fatalf("writes after close = %d, want only the in-flight write", writes)
+ }
+ select {
+ case err := <-fatal:
+ t.Fatalf("close reported a fatal error: %v", err)
+ default:
+ }
+}
+
+func TestDecodeFrame(t *testing.T) {
+ payload := make([]byte, FrameHeaderBytes+3)
+ payload[0] = 1
+ binary.BigEndian.PutUint64(payload[1:9], 123)
+ binary.BigEndian.PutUint64(payload[9:17], 33_333)
+ copy(payload[17:], []byte{1, 2, 3})
+
+ frame, err := DecodeFrame(payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !frame.KeyFrame || frame.TimestampMicros != 123 || frame.DurationMicros != 33_333 {
+ t.Fatalf("decoded frame = %+v", frame)
+ }
+}
+
+type rtpWriterFunc func(*rtp.Packet) error
+
+func (write rtpWriterFunc) WriteRTP(packet *rtp.Packet) error {
+ return write(packet)
+}
+
+type blockingWriter struct {
+ mu sync.Mutex
+ started chan struct{}
+ release chan struct{}
+ writes int
+ once sync.Once
+}
+
+func (writer *blockingWriter) WriteRTP(*rtp.Packet) error {
+ writer.once.Do(func() { close(writer.started) })
+ <-writer.release
+ writer.mu.Lock()
+ writer.writes++
+ writer.mu.Unlock()
+ return nil
+}
+
+func (writer *blockingWriter) Writes() int {
+ writer.mu.Lock()
+ defer writer.mu.Unlock()
+ return writer.writes
+}
diff --git a/native/sender/internal/media/queue.go b/native/sender/internal/media/queue.go
new file mode 100644
index 0000000..42ad48c
--- /dev/null
+++ b/native/sender/internal/media/queue.go
@@ -0,0 +1,157 @@
+package media
+
+import (
+ "context"
+ "errors"
+ "sync"
+)
+
+type QueueMetrics struct {
+ Capacity int `json:"capacity"`
+ Depth int `json:"depth"`
+ MaxDepth int `json:"maxDepth"`
+ AcceptedFrames uint64 `json:"acceptedFrames"`
+ DroppedFrames uint64 `json:"droppedFrames"`
+ ShutdownDroppedFrames uint64 `json:"shutdownDroppedFrames"`
+ OverloadEvents uint64 `json:"overloadEvents"`
+ RecoveryKeyFrames uint64 `json:"recoveryKeyFrames"`
+ CompletedRecoveries uint64 `json:"completedRecoveries"`
+ UnrecoverableOverload uint64 `json:"unrecoverableOverload"`
+}
+
+type queuePushResult struct {
+ accepted bool
+ requestKeyFrame bool
+}
+
+// frameQueue owns the only helper-side media backlog. After an overload it
+// discards dependent frames until a key frame restores a decodable boundary.
+type frameQueue struct {
+ mu sync.Mutex
+ items []Frame
+ capacity int
+ droppingUntilKeyFrame bool
+ recoveryPending bool
+ closed bool
+ notify chan struct{}
+ metrics QueueMetrics
+}
+
+func newFrameQueue(capacity int) *frameQueue {
+ if capacity < 1 {
+ panic("frame queue capacity must be positive")
+ }
+ return &frameQueue{
+ capacity: capacity,
+ notify: make(chan struct{}, 1),
+ metrics: QueueMetrics{Capacity: capacity},
+ }
+}
+
+func (queue *frameQueue) Push(frame Frame) (queuePushResult, error) {
+ queue.mu.Lock()
+ defer queue.mu.Unlock()
+ if queue.closed {
+ return queuePushResult{}, errors.New("encoded media queue is closed")
+ }
+
+ if queue.droppingUntilKeyFrame {
+ if !frame.KeyFrame {
+ queue.metrics.DroppedFrames++
+ return queuePushResult{}, nil
+ }
+ queue.droppingUntilKeyFrame = false
+ }
+
+ requestKeyFrame := false
+ if len(queue.items) >= queue.capacity {
+ if queue.recoveryPending {
+ queue.metrics.UnrecoverableOverload++
+ return queuePushResult{}, errors.New("encoded media queue overloaded before key-frame recovery completed")
+ }
+ queue.metrics.DroppedFrames += uint64(len(queue.items))
+ queue.metrics.OverloadEvents++
+ queue.metrics.RecoveryKeyFrames++
+ clear(queue.items)
+ queue.items = queue.items[:0]
+ queue.droppingUntilKeyFrame = true
+ queue.recoveryPending = true
+ requestKeyFrame = true
+ if !frame.KeyFrame {
+ queue.metrics.DroppedFrames++
+ return queuePushResult{requestKeyFrame: true}, nil
+ }
+ queue.droppingUntilKeyFrame = false
+ }
+
+ frame.Data = append([]byte(nil), frame.Data...)
+ queue.items = append(queue.items, frame)
+ queue.metrics.AcceptedFrames++
+ if len(queue.items) > queue.metrics.MaxDepth {
+ queue.metrics.MaxDepth = len(queue.items)
+ }
+ queue.signal()
+ return queuePushResult{accepted: true, requestKeyFrame: requestKeyFrame}, nil
+}
+
+func (queue *frameQueue) Pop(ctx context.Context) (Frame, bool, error) {
+ for {
+ queue.mu.Lock()
+ if len(queue.items) > 0 {
+ frame := queue.items[0]
+ queue.items[0] = Frame{}
+ queue.items = queue.items[1:]
+ queue.mu.Unlock()
+ return frame, true, nil
+ }
+ if queue.closed {
+ queue.mu.Unlock()
+ return Frame{}, false, nil
+ }
+ queue.mu.Unlock()
+
+ select {
+ case <-queue.notify:
+ case <-ctx.Done():
+ return Frame{}, false, ctx.Err()
+ }
+ }
+}
+
+func (queue *frameQueue) MarkRecoveryWritten(keyFrame bool) {
+ if !keyFrame {
+ return
+ }
+ queue.mu.Lock()
+ defer queue.mu.Unlock()
+ if queue.recoveryPending {
+ queue.recoveryPending = false
+ queue.metrics.CompletedRecoveries++
+ }
+}
+
+func (queue *frameQueue) Close() {
+ queue.mu.Lock()
+ queue.metrics.ShutdownDroppedFrames += uint64(len(queue.items))
+ queue.metrics.DroppedFrames += uint64(len(queue.items))
+ clear(queue.items)
+ queue.items = nil
+ queue.closed = true
+ queue.mu.Unlock()
+ queue.signal()
+}
+
+func (queue *frameQueue) Snapshot() QueueMetrics {
+ queue.mu.Lock()
+ defer queue.mu.Unlock()
+ metrics := queue.metrics
+ metrics.Depth = len(queue.items)
+ return metrics
+}
+
+func (queue *frameQueue) signal() {
+ select {
+ case queue.notify <- struct{}{}:
+ default:
+ }
+}
diff --git a/native/sender/internal/media/queue_test.go b/native/sender/internal/media/queue_test.go
new file mode 100644
index 0000000..340aefa
--- /dev/null
+++ b/native/sender/internal/media/queue_test.go
@@ -0,0 +1,93 @@
+package media
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestFrameQueueRecoversAtAKeyFrameAfterOneOverload(t *testing.T) {
+ queue := newFrameQueue(2)
+ defer queue.Close()
+
+ pushFrame(t, queue, false, 1)
+ pushFrame(t, queue, false, 2)
+ result, err := queue.Push(testFrame(false, 3))
+ if err != nil {
+ t.Fatalf("first overload returned an error: %v", err)
+ }
+ if result.accepted || !result.requestKeyFrame {
+ t.Fatalf("first overload result = %+v, want dropped frame and key-frame request", result)
+ }
+ result, err = queue.Push(testFrame(false, 4))
+ if err != nil || result.accepted || result.requestKeyFrame {
+ t.Fatalf("recovery delta result = %+v, %v", result, err)
+ }
+ result, err = queue.Push(testFrame(true, 5))
+ if err != nil || !result.accepted {
+ t.Fatalf("recovery key frame result = %+v, %v", result, err)
+ }
+
+ frame, ok, err := queue.Pop(context.Background())
+ if err != nil || !ok || !frame.KeyFrame || frame.TimestampMicros != 5 {
+ t.Fatalf("recovery pop = %+v, %t, %v", frame, ok, err)
+ }
+ queue.MarkRecoveryWritten(true)
+
+ metrics := queue.Snapshot()
+ if metrics.OverloadEvents != 1 || metrics.RecoveryKeyFrames != 1 ||
+ metrics.CompletedRecoveries != 1 || metrics.DroppedFrames != 4 {
+ t.Fatalf("unexpected recovery metrics: %+v", metrics)
+ }
+}
+
+func TestFrameQueueFailsOnASecondOverloadBeforeRecoveryIsWritten(t *testing.T) {
+ queue := newFrameQueue(1)
+ defer queue.Close()
+
+ pushFrame(t, queue, false, 1)
+ result, err := queue.Push(testFrame(true, 2))
+ if err != nil || !result.accepted || !result.requestKeyFrame {
+ t.Fatalf("first overload result = %+v, %v", result, err)
+ }
+ _, err = queue.Push(testFrame(false, 3))
+ if err == nil || !strings.Contains(err.Error(), "before key-frame recovery completed") {
+ t.Fatalf("second overload error = %v", err)
+ }
+ if queue.Snapshot().UnrecoverableOverload != 1 {
+ t.Fatalf("unrecoverable overload was not counted: %+v", queue.Snapshot())
+ }
+}
+
+func TestFrameQueueCloseDiscardsBacklog(t *testing.T) {
+ queue := newFrameQueue(2)
+ pushFrame(t, queue, true, 1)
+ pushFrame(t, queue, false, 2)
+ queue.Close()
+
+ _, ok, err := queue.Pop(context.Background())
+ if err != nil || ok {
+ t.Fatalf("closed queue pop = %t, %v; want no frame", ok, err)
+ }
+ metrics := queue.Snapshot()
+ if metrics.ShutdownDroppedFrames != 2 || metrics.DroppedFrames != 2 {
+ t.Fatalf("shutdown drops = %+v, want 2", metrics)
+ }
+}
+
+func pushFrame(t *testing.T, queue *frameQueue, keyFrame bool, timestamp uint64) {
+ t.Helper()
+ result, err := queue.Push(testFrame(keyFrame, timestamp))
+ if err != nil || !result.accepted {
+ t.Fatalf("push frame %d = %+v, %v", timestamp, result, err)
+ }
+}
+
+func testFrame(keyFrame bool, timestamp uint64) Frame {
+ return Frame{
+ KeyFrame: keyFrame,
+ TimestampMicros: timestamp,
+ DurationMicros: 1,
+ Data: []byte{byte(timestamp)},
+ }
+}
diff --git a/native/sender/internal/remote/admission.go b/native/sender/internal/remote/admission.go
new file mode 100644
index 0000000..beb6644
--- /dev/null
+++ b/native/sender/internal/remote/admission.go
@@ -0,0 +1,96 @@
+package remote
+
+type admissionAction uint8
+
+const (
+ admissionDuplicate admissionAction = iota
+ admissionActivate
+ admissionWait
+ admissionFull
+)
+
+type viewerAdmission struct {
+ edgeLimit int
+ roomLimit int
+ active map[string]struct{}
+ waiting []string
+ waitSet map[string]struct{}
+}
+
+func newViewerAdmission(maxViewers int) *viewerAdmission {
+ edgeLimit := maxHostEdges
+ if maxViewers < edgeLimit {
+ edgeLimit = maxViewers
+ }
+ return &viewerAdmission{
+ edgeLimit: edgeLimit,
+ roomLimit: maxViewers,
+ active: make(map[string]struct{}),
+ waitSet: make(map[string]struct{}),
+ }
+}
+
+func (admission *viewerAdmission) Join(peerID string) admissionAction {
+ if _, exists := admission.active[peerID]; exists {
+ return admissionDuplicate
+ }
+ if _, exists := admission.waitSet[peerID]; exists {
+ return admissionDuplicate
+ }
+ if len(admission.active) < admission.edgeLimit {
+ admission.active[peerID] = struct{}{}
+ return admissionActivate
+ }
+ if len(admission.active)+len(admission.waiting) >= admission.roomLimit {
+ return admissionFull
+ }
+ admission.waiting = append(admission.waiting, peerID)
+ admission.waitSet[peerID] = struct{}{}
+ return admissionWait
+}
+
+func (admission *viewerAdmission) Leave(peerID string) (wasActive bool, promoted string) {
+ if _, exists := admission.active[peerID]; exists {
+ delete(admission.active, peerID)
+ return true, admission.promoteNext()
+ }
+ if _, exists := admission.waitSet[peerID]; !exists {
+ return false, ""
+ }
+ delete(admission.waitSet, peerID)
+ for index, waitingID := range admission.waiting {
+ if waitingID != peerID {
+ continue
+ }
+ copy(admission.waiting[index:], admission.waiting[index+1:])
+ admission.waiting[len(admission.waiting)-1] = ""
+ admission.waiting = admission.waiting[:len(admission.waiting)-1]
+ break
+ }
+ return false, ""
+}
+
+func (admission *viewerAdmission) AbortActivation(peerID string) {
+ delete(admission.active, peerID)
+}
+
+func (admission *viewerAdmission) Counts() (active, waiting int) {
+ return len(admission.active), len(admission.waiting)
+}
+
+func (admission *viewerAdmission) IsActive(peerID string) bool {
+ _, exists := admission.active[peerID]
+ return exists
+}
+
+func (admission *viewerAdmission) promoteNext() string {
+ if len(admission.waiting) == 0 {
+ return ""
+ }
+ promoted := admission.waiting[0]
+ admission.waiting[0] = ""
+ admission.waiting = admission.waiting[1:]
+ delete(admission.waitSet, promoted)
+ admission.active[promoted] = struct{}{}
+ return promoted
+}
diff --git a/native/sender/internal/remote/admission_test.go b/native/sender/internal/remote/admission_test.go
new file mode 100644
index 0000000..30c8763
--- /dev/null
+++ b/native/sender/internal/remote/admission_test.go
@@ -0,0 +1,83 @@
+package remote
+
+import "testing"
+
+func TestViewerAdmissionNeverExceedsTwoEdgesAndPromotesInJoinOrder(t *testing.T) {
+ admission := newViewerAdmission(4)
+ assertAdmission(t, admission, "viewer-a", admissionActivate)
+ assertAdmission(t, admission, "viewer-b", admissionActivate)
+ assertAdmission(t, admission, "viewer-c", admissionWait)
+ assertAdmission(t, admission, "viewer-d", admissionWait)
+ assertAdmission(t, admission, "viewer-c", admissionDuplicate)
+ assertAdmission(t, admission, "viewer-e", admissionFull)
+
+ active, waiting := admission.Counts()
+ if active != 2 || waiting != 2 {
+ t.Fatalf("counts = %d active, %d waiting", active, waiting)
+ }
+ wasActive, promoted := admission.Leave("viewer-b")
+ if !wasActive || promoted != "viewer-c" {
+ t.Fatalf("leave promoted %q from active=%t", promoted, wasActive)
+ }
+ if admission.IsActive("viewer-b") || !admission.IsActive("viewer-c") {
+ t.Fatal("promotion did not atomically replace the freed edge")
+ }
+ active, waiting = admission.Counts()
+ if active != 2 || waiting != 1 {
+ t.Fatalf("post-promotion counts = %d active, %d waiting", active, waiting)
+ }
+}
+
+func TestViewerAdmissionRemovesAWaitingViewerWithoutPromotion(t *testing.T) {
+ admission := newViewerAdmission(3)
+ assertAdmission(t, admission, "viewer-a", admissionActivate)
+ assertAdmission(t, admission, "viewer-b", admissionActivate)
+ assertAdmission(t, admission, "viewer-c", admissionWait)
+
+ wasActive, promoted := admission.Leave("viewer-c")
+ if wasActive || promoted != "" {
+ t.Fatalf("waiting leave = active %t, promotion %q", wasActive, promoted)
+ }
+ active, waiting := admission.Counts()
+ if active != 2 || waiting != 0 {
+ t.Fatalf("counts = %d active, %d waiting", active, waiting)
+ }
+}
+
+func TestViewerAdmissionUsesTheSmallerRoomLimit(t *testing.T) {
+ admission := newViewerAdmission(1)
+ assertAdmission(t, admission, "viewer-a", admissionActivate)
+ assertAdmission(t, admission, "viewer-b", admissionFull)
+ active, waiting := admission.Counts()
+ if active != 1 || waiting != 0 {
+ t.Fatalf("counts = %d active, %d waiting", active, waiting)
+ }
+}
+
+func TestViewerAdmissionClearsFailedActivation(t *testing.T) {
+ admission := newViewerAdmission(4)
+ assertAdmission(t, admission, "viewer-a", admissionActivate)
+ assertAdmission(t, admission, "viewer-b", admissionActivate)
+ assertAdmission(t, admission, "viewer-c", admissionWait)
+ assertAdmission(t, admission, "viewer-d", admissionWait)
+
+ _, promoted := admission.Leave("viewer-a")
+ if promoted != "viewer-c" {
+ t.Fatalf("first promotion = %q", promoted)
+ }
+ admission.AbortActivation(promoted)
+ if admission.IsActive("viewer-c") || admission.IsActive("viewer-d") {
+ t.Fatal("failed activation left a viewer in the active set")
+ }
+ active, waiting := admission.Counts()
+ if active != 1 || waiting != 1 {
+ t.Fatalf("counts = %d active, %d waiting", active, waiting)
+ }
+}
+
+func assertAdmission(t *testing.T, admission *viewerAdmission, peerID string, want admissionAction) {
+ t.Helper()
+ if got := admission.Join(peerID); got != want {
+ t.Fatalf("join %q = %d, want %d", peerID, got, want)
+ }
+}
diff --git a/native/sender/internal/remote/peer.go b/native/sender/internal/remote/peer.go
new file mode 100644
index 0000000..3d6a192
--- /dev/null
+++ b/native/sender/internal/remote/peer.go
@@ -0,0 +1,300 @@
+package remote
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/pion/rtcp"
+ "github.com/pion/webrtc/v4"
+)
+
+type peer struct {
+ session *Session
+ peerID string
+ slot int
+ connectionID string
+ connection *webrtc.PeerConnection
+
+ mu sync.Mutex
+ pendingCandidates []webrtc.ICECandidateInit
+ remoteDescription bool
+ closeOnce sync.Once
+ firstConnected sync.Once
+ closed atomic.Bool
+ pliRequests atomic.Uint64
+ firRequests atomic.Uint64
+ diagnosticsMu sync.Mutex
+ lastSampleAt time.Time
+ lastPacketsSent uint64
+ lastBytesSent uint64
+}
+
+type PeerDiagnostics struct {
+ Slot int `json:"slot"`
+ ConnectionState string `json:"connectionState"`
+ ICEConnectionState string `json:"iceConnectionState"`
+ Route string `json:"route"`
+ PacketsSent uint64 `json:"packetsSent"`
+ BytesSent uint64 `json:"bytesSent"`
+ BitrateBPS *uint64 `json:"bitrateBps,omitempty"`
+ CurrentRoundTripTimeMS *uint64 `json:"currentRoundTripTimeMs,omitempty"`
+ AvailableOutgoingBitrate *uint64 `json:"availableOutgoingBitrate,omitempty"`
+ PLIRequests uint64 `json:"pliRequests"`
+ FIRRequests uint64 `json:"firRequests"`
+}
+
+func newPeer(session *Session, peerID string, slot int, configuration webrtc.Configuration) (*peer, error) {
+ connectionID, err := opaqueID()
+ if err != nil {
+ return nil, fmt.Errorf("create connection identity: %w", err)
+ }
+ connection, err := webrtc.NewPeerConnection(configuration)
+ if err != nil {
+ return nil, errors.New("create viewer peer connection failed")
+ }
+ peer := &peer{
+ session: session,
+ peerID: peerID,
+ slot: slot,
+ connectionID: connectionID,
+ connection: connection,
+ }
+ connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
+ if peer.closed.Load() {
+ return
+ }
+ payload := outboundCandidatePayload{Kind: "candidate", ConnectionID: connectionID}
+ if candidate != nil {
+ init := candidate.ToJSON()
+ payload.Candidate = &iceCandidate{
+ Candidate: init.Candidate,
+ SDPMid: init.SDPMid,
+ SDPMLineIndex: init.SDPMLineIndex,
+ UsernameFragment: init.UsernameFragment,
+ }
+ }
+ if sendErr := session.sendSignal(peerID, payload); sendErr != nil && !session.closing.Load() {
+ go session.fail(errors.New("send viewer ICE candidate failed"))
+ }
+ })
+ connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
+ if !peer.closed.Load() {
+ session.emit(Event{Kind: "peer-state", Message: fmt.Sprintf("Media edge %d is %s", slot, state.String())})
+ if state == webrtc.PeerConnectionStateConnected {
+ peer.firstConnected.Do(func() { session.emit(Event{Kind: "request-keyframe"}) })
+ }
+ }
+ })
+ sender, err := connection.AddTrack(session.fanout.Track())
+ if err != nil {
+ _ = connection.Close()
+ return nil, errors.New("bind shared VP8 track failed")
+ }
+ go peer.readRTCP(sender)
+ return peer, nil
+}
+
+func (peer *peer) offer(restart bool) error {
+ offer, err := peer.connection.CreateOffer(&webrtc.OfferOptions{ICERestart: restart})
+ if err != nil {
+ return errors.New("create viewer offer failed")
+ }
+ if err = peer.connection.SetLocalDescription(offer); err != nil {
+ return errors.New("set viewer offer failed")
+ }
+ local := peer.connection.LocalDescription()
+ if local == nil {
+ return errors.New("viewer offer is unavailable")
+ }
+ if err = peer.session.sendSignal(peer.peerID, outboundDescriptionPayload{
+ Kind: "description",
+ ConnectionID: peer.connectionID,
+ Description: sessionDescription{
+ Type: "offer",
+ SDP: local.SDP,
+ },
+ }); err != nil {
+ return errors.New("send viewer offer failed")
+ }
+ return nil
+}
+
+func (peer *peer) restartICE() error {
+ return peer.offer(true)
+}
+
+func (peer *peer) acceptSignal(payload inboundSignalPayload) error {
+ if payload.ConnectionID != peer.connectionID {
+ return nil
+ }
+ switch payload.Kind {
+ case "description":
+ if payload.Description == nil || payload.Description.Type != "answer" || payload.Description.SDP == "" {
+ return errors.New("viewer returned an invalid answer")
+ }
+ if err := peer.connection.SetRemoteDescription(webrtc.SessionDescription{
+ Type: webrtc.SDPTypeAnswer,
+ SDP: payload.Description.SDP,
+ }); err != nil {
+ return errors.New("apply viewer answer failed")
+ }
+ peer.mu.Lock()
+ peer.remoteDescription = true
+ pending := append([]webrtc.ICECandidateInit(nil), peer.pendingCandidates...)
+ peer.pendingCandidates = nil
+ peer.mu.Unlock()
+ for _, candidate := range pending {
+ if err := peer.connection.AddICECandidate(candidate); err != nil {
+ return errors.New("apply queued viewer ICE candidate failed")
+ }
+ }
+ return nil
+ case "candidate":
+ candidate := webrtc.ICECandidateInit{}
+ if payload.Candidate != nil {
+ candidate = webrtc.ICECandidateInit{
+ Candidate: payload.Candidate.Candidate,
+ SDPMid: payload.Candidate.SDPMid,
+ SDPMLineIndex: payload.Candidate.SDPMLineIndex,
+ UsernameFragment: payload.Candidate.UsernameFragment,
+ }
+ }
+ peer.mu.Lock()
+ if !peer.remoteDescription {
+ if len(peer.pendingCandidates) >= 64 {
+ peer.mu.Unlock()
+ return errors.New("viewer ICE candidate queue exceeded its bound")
+ }
+ peer.pendingCandidates = append(peer.pendingCandidates, candidate)
+ peer.mu.Unlock()
+ return nil
+ }
+ peer.mu.Unlock()
+ if err := peer.connection.AddICECandidate(candidate); err != nil {
+ return errors.New("apply viewer ICE candidate failed")
+ }
+ return nil
+ default:
+ return errors.New("viewer signal kind is invalid")
+ }
+}
+
+func (peer *peer) readRTCP(sender *webrtc.RTPSender) {
+ for {
+ packets, _, err := sender.ReadRTCP()
+ if err != nil {
+ return
+ }
+ for _, packet := range packets {
+ switch packet.(type) {
+ case *rtcp.PictureLossIndication:
+ peer.pliRequests.Add(1)
+ peer.session.emit(Event{Kind: "request-keyframe"})
+ case *rtcp.FullIntraRequest:
+ peer.firRequests.Add(1)
+ peer.session.emit(Event{Kind: "request-keyframe"})
+ }
+ }
+ }
+}
+
+func (peer *peer) close() {
+ peer.closeOnce.Do(func() {
+ peer.closed.Store(true)
+ _ = peer.connection.Close()
+ })
+}
+
+func (peer *peer) snapshot(now time.Time) PeerDiagnostics {
+ diagnostics := PeerDiagnostics{
+ Slot: peer.slot,
+ ConnectionState: peer.connection.ConnectionState().String(),
+ ICEConnectionState: peer.connection.ICEConnectionState().String(),
+ Route: "unknown",
+ PLIRequests: peer.pliRequests.Load(),
+ FIRRequests: peer.firRequests.Load(),
+ }
+ report := peer.connection.GetStats()
+ transportID := ""
+ for _, value := range report {
+ outbound, ok := value.(webrtc.OutboundRTPStreamStats)
+ if !ok || outbound.Kind != "video" {
+ continue
+ }
+ diagnostics.PacketsSent += uint64(outbound.PacketsSent)
+ diagnostics.BytesSent += outbound.BytesSent
+ if transportID == "" {
+ transportID = outbound.TransportID
+ }
+ }
+ if transport, ok := report[transportID].(webrtc.TransportStats); ok {
+ if pair, pairOK := report[transport.SelectedCandidatePairID].(webrtc.ICECandidatePairStats); pairOK {
+ diagnostics.Route = selectedRoute(report, pair)
+ if pair.CurrentRoundTripTime > 0 {
+ value := uint64(pair.CurrentRoundTripTime*1000 + 0.5)
+ diagnostics.CurrentRoundTripTimeMS = &value
+ }
+ if pair.AvailableOutgoingBitrate > 0 {
+ value := uint64(pair.AvailableOutgoingBitrate + 0.5)
+ diagnostics.AvailableOutgoingBitrate = &value
+ }
+ }
+ }
+ peer.diagnosticsMu.Lock()
+ if !peer.lastSampleAt.IsZero() && now.After(peer.lastSampleAt) &&
+ diagnostics.BytesSent >= peer.lastBytesSent && diagnostics.PacketsSent >= peer.lastPacketsSent {
+ seconds := now.Sub(peer.lastSampleAt).Seconds()
+ value := uint64(float64(diagnostics.BytesSent-peer.lastBytesSent)*8/seconds + 0.5)
+ diagnostics.BitrateBPS = &value
+ }
+ peer.lastSampleAt = now
+ peer.lastPacketsSent = diagnostics.PacketsSent
+ peer.lastBytesSent = diagnostics.BytesSent
+ peer.diagnosticsMu.Unlock()
+ return diagnostics
+}
+
+func selectedRoute(report webrtc.StatsReport, pair webrtc.ICECandidatePairStats) string {
+ local, localOK := report[pair.LocalCandidateID].(webrtc.ICECandidateStats)
+ remote, remoteOK := report[pair.RemoteCandidateID].(webrtc.ICECandidateStats)
+ if localOK && local.CandidateType == webrtc.ICECandidateTypeRelay {
+ return "turn-" + relayTransport(local)
+ }
+ if remoteOK && remote.CandidateType == webrtc.ICECandidateTypeRelay {
+ return "turn-unknown"
+ }
+ if !localOK || !remoteOK {
+ return "unknown"
+ }
+ return "direct-" + candidateProtocol(local)
+}
+
+func relayTransport(candidate webrtc.ICECandidateStats) string {
+ switch strings.ToLower(candidate.RelayProtocol) {
+ case "udp", "tcp", "tls":
+ return strings.ToLower(candidate.RelayProtocol)
+ default:
+ return "unknown"
+ }
+}
+
+func candidateProtocol(candidate webrtc.ICECandidateStats) string {
+ if protocol := strings.ToLower(candidate.Protocol); protocol == "udp" || protocol == "tcp" {
+ return protocol
+ }
+ return "unknown"
+}
+
+func opaqueID() (string, error) {
+ value := make([]byte, 16)
+ if _, err := rand.Read(value); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(value), nil
+}
diff --git a/native/sender/internal/remote/peer_test.go b/native/sender/internal/remote/peer_test.go
new file mode 100644
index 0000000..aefef73
--- /dev/null
+++ b/native/sender/internal/remote/peer_test.go
@@ -0,0 +1,77 @@
+package remote
+
+import (
+ "testing"
+
+ "github.com/pion/webrtc/v4"
+)
+
+func TestSelectedRouteTreatsARemoteRelayCandidateAsTURN(t *testing.T) {
+ report := webrtc.StatsReport{
+ "local": webrtc.ICECandidateStats{
+ ID: "local",
+ Protocol: "udp",
+ CandidateType: webrtc.ICECandidateTypeHost,
+ },
+ "remote": webrtc.ICECandidateStats{
+ ID: "remote",
+ Protocol: "udp",
+ CandidateType: webrtc.ICECandidateTypeRelay,
+ },
+ }
+ pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"}
+ if route := selectedRoute(report, pair); route != "turn-unknown" {
+ t.Fatalf("selected route = %q, want turn-unknown", route)
+ }
+}
+
+func TestSelectedRouteUsesOnlyTheLocalRelayProtocol(t *testing.T) {
+ report := webrtc.StatsReport{
+ "local": webrtc.ICECandidateStats{
+ ID: "local", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeRelay,
+ },
+ "remote": webrtc.ICECandidateStats{
+ ID: "remote", Protocol: "udp", CandidateType: webrtc.ICECandidateTypeHost,
+ },
+ }
+ pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "remote"}
+ if route := selectedRoute(report, pair); route != "turn-unknown" {
+ t.Fatalf("selected route without local relayProtocol = %q", route)
+ }
+ local := report["local"].(webrtc.ICECandidateStats)
+ local.RelayProtocol = "tcp"
+ report["local"] = local
+ if route := selectedRoute(report, pair); route != "turn-tcp" {
+ t.Fatalf("selected route with local relayProtocol = %q", route)
+ }
+}
+
+func TestSelectedRouteRequiresBothCandidatesBeforeClaimingDirect(t *testing.T) {
+ report := webrtc.StatsReport{
+ "local": webrtc.ICECandidateStats{
+ ID: "local",
+ Protocol: "udp",
+ CandidateType: webrtc.ICECandidateTypeHost,
+ },
+ }
+ pair := webrtc.ICECandidatePairStats{LocalCandidateID: "local", RemoteCandidateID: "missing"}
+ if route := selectedRoute(report, pair); route != "unknown" {
+ t.Fatalf("selected route = %q, want unknown", route)
+ }
+}
+
+func TestPeerAnswerFailureIsSanitized(t *testing.T) {
+ connection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer connection.Close()
+ viewer := &peer{connection: connection, connectionID: "connection-1"}
+ err = viewer.acceptSignal(inboundSignalPayload{
+ Kind: "description", ConnectionID: "connection-1",
+ Description: &sessionDescription{Type: "answer", SDP: "private-sdp-marker"},
+ })
+ if err == nil || err.Error() != "apply viewer answer failed" {
+ t.Fatalf("answer error = %q", err)
+ }
+}
diff --git a/native/sender/internal/remote/session.go b/native/sender/internal/remote/session.go
new file mode 100644
index 0000000..543fde7
--- /dev/null
+++ b/native/sender/internal/remote/session.go
@@ -0,0 +1,848 @@
+package remote
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/TNTcraftHIM/Screener/native/sender/internal/media"
+ "github.com/coder/websocket"
+ "github.com/pion/webrtc/v4"
+)
+
+const (
+ maxHostEdges = 2
+ maxRemoteResponse = 64 << 10
+ remoteRequestTimeout = 10 * time.Second
+ signalWriteTimeout = 5 * time.Second
+ hostAdmissionTTL = 12 * time.Hour
+ nativeHostClaimTTL = 5 * time.Minute
+ privateViewerGrantTTL = 7 * 24 * time.Hour
+ privateViewerPolicy = "private-link"
+)
+
+var (
+ roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{0,11}$`)
+ hostTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`)
+ viewerGrantPattern = regexp.MustCompile(`^g1\.([1-9][0-9]{0,11})\.([1-9][0-9]{0,12})\.[A-Za-z0-9_-]{43}$`)
+ admissionCookiePattern = regexp.MustCompile(`^v1\.[1-9][0-9]{0,12}\.[A-Za-z0-9_-]{43}$`)
+)
+
+type Event struct {
+ Kind string `json:"kind"`
+ Message string `json:"message,omitempty"`
+ ActiveViewers int `json:"activeViewers,omitempty"`
+ WaitingViewers int `json:"waitingViewers,omitempty"`
+ Media *media.Metrics `json:"media,omitempty"`
+ Peers []PeerDiagnostics `json:"peers,omitempty"`
+}
+
+type StartOptions struct {
+ BaseURL *url.URL
+ HostAdmissionPassword string
+ OnEvent func(Event)
+}
+
+type Room struct {
+ ID string `json:"roomId"`
+ InviteURL string `json:"inviteUrl"`
+}
+
+type Session struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ signalCtx context.Context
+ cancelSignal context.CancelFunc
+ baseURL *url.URL
+ httpClient *http.Client
+ room Room
+ hostToken string
+ clientID string
+ shareGeneration string
+ onEvent func(Event)
+
+ mu sync.Mutex
+ writeMu sync.Mutex
+ conn *websocket.Conn
+ ice iceConfig
+ peers map[string]*peer
+ admission *viewerAdmission
+ fanout *media.Fanout
+ closeOnce sync.Once
+ fatalOnce sync.Once
+ terminalOnce sync.Once
+ readDone chan struct{}
+ terminalResult chan error
+ readerStarted atomic.Bool
+ closing atomic.Bool
+}
+
+type createRoomResponse struct {
+ RoomID string `json:"roomId"`
+ HostToken string `json:"hostToken"`
+ InviteURL string `json:"inviteUrl"`
+ ViewerPolicy string `json:"viewerPolicy"`
+ ViewerGrantExpiresAt json.RawMessage `json:"viewerGrantExpiresAt"`
+ ExpiresAt json.RawMessage `json:"expiresAt"`
+}
+
+type hostAdmissionStatus struct {
+ Required *bool `json:"required"`
+ Authenticated *bool `json:"authenticated"`
+}
+
+type createRoomRequest struct {
+ ViewerPolicy string `json:"viewerPolicy"`
+ HostClaimTTLSeconds int `json:"hostClaimTtlSeconds"`
+}
+
+type iceServer struct {
+ URLs json.RawMessage `json:"urls"`
+}
+
+type iceConfig struct {
+ IceServers []iceServer `json:"iceServers"`
+}
+
+func Start(parent context.Context, options StartOptions) (*Session, Room, error) {
+ if options.BaseURL == nil {
+ return nil, Room{}, errors.New("remote server URL is required")
+ }
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, Room{}, fmt.Errorf("create in-memory cookie jar: %w", err)
+ }
+ httpClient := &http.Client{
+ Jar: jar,
+ Timeout: remoteRequestTimeout,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ if err = authenticateHTTP(parent, httpClient, options.BaseURL, options.HostAdmissionPassword); err != nil {
+ return nil, Room{}, err
+ }
+ created, err := createRoom(parent, httpClient, options.BaseURL)
+ if err != nil {
+ return nil, Room{}, err
+ }
+ clientID, err := opaqueID()
+ if err != nil {
+ return nil, Room{}, fmt.Errorf("create sender identity: %w", err)
+ }
+ shareGeneration, err := opaqueID()
+ if err != nil {
+ return nil, Room{}, fmt.Errorf("create sharing generation: %w", err)
+ }
+ ctx, cancel := context.WithCancel(parent)
+ signalCtx, cancelSignal := context.WithCancel(context.WithoutCancel(parent))
+ session := &Session{
+ ctx: ctx,
+ cancel: cancel,
+ signalCtx: signalCtx,
+ cancelSignal: cancelSignal,
+ baseURL: options.BaseURL,
+ httpClient: httpClient,
+ room: Room{ID: created.RoomID, InviteURL: created.InviteURL},
+ hostToken: created.HostToken,
+ clientID: clientID,
+ shareGeneration: shareGeneration,
+ onEvent: options.OnEvent,
+ peers: make(map[string]*peer),
+ readDone: make(chan struct{}),
+ terminalResult: make(chan error, 1),
+ }
+ session.fanout, err = media.NewFanout(
+ func() { session.emit(Event{Kind: "request-keyframe"}) },
+ func(fatalErr error) { session.fail(fatalErr) },
+ )
+ if err != nil {
+ cancel()
+ return nil, Room{}, err
+ }
+ if err = session.connectWithRetry(parent); err != nil {
+ session.close()
+ return nil, Room{}, err
+ }
+ session.readerStarted.Store(true)
+ go session.readLoop()
+ go session.diagnosticsLoop()
+ go session.closeWhenCanceled()
+ return session, session.room, nil
+}
+
+func (session *Session) connectWithRetry(parent context.Context) error {
+ var err error
+ for attempt := 0; attempt < 2; attempt++ {
+ ctx, cancel := context.WithTimeout(parent, remoteRequestTimeout)
+ err = session.connect(ctx)
+ cancel()
+ if err == nil {
+ return nil
+ }
+ session.resetSignalingConnection()
+ if parent.Err() != nil {
+ break
+ }
+ }
+ return err
+}
+
+func (session *Session) resetSignalingConnection() {
+ session.mu.Lock()
+ conn := session.conn
+ session.conn = nil
+ session.mu.Unlock()
+ if conn != nil {
+ conn.CloseNow()
+ }
+}
+
+func (session *Session) WriteFrame(frame media.Frame) error {
+ if err := session.fanout.Push(frame); err != nil {
+ session.fail(fmt.Errorf("write encoded frame: %w", err))
+ return err
+ }
+ return nil
+}
+
+func (session *Session) Stop() {
+ session.close()
+}
+
+func (session *Session) Close() {
+ session.close()
+}
+
+func (session *Session) Fail(err error) {
+ session.fail(err)
+}
+
+func (session *Session) connect(ctx context.Context) error {
+ signalURL := *session.baseURL
+ if signalURL.Scheme == "https" {
+ signalURL.Scheme = "wss"
+ } else {
+ signalURL.Scheme = "ws"
+ }
+ signalURL.Path = "/signal"
+ header := http.Header{}
+ header.Set("Origin", remoteOrigin(session.baseURL))
+ conn, response, err := websocket.Dial(ctx, signalURL.String(), &websocket.DialOptions{
+ HTTPClient: session.httpClient,
+ HTTPHeader: header,
+ })
+ if err != nil {
+ if response != nil {
+ return fmt.Errorf("connect signaling: HTTP %d", response.StatusCode)
+ }
+ return errors.New("connect signaling failed")
+ }
+ conn.SetReadLimit(64 << 10)
+ authenticate := authenticateMessage{
+ Type: "authenticate",
+ Protocol: signalingProtocol,
+ RoomID: session.room.ID,
+ Role: "host",
+ Token: session.hostToken,
+ ClientID: session.clientID,
+ ShareGeneration: session.shareGeneration,
+ }
+ writeContext, cancel := context.WithTimeout(ctx, signalWriteTimeout)
+ err = writeWebSocketJSON(writeContext, conn, authenticate)
+ cancel()
+ if err != nil {
+ conn.CloseNow()
+ return errors.New("authenticate signaling failed")
+ }
+ session.mu.Lock()
+ session.conn = conn
+ session.mu.Unlock()
+ message, err := readServerMessage(ctx, conn)
+ if err != nil {
+ return errors.New("read signaling authentication failed")
+ }
+ if message.Type != "authenticated" || message.Role != "host" {
+ return errors.New("signaling returned an invalid host authentication response")
+ }
+ if message.ViewerPolicy != privateViewerPolicy {
+ return errors.New("signaling returned an unexpected room access policy")
+ }
+ session.mu.Lock()
+ session.ice = message.IceConfig
+ session.admission = newViewerAdmission(message.MaxViewers)
+ session.mu.Unlock()
+ // viewerPeerIds is a reconciliation roster and can include offline grace
+ // members. Only ordered peer-joined messages allocate native media edges.
+ session.emitViewerCounts()
+ session.emit(Event{Kind: "remote-ready"})
+ return nil
+}
+
+func (session *Session) readLoop() {
+ defer close(session.readDone)
+ conn := session.connection()
+ for {
+ message, err := readServerMessage(session.signalCtx, conn)
+ if err != nil {
+ if !session.closing.Load() {
+ go session.fail(errors.New("remote signaling disconnected"))
+ }
+ return
+ }
+ if session.closing.Load() {
+ switch message.Type {
+ case "room-closed":
+ if message.Reason == "host-ended" {
+ session.completeTerminal(nil)
+ } else {
+ session.completeTerminal(errors.New("remote signaling returned an unexpected room closure"))
+ }
+ return
+ default:
+ continue
+ }
+ }
+ if err = session.handle(message); err != nil {
+ // The sole reader must remain available for the room-closed ACK while
+ // the asynchronous fatal path performs the bounded terminal write.
+ go session.fail(err)
+ }
+ }
+}
+
+func (session *Session) closeWhenCanceled() {
+ select {
+ case <-session.ctx.Done():
+ session.close()
+ case <-session.readDone:
+ }
+}
+
+func (session *Session) completeTerminal(err error) {
+ session.terminalOnce.Do(func() {
+ session.terminalResult <- err
+ })
+}
+
+func (session *Session) handle(message serverMessage) error {
+ switch message.Type {
+ case "peer-joined":
+ return session.addViewer(message.PeerID)
+ case "peer-left":
+ return session.removeViewer(message.PeerID)
+ case "signal":
+ peer := session.peer(message.FromPeerID)
+ if peer == nil {
+ return nil
+ }
+ if err := peer.acceptSignal(message.Payload); err != nil {
+ return fmt.Errorf("apply viewer signal: %w", err)
+ }
+ case "restart-request":
+ peer := session.peer(message.FromPeerID)
+ if peer == nil || peer.connectionID != message.ConnectionID {
+ return nil
+ }
+ if message.Rebuild {
+ return session.rebuildPeer(message.FromPeerID)
+ } else if err := peer.restartICE(); err != nil {
+ return fmt.Errorf("restart viewer ICE: %w", err)
+ }
+ case "viewer-quality-evidence":
+ // Fixed-HIGH observes no viewer feedback; accepting this current-wire
+ // message must not turn it into a room-wide quality controller.
+ return nil
+ case "error":
+ return fmt.Errorf("remote signaling error %s", message.Code)
+ case "room-closed":
+ return fmt.Errorf("remote room closed: %s", message.Reason)
+ }
+ return nil
+}
+
+func (session *Session) addViewer(peerID string) error {
+ session.mu.Lock()
+ if session.admission == nil {
+ session.mu.Unlock()
+ return errors.New("viewer joined before host authentication completed")
+ }
+ action := session.admission.Join(peerID)
+ session.mu.Unlock()
+ switch action {
+ case admissionDuplicate:
+ return nil
+ case admissionWait:
+ session.emitViewerCounts()
+ return nil
+ case admissionFull:
+ return errors.New("viewer presence exceeded the authenticated room capacity")
+ case admissionActivate:
+ if err := session.activatePeer(peerID); err != nil {
+ session.mu.Lock()
+ session.admission.AbortActivation(peerID)
+ session.mu.Unlock()
+ return err
+ }
+ session.emitViewerCounts()
+ return nil
+ default:
+ return errors.New("viewer admission returned an invalid action")
+ }
+}
+
+func (session *Session) activatePeer(peerID string) error {
+ session.mu.Lock()
+ configuration, err := pionConfiguration(session.ice)
+ slot := session.availableSlotLocked()
+ session.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ if slot == 0 {
+ return errors.New("native host edge budget is exhausted")
+ }
+ created, err := newPeer(session, peerID, slot, configuration)
+ if err != nil {
+ return err
+ }
+ session.mu.Lock()
+ session.peers[peerID] = created
+ session.mu.Unlock()
+ if err = created.offer(false); err != nil {
+ session.mu.Lock()
+ delete(session.peers, peerID)
+ session.mu.Unlock()
+ created.close()
+ return err
+ }
+ return nil
+}
+
+func (session *Session) removeViewer(peerID string) error {
+ session.mu.Lock()
+ peer := session.peers[peerID]
+ delete(session.peers, peerID)
+ if session.admission == nil {
+ session.mu.Unlock()
+ if peer != nil {
+ peer.close()
+ }
+ return nil
+ }
+ _, promoted := session.admission.Leave(peerID)
+ session.mu.Unlock()
+ if peer != nil {
+ peer.close()
+ }
+ if promoted != "" {
+ if err := session.activatePeer(promoted); err != nil {
+ session.mu.Lock()
+ session.admission.AbortActivation(promoted)
+ session.mu.Unlock()
+ return fmt.Errorf("promote waiting viewer: %w", err)
+ }
+ }
+ session.emitViewerCounts()
+ return nil
+}
+
+func (session *Session) rebuildPeer(peerID string) error {
+ session.mu.Lock()
+ if session.admission == nil || !session.admission.IsActive(peerID) {
+ session.mu.Unlock()
+ return nil
+ }
+ old := session.peers[peerID]
+ delete(session.peers, peerID)
+ session.mu.Unlock()
+ if old != nil {
+ old.close()
+ }
+ if err := session.activatePeer(peerID); err != nil {
+ session.mu.Lock()
+ session.admission.AbortActivation(peerID)
+ session.mu.Unlock()
+ return err
+ }
+ return nil
+}
+
+func (session *Session) peer(peerID string) *peer {
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ return session.peers[peerID]
+}
+
+func (session *Session) connection() *websocket.Conn {
+ session.mu.Lock()
+ defer session.mu.Unlock()
+ return session.conn
+}
+
+func (session *Session) send(value any) error {
+ ctx, cancel := context.WithTimeout(session.signalCtx, signalWriteTimeout)
+ defer cancel()
+ return session.write(ctx, value, false)
+}
+
+func (session *Session) write(ctx context.Context, value any, terminal bool) error {
+ session.writeMu.Lock()
+ defer session.writeMu.Unlock()
+ if session.closing.Load() && !terminal {
+ return io.ErrClosedPipe
+ }
+ conn := session.connection()
+ if conn == nil {
+ return io.ErrClosedPipe
+ }
+ return writeWebSocketJSON(ctx, conn, value)
+}
+
+func writeWebSocketJSON(ctx context.Context, conn *websocket.Conn, value any) error {
+ payload, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return conn.Write(ctx, websocket.MessageText, payload)
+}
+
+func (session *Session) sendSignal(targetPeerID string, payload any) error {
+ return session.send(outboundSignalMessage{
+ Type: "signal",
+ TargetPeerID: targetPeerID,
+ Payload: payload,
+ })
+}
+
+func (session *Session) emit(event Event) {
+ if session.onEvent != nil {
+ session.onEvent(event)
+ }
+}
+
+func (session *Session) emitViewerCounts() {
+ session.mu.Lock()
+ active, waiting := 0, 0
+ if session.admission != nil {
+ active, waiting = session.admission.Counts()
+ }
+ session.mu.Unlock()
+ session.emit(Event{Kind: "viewer-count", ActiveViewers: active, WaitingViewers: waiting})
+}
+
+func (session *Session) diagnosticsLoop() {
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-session.ctx.Done():
+ return
+ case now := <-ticker.C:
+ session.mu.Lock()
+ peers := make([]*peer, 0, len(session.peers))
+ for _, activePeer := range session.peers {
+ peers = append(peers, activePeer)
+ }
+ session.mu.Unlock()
+ diagnostics := make([]PeerDiagnostics, 0, len(peers))
+ for _, activePeer := range peers {
+ diagnostics = append(diagnostics, activePeer.snapshot(now))
+ }
+ sort.Slice(diagnostics, func(left, right int) bool {
+ return diagnostics[left].Slot < diagnostics[right].Slot
+ })
+ metrics := session.fanout.Snapshot()
+ session.emit(Event{Kind: "diagnostics", Media: &metrics, Peers: diagnostics})
+ }
+ }
+}
+
+func (session *Session) availableSlotLocked() int {
+ used := [maxHostEdges + 1]bool{}
+ for _, activePeer := range session.peers {
+ if activePeer.slot > 0 && activePeer.slot <= maxHostEdges {
+ used[activePeer.slot] = true
+ }
+ }
+ for slot := 1; slot <= maxHostEdges; slot++ {
+ if !used[slot] {
+ return slot
+ }
+ }
+ return 0
+}
+
+func (session *Session) fail(err error) {
+ if err == nil || session.closing.Load() {
+ return
+ }
+ session.fatalOnce.Do(func() {
+ session.emit(Event{Kind: "fatal", Message: err.Error()})
+ session.close()
+ })
+}
+
+func (session *Session) close() {
+ session.closeOnce.Do(func() {
+ session.closing.Store(true)
+ if session.connection() != nil {
+ terminalContext, cancelTerminal := context.WithTimeout(context.Background(), signalWriteTimeout)
+ err := session.write(terminalContext, simpleMessage{Type: "abandon-room"}, true)
+ confirmed := false
+ if err == nil && session.readerStarted.Load() {
+ select {
+ case terminalErr := <-session.terminalResult:
+ err = terminalErr
+ confirmed = terminalErr == nil
+ case <-session.readDone:
+ select {
+ case terminalErr := <-session.terminalResult:
+ err = terminalErr
+ confirmed = terminalErr == nil
+ default:
+ err = errors.New("remote signaling closed before room abandonment was acknowledged")
+ }
+ case <-terminalContext.Done():
+ select {
+ case terminalErr := <-session.terminalResult:
+ err = terminalErr
+ confirmed = terminalErr == nil
+ default:
+ err = terminalContext.Err()
+ }
+ }
+ }
+ cancelTerminal()
+ if err != nil || !confirmed {
+ session.emit(Event{Kind: "warning", Message: "The fresh room could not be removed before shutdown"})
+ }
+ }
+ session.cancel()
+ session.cancelSignal()
+ session.mu.Lock()
+ conn := session.conn
+ session.conn = nil
+ peers := make([]*peer, 0, len(session.peers))
+ for _, peer := range session.peers {
+ peers = append(peers, peer)
+ }
+ session.peers = make(map[string]*peer)
+ session.mu.Unlock()
+ session.fanout.Close()
+ for _, peer := range peers {
+ peer.close()
+ }
+ if conn != nil {
+ _ = conn.Close(websocket.StatusNormalClosure, "sender stopped")
+ }
+ })
+}
+
+func authenticateHTTP(ctx context.Context, client *http.Client, base *url.URL, password string) error {
+ endpoint := base.ResolveReference(&url.URL{Path: "/api/host-admission"})
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil)
+ if err != nil {
+ return errors.New("prepare host admission request failed")
+ }
+ request.Header.Set("Origin", remoteOrigin(base))
+ request.Header.Set("Accept", "application/json")
+ if password != "" {
+ request.Header.Set("Authorization", "Bearer "+password)
+ }
+ var status hostAdmissionStatus
+ response, err := doJSON(client, request, &status)
+ if err != nil {
+ return fmt.Errorf("authenticate with the Screener site: %w", err)
+ }
+ if response.StatusCode != http.StatusOK {
+ return errors.New("authenticate with the Screener site: unexpected success status")
+ }
+ if status.Required == nil || status.Authenticated == nil {
+ return errors.New("authenticate with the Screener site: response is invalid")
+ }
+ if !*status.Authenticated {
+ return errors.New("the Screener site did not authenticate this sender")
+ }
+ if err = validateHostAdmissionCookie(response, client.Jar, base, *status.Required); err != nil {
+ return fmt.Errorf("authenticate with the Screener site: %w", err)
+ }
+ return nil
+}
+
+func createRoom(ctx context.Context, client *http.Client, base *url.URL) (createRoomResponse, error) {
+ endpoint := base.ResolveReference(&url.URL{Path: "/api/rooms"})
+ payload, err := json.Marshal(createRoomRequest{
+ ViewerPolicy: privateViewerPolicy,
+ HostClaimTTLSeconds: int(nativeHostClaimTTL / time.Second),
+ })
+ if err != nil {
+ return createRoomResponse{}, errors.New("prepare room request failed")
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
+ if err != nil {
+ return createRoomResponse{}, errors.New("prepare room request failed")
+ }
+ request.Header.Set("Origin", remoteOrigin(base))
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("Content-Type", "application/json")
+ var room createRoomResponse
+ response, err := doJSON(client, request, &room)
+ if err != nil {
+ return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err)
+ }
+ if response.StatusCode != http.StatusCreated {
+ return createRoomResponse{}, errors.New("create Screener room: unexpected success status")
+ }
+ if err = validateCreatedRoom(room, base, time.Now()); err != nil {
+ return createRoomResponse{}, fmt.Errorf("create Screener room: %w", err)
+ }
+ return room, nil
+}
+
+func doJSON(client *http.Client, request *http.Request, target any) (*http.Response, error) {
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, errors.New("request failed")
+ }
+ defer response.Body.Close()
+ payload, err := io.ReadAll(io.LimitReader(response.Body, maxRemoteResponse+1))
+ if err != nil {
+ return nil, errors.New("response read failed")
+ }
+ if len(payload) > maxRemoteResponse {
+ return nil, errors.New("response is too large")
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("HTTP %d", response.StatusCode)
+ }
+ if err = decodeStrict(payload, target); err != nil {
+ return nil, errors.New("response is not valid current JSON")
+ }
+ return response, nil
+}
+
+func remoteOrigin(base *url.URL) string {
+ return (&url.URL{Scheme: base.Scheme, Host: base.Host}).String()
+}
+
+func validateHostAdmissionCookie(response *http.Response, jar http.CookieJar, base *url.URL, required bool) error {
+ setCookies := response.Header.Values("Set-Cookie")
+ if !required {
+ if len(setCookies) != 0 {
+ return errors.New("unexpected host admission cookie")
+ }
+ return nil
+ }
+ if len(setCookies) != 1 {
+ return errors.New("host admission cookie is missing or ambiguous")
+ }
+ cookies := response.Cookies()
+ if len(cookies) != 1 {
+ return errors.New("host admission cookie is invalid")
+ }
+ cookie := cookies[0]
+ expectedName := "screener-host-admission"
+ if base.Scheme == "https" {
+ expectedName = "__Host-screener-host-admission"
+ }
+ if cookie.Name != expectedName || !admissionCookiePattern.MatchString(cookie.Value) ||
+ cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != int(hostAdmissionTTL/time.Second) ||
+ !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Secure != (base.Scheme == "https") ||
+ cookie.Quoted || cookie.Partitioned || cookie.RawExpires != "" || !cookie.Expires.IsZero() || len(cookie.Unparsed) != 0 {
+ return errors.New("host admission cookie is invalid")
+ }
+ stored := jar.Cookies(base)
+ if len(stored) != 1 || stored[0].Name != cookie.Name || stored[0].Value != cookie.Value {
+ return errors.New("host admission cookie was not retained")
+ }
+ return nil
+}
+
+func validateCreatedRoom(room createRoomResponse, base *url.URL, now time.Time) error {
+ if !roomCodePattern.MatchString(room.RoomID) || !hostTokenPattern.MatchString(room.HostToken) ||
+ room.ViewerPolicy != privateViewerPolicy ||
+ len(room.InviteURL) < 1 || len(room.InviteURL) > 2048 {
+ return errors.New("response is invalid")
+ }
+ viewerGrantExpiresAt, isNull, err := parseResponseTimestamp(room.ViewerGrantExpiresAt)
+ if err != nil || isNull || !viewerGrantExpiresAt.After(now) ||
+ viewerGrantExpiresAt.After(now.Add(privateViewerGrantTTL)) {
+ return errors.New("response is invalid")
+ }
+ expiresAt, expiresAtIsNull, err := parseResponseTimestamp(room.ExpiresAt)
+ if err != nil || expiresAtIsNull || !expiresAt.After(now) || viewerGrantExpiresAt.After(expiresAt) {
+ return errors.New("response is invalid")
+ }
+ invite, err := url.Parse(room.InviteURL)
+ if err != nil || invite.User != nil || invite.RawQuery != "" ||
+ !strings.EqualFold(invite.Scheme, base.Scheme) || !strings.EqualFold(invite.Host, base.Host) ||
+ invite.Path != "/r/"+room.RoomID || !strings.HasPrefix(invite.Fragment, "v=") {
+ return errors.New("response is invalid")
+ }
+ grant := strings.TrimPrefix(invite.Fragment, "v=")
+ match := viewerGrantPattern.FindStringSubmatch(grant)
+ if len(match) != 3 || match[1] != room.RoomID {
+ return errors.New("response is invalid")
+ }
+ grantExpiresAt, err := strconv.ParseInt(match[2], 10, 64)
+ if err != nil || grantExpiresAt != viewerGrantExpiresAt.Unix() {
+ return errors.New("response is invalid")
+ }
+ return nil
+}
+
+func parseResponseTimestamp(raw json.RawMessage) (time.Time, bool, error) {
+ if len(raw) == 0 {
+ return time.Time{}, false, errors.New("response field is missing")
+ }
+ if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
+ return time.Time{}, true, nil
+ }
+ var value string
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return time.Time{}, false, errors.New("response timestamp is invalid")
+ }
+ parsed, err := time.Parse(time.RFC3339, value)
+ if err != nil {
+ return time.Time{}, false, errors.New("response timestamp is invalid")
+ }
+ return parsed, false, nil
+}
+
+func pionConfiguration(config iceConfig) (webrtc.Configuration, error) {
+ servers := make([]webrtc.ICEServer, 0, len(config.IceServers))
+ for _, server := range config.IceServers {
+ var urls []string
+ if err := json.Unmarshal(server.URLs, &urls); err != nil {
+ var single string
+ if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) {
+ return webrtc.Configuration{}, errors.New("ICE server URLs are invalid")
+ }
+ urls = []string{single}
+ }
+ for index, value := range urls {
+ if !validStunURL(value) {
+ return webrtc.Configuration{}, errors.New("ICE server URLs are invalid")
+ }
+ urls[index] = "stun:" + value[len("stun:"):]
+ }
+ servers = append(servers, webrtc.ICEServer{
+ URLs: urls,
+ })
+ }
+ return webrtc.Configuration{ICEServers: servers}, nil
+}
diff --git a/native/sender/internal/remote/session_test.go b/native/sender/internal/remote/session_test.go
new file mode 100644
index 0000000..fb951fb
--- /dev/null
+++ b/native/sender/internal/remote/session_test.go
@@ -0,0 +1,784 @@
+package remote
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+func TestStartUsesHostAdmissionCookieAndPrivateV2Room(t *testing.T) {
+ const password = "host-admission-password"
+ harness := newRemoteHarnessWithPassword(t, []string{}, password)
+ defer harness.Close()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, room, err := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ HostAdmissionPassword: password,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+
+ admission := <-harness.admission
+ if admission.Method != http.MethodPost || admission.Authorization != "Bearer "+password ||
+ admission.Origin != harness.URL() || admission.Body != "" {
+ t.Fatalf("host admission request = %+v", admission)
+ }
+ creation := <-harness.roomCreation
+ if creation.Method != http.MethodPost || creation.Authorization != "" || creation.Origin != harness.URL() ||
+ creation.ContentType != "application/json" ||
+ creation.Body != `{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}` ||
+ !strings.HasPrefix(creation.Cookie, "screener-host-admission=v1.") {
+ t.Fatalf("room creation request = %+v", creation)
+ }
+ if signalCookie := <-harness.signalCookie; signalCookie != creation.Cookie {
+ t.Fatalf("signaling cookie did not match room cookie")
+ }
+ if !strings.Contains(room.InviteURL, "/r/1#v=g1.1.") {
+ t.Fatalf("private invite was not returned to the local Host UI")
+ }
+ assertAuthenticateShape(t, harness.Authentication())
+}
+
+func TestStartRetriesTheSameProvisionalRoomOnce(t *testing.T) {
+ var roomCreations atomic.Int32
+ var signalAttempts atomic.Int32
+ var authMu sync.Mutex
+ var authentications [][]byte
+ mux := http.NewServeMux()
+ var server *httptest.Server
+ mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) {
+ writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true})
+ })
+ mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) {
+ roomCreations.Add(1)
+ response.WriteHeader(http.StatusCreated)
+ _, _ = response.Write([]byte(validRoomResponseJSON(server.URL, "1", "1", nil)))
+ })
+ mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) {
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ return
+ }
+ defer connection.CloseNow()
+ messageType, payload, err := connection.Read(request.Context())
+ if err != nil || messageType != websocket.MessageText {
+ return
+ }
+ authMu.Lock()
+ authentications = append(authentications, append([]byte(nil), payload...))
+ authMu.Unlock()
+ if signalAttempts.Add(1) == 1 {
+ return
+ }
+ expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
+ authenticated, _ := json.Marshal(map[string]any{
+ "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer",
+ "roomExpiresAt": expiresAt.Format(time.RFC3339), "maxViewers": 3, "hostOnline": true,
+ "connectionId": nil, "viewerPeerIds": []string{},
+ "iceConfig": map[string]any{"iceServers": []any{}}, "viewerPolicy": privateViewerPolicy,
+ "viewerAuthorizationGeneration": "viewer_generation_12345678",
+ })
+ writeErr := connection.Write(request.Context(), websocket.MessageText, authenticated)
+ if writeErr != nil {
+ return
+ }
+ _, payload, err = connection.Read(request.Context())
+ if err != nil || string(payload) != `{"type":"abandon-room"}` {
+ return
+ }
+ _ = connection.Write(context.Background(), websocket.MessageText, []byte(`{"type":"room-closed","reason":"host-ended"}`))
+ })
+ server = httptest.NewServer(mux)
+ defer server.Close()
+ baseURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, _, err := Start(context.Background(), StartOptions{BaseURL: baseURL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.Close()
+ if roomCreations.Load() != 1 || signalAttempts.Load() != 2 {
+ t.Fatalf("room creations = %d, signaling attempts = %d", roomCreations.Load(), signalAttempts.Load())
+ }
+ authMu.Lock()
+ defer authMu.Unlock()
+ if len(authentications) != 2 || !bytes.Equal(authentications[0], authentications[1]) {
+ t.Fatal("signaling retry did not reuse the exact room and Host generation")
+ }
+}
+
+func TestHostAdmissionFailuresAreBoundedAndDoNotLeakSecrets(t *testing.T) {
+ const password = "never-report-this-password"
+ const grant = "g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ tests := []struct {
+ name string
+ status int
+ body string
+ setCookie string
+ }{
+ {name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"` + password + ` ` + grant + `"}`},
+ {name: "rate-limited", status: http.StatusTooManyRequests, body: `{"error":"` + password + ` ` + grant + `"}`},
+ {name: "wrong-success-status", status: http.StatusCreated, body: `{"required":false,"authenticated":true}`},
+ {name: "missing-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`},
+ {name: "missing-required", status: http.StatusOK, body: `{"authenticated":true}`},
+ {name: "missing-authenticated", status: http.StatusOK, body: `{"required":false}`},
+ {name: "unknown-field", status: http.StatusOK, body: `{"required":false,"authenticated":true,"url":"https://private.test/path"}`},
+ {
+ name: "quoted-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`,
+ setCookie: `screener-host-admission="v1.1893456000.` + strings.Repeat("a", 43) + `"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict`,
+ },
+ {
+ name: "partitioned-cookie", status: http.StatusOK, body: `{"required":true,"authenticated":true}`,
+ setCookie: `screener-host-admission=v1.1893456000.` + strings.Repeat("a", 43) + `; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict; Partitioned`,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ if request.URL.Path != "/api/host-admission" {
+ http.NotFound(response, request)
+ return
+ }
+ if test.setCookie != "" {
+ response.Header().Set("Set-Cookie", test.setCookie)
+ }
+ response.WriteHeader(test.status)
+ _, _ = response.Write([]byte(test.body))
+ }))
+ defer server.Close()
+ baseURL, parseErr := url.Parse(server.URL)
+ if parseErr != nil {
+ t.Fatal(parseErr)
+ }
+ _, _, startErr := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ HostAdmissionPassword: password,
+ })
+ if startErr == nil {
+ t.Fatal("invalid host admission response was accepted")
+ }
+ reported := startErr.Error()
+ if strings.Contains(reported, password) || strings.Contains(reported, grant) || strings.Contains(reported, server.URL) {
+ t.Fatalf("host admission error leaked untrusted data: %q", reported)
+ }
+ })
+ }
+}
+
+func TestCreateRoomFailuresAreStrictAndDoNotLeakGrant(t *testing.T) {
+ const grant = "g1.1.1893456000.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
+ tests := []struct {
+ name string
+ status int
+ body func(string) string
+ }{
+ {name: "unauthorized", status: http.StatusUnauthorized, body: func(string) string { return `{"error":"` + grant + `"}` }},
+ {name: "wrong-success-status", status: http.StatusOK, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "1", "1", nil)
+ }},
+ {name: "unknown-field", status: http.StatusCreated, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "1", "1", map[string]any{"rawGrant": grant})
+ }},
+ {name: "mismatched-grant-room", status: http.StatusCreated, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "2", "1", nil)
+ }},
+ {name: "missing-room-expiry", status: http.StatusCreated, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": omittedJSONField{}})
+ }},
+ {name: "persistent-room", status: http.StatusCreated, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "1", "1", map[string]any{"expiresAt": nil})
+ }},
+ {name: "grant-expiry-mismatch", status: http.StatusCreated, body: func(origin string) string {
+ return validRoomResponseJSON(origin, "1", "1", map[string]any{
+ "viewerGrantExpiresAt": time.Now().UTC().Add(25 * time.Hour).Truncate(time.Second).Format(time.RFC3339),
+ })
+ }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ var server *httptest.Server
+ mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) {
+ writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true})
+ })
+ mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(test.status)
+ _, _ = response.Write([]byte(test.body(server.URL)))
+ })
+ server = httptest.NewServer(mux)
+ defer server.Close()
+ baseURL, parseErr := url.Parse(server.URL)
+ if parseErr != nil {
+ t.Fatal(parseErr)
+ }
+ _, _, startErr := Start(context.Background(), StartOptions{BaseURL: baseURL})
+ if startErr == nil {
+ t.Fatal("invalid room response was accepted")
+ }
+ if strings.Contains(startErr.Error(), grant) || strings.Contains(startErr.Error(), server.URL) {
+ t.Fatalf("room creation error leaked untrusted data: %q", startErr)
+ }
+ })
+ }
+}
+
+func TestSessionTerminalPathsAbandonFreshRoomExactlyOnce(t *testing.T) {
+ tests := []struct {
+ name string
+ terminate func(*Session)
+ wantFatal bool
+ }{
+ {name: "stop", terminate: func(session *Session) { session.Stop() }},
+ {name: "process-close", terminate: func(session *Session) { session.Close() }},
+ {name: "local-fatal", terminate: func(session *Session) { session.Fail(errors.New("local bridge failed")) }, wantFatal: true},
+ {name: "concurrent", terminate: func(session *Session) {
+ var wait sync.WaitGroup
+ for index := 0; index < 8; index++ {
+ wait.Add(1)
+ go func(closeSession bool) {
+ defer wait.Done()
+ if closeSession {
+ session.Close()
+ } else {
+ session.Stop()
+ }
+ }(index%2 == 0)
+ }
+ wait.Wait()
+ }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ harness := newRemoteHarness(t, []string{"viewer-old"})
+ defer harness.Close()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var eventsMu sync.Mutex
+ var events []Event
+ session, _, err := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ OnEvent: func(event Event) {
+ eventsMu.Lock()
+ events = append(events, event)
+ eventsMu.Unlock()
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertAuthenticateShape(t, harness.Authentication())
+
+ session.mu.Lock()
+ active, waiting := session.admission.Counts()
+ peerCount := len(session.peers)
+ session.mu.Unlock()
+ if active != 0 || waiting != 0 || peerCount != 0 {
+ t.Fatalf("authentication roster allocated media: %d active, %d waiting, %d peers", active, waiting, peerCount)
+ }
+
+ test.terminate(session)
+ select {
+ case <-harness.Done():
+ case <-time.After(2 * time.Second):
+ t.Fatal("fake signaling server did not observe terminal shutdown")
+ }
+ if got := harness.AbandonCount(); got != 1 {
+ t.Fatalf("abandon-room count = %d, want 1", got)
+ }
+ if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` {
+ t.Fatalf("terminal wire = %q (length %d)", got, len(got))
+ }
+
+ eventsMu.Lock()
+ defer eventsMu.Unlock()
+ fatalCount := 0
+ for _, event := range events {
+ if event.Kind == "fatal" {
+ fatalCount++
+ }
+ }
+ if test.wantFatal && fatalCount != 1 {
+ t.Fatalf("fatal event count = %d, want 1", fatalCount)
+ }
+ if !test.wantFatal && fatalCount != 0 {
+ t.Fatalf("normal room closure emitted %d fatal events", fatalCount)
+ }
+ })
+ }
+}
+
+func TestSessionSendPropagatesAClosedSignalingConnection(t *testing.T) {
+ accepted := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ return
+ }
+ defer connection.CloseNow()
+ close(accepted)
+ _, _, _ = connection.Read(request.Context())
+ }))
+ defer server.Close()
+
+ websocketURL := "ws" + server.URL[len("http"):]
+ connection, _, err := websocket.Dial(context.Background(), websocketURL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ <-accepted
+ connection.CloseNow()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ session := &Session{ctx: ctx, signalCtx: ctx, conn: connection}
+ if err = session.send(simpleMessage{Type: "abandon-room"}); err == nil {
+ t.Fatal("signaling write to a closed connection returned success")
+ }
+}
+
+func TestCanceledSessionContextStillAbandonsTheFreshRoomExactlyOnce(t *testing.T) {
+ harness := newRemoteHarness(t, []string{})
+ defer harness.Close()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ parent, cancelParent := context.WithCancel(context.Background())
+ session, _, err := Start(parent, StartOptions{BaseURL: baseURL})
+ if err != nil {
+ cancelParent()
+ t.Fatal(err)
+ }
+ assertAuthenticateShape(t, harness.Authentication())
+ cancelParent()
+ session.Close()
+ select {
+ case <-harness.Done():
+ case <-time.After(2 * time.Second):
+ t.Fatal("canceled session context prevented fresh-room abandonment")
+ }
+ if got := harness.AbandonCount(); got != 1 {
+ t.Fatalf("abandon-room count = %d, want 1", got)
+ }
+ if got := string(harness.TerminalMessage()); got != `{"type":"abandon-room"}` {
+ t.Fatalf("terminal wire = %q", got)
+ }
+}
+
+func TestSessionCloseWaitsForRoomClosedAcknowledgement(t *testing.T) {
+ ackGate := make(chan struct{})
+ var releaseOnce sync.Once
+ releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) }
+
+ harness := newRemoteHarness(t, []string{})
+ harness.ackGate = ackGate
+ defer harness.Close()
+ defer releaseACK()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var warningCount atomic.Uint64
+ session, _, err := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ OnEvent: func(event Event) {
+ if event.Kind == "warning" {
+ warningCount.Add(1)
+ }
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertAuthenticateShape(t, harness.Authentication())
+
+ closed := make(chan struct{})
+ go func() {
+ session.Close()
+ close(closed)
+ }()
+ select {
+ case <-harness.AbandonObserved():
+ case <-time.After(2 * time.Second):
+ releaseACK()
+ t.Fatal("fake signaling server did not observe room abandonment")
+ }
+ select {
+ case <-closed:
+ releaseACK()
+ t.Fatal("session closed the signaling transport before terminal acknowledgement")
+ default:
+ }
+
+ releaseACK()
+ select {
+ case <-closed:
+ case <-time.After(2 * time.Second):
+ t.Fatal("session did not close after terminal acknowledgement")
+ }
+ if got := warningCount.Load(); got != 0 {
+ t.Fatalf("acknowledged room abandonment emitted %d warnings", got)
+ }
+}
+
+func TestSessionCloseBoundsMissingRoomClosedAcknowledgement(t *testing.T) {
+ ackGate := make(chan struct{})
+ var releaseOnce sync.Once
+ releaseACK := func() { releaseOnce.Do(func() { close(ackGate) }) }
+
+ harness := newRemoteHarness(t, []string{})
+ harness.ackGate = ackGate
+ defer harness.Close()
+ defer releaseACK()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var warningCount atomic.Uint64
+ session, _, err := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ OnEvent: func(event Event) {
+ if event.Kind == "warning" {
+ warningCount.Add(1)
+ }
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertAuthenticateShape(t, harness.Authentication())
+
+ started := time.Now()
+ session.Close()
+ elapsed := time.Since(started)
+ if elapsed < signalWriteTimeout-250*time.Millisecond {
+ t.Fatalf("session closed before its terminal deadline: %s", elapsed)
+ }
+ if elapsed > signalWriteTimeout+2*time.Second {
+ t.Fatalf("session exceeded its terminal deadline: %s", elapsed)
+ }
+ if got := harness.AbandonCount(); got != 1 {
+ t.Fatalf("abandon-room count = %d, want 1", got)
+ }
+ if got := warningCount.Load(); got != 1 {
+ t.Fatalf("missing acknowledgement emitted %d warnings, want 1", got)
+ }
+ releaseACK()
+}
+
+func TestReaderOriginatedFatalAbandonsOnceWithoutDeadlock(t *testing.T) {
+ harness := newRemoteHarness(t, []string{})
+ harness.afterAuth = [][]byte{[]byte(`{"type":"error","code":"SERVER_ERROR","message":"bounded failure"}`)}
+ defer harness.Close()
+ baseURL, err := url.Parse(harness.URL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ fatal := make(chan string, 1)
+ session, _, err := Start(context.Background(), StartOptions{
+ BaseURL: baseURL,
+ OnEvent: func(event Event) {
+ if event.Kind == "fatal" {
+ select {
+ case fatal <- event.Message:
+ default:
+ }
+ }
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+ assertAuthenticateShape(t, harness.Authentication())
+
+ select {
+ case <-harness.Done():
+ case <-time.After(2 * time.Second):
+ t.Fatal("reader-originated fatal did not complete terminal cleanup")
+ }
+ if got := harness.AbandonCount(); got != 1 {
+ t.Fatalf("abandon-room count = %d, want 1", got)
+ }
+ select {
+ case message := <-fatal:
+ if message == "" {
+ t.Fatal("reader-originated fatal message is empty")
+ }
+ default:
+ t.Fatal("reader-originated error did not emit a fatal event")
+ }
+}
+
+type remoteHarness struct {
+ testing *testing.T
+ server *httptest.Server
+ authentication chan []byte
+ done chan struct{}
+ doneOnce sync.Once
+ abandonObserved chan struct{}
+ abandonOnce sync.Once
+ ackGate <-chan struct{}
+ afterAuth [][]byte
+ terminalMu sync.Mutex
+ terminal []byte
+ abandonCount atomic.Uint64
+ password string
+ admission chan capturedRequest
+ roomCreation chan capturedRequest
+ signalCookie chan string
+}
+
+type capturedRequest struct {
+ Method string
+ Authorization string
+ Cookie string
+ Origin string
+ ContentType string
+ Body string
+}
+
+func newRemoteHarness(t *testing.T, roster []string) *remoteHarness {
+ return newRemoteHarnessWithPassword(t, roster, "")
+}
+
+func newRemoteHarnessWithPassword(t *testing.T, roster []string, password string) *remoteHarness {
+ t.Helper()
+ harness := &remoteHarness{
+ testing: t,
+ authentication: make(chan []byte, 1),
+ done: make(chan struct{}),
+ abandonObserved: make(chan struct{}),
+ password: password,
+ admission: make(chan capturedRequest, 1),
+ roomCreation: make(chan capturedRequest, 1),
+ signalCookie: make(chan string, 1),
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/host-admission", func(response http.ResponseWriter, request *http.Request) {
+ body, _ := io.ReadAll(request.Body)
+ harness.admission <- capturedRequest{
+ Method: request.Method,
+ Authorization: request.Header.Get("Authorization"),
+ Origin: request.Header.Get("Origin"),
+ Body: string(body),
+ }
+ if password == "" {
+ writeHarnessJSON(response, map[string]any{"required": false, "authenticated": true})
+ return
+ }
+ if request.Header.Get("Authorization") != "Bearer "+password {
+ response.WriteHeader(http.StatusUnauthorized)
+ writeHarnessJSON(response, map[string]string{"error": "secret must never be reflected"})
+ return
+ }
+ response.Header().Set("Set-Cookie", "screener-host-admission=v1.1893456000."+strings.Repeat("a", 43)+"; Path=/; Max-Age=43200; HttpOnly; SameSite=Strict")
+ writeHarnessJSON(response, map[string]any{"required": true, "authenticated": true})
+ })
+ mux.HandleFunc("/api/rooms", func(response http.ResponseWriter, request *http.Request) {
+ body, _ := io.ReadAll(request.Body)
+ harness.roomCreation <- capturedRequest{
+ Method: request.Method,
+ Authorization: request.Header.Get("Authorization"),
+ Cookie: request.Header.Get("Cookie"),
+ Origin: request.Header.Get("Origin"),
+ ContentType: request.Header.Get("Content-Type"),
+ Body: string(body),
+ }
+ if password != "" && request.Header.Get("Cookie") == "" {
+ response.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ response.WriteHeader(http.StatusCreated)
+ _, _ = response.Write([]byte(validRoomResponseJSON(harness.server.URL, "1", "1", nil)))
+ })
+ mux.HandleFunc("/signal", func(response http.ResponseWriter, request *http.Request) {
+ harness.signalCookie <- request.Header.Get("Cookie")
+ if password != "" && request.Header.Get("Cookie") == "" {
+ http.Error(response, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{InsecureSkipVerify: true})
+ if err != nil {
+ harness.finish()
+ return
+ }
+ defer connection.CloseNow()
+ messageType, payload, err := connection.Read(request.Context())
+ if err != nil || messageType != websocket.MessageText {
+ harness.finish()
+ return
+ }
+ harness.authentication <- append([]byte(nil), payload...)
+ authenticated, _ := json.Marshal(map[string]any{
+ "type": "authenticated", "protocol": signalingProtocol, "role": "host", "peerId": "host-peer",
+ "roomExpiresAt": nil, "maxViewers": 3, "hostOnline": true,
+ "connectionId": nil, "viewerPeerIds": roster,
+ "iceConfig": map[string]any{"iceServers": []any{}},
+ "viewerPolicy": privateViewerPolicy, "viewerAuthorizationGeneration": "viewer_generation_12345678",
+ })
+ if err = connection.Write(request.Context(), websocket.MessageText, authenticated); err != nil {
+ harness.finish()
+ return
+ }
+ for _, message := range harness.afterAuth {
+ if err = connection.Write(request.Context(), websocket.MessageText, message); err != nil {
+ harness.finish()
+ return
+ }
+ }
+ for {
+ messageType, payload, err = connection.Read(request.Context())
+ if err != nil {
+ harness.finish()
+ return
+ }
+ if messageType != websocket.MessageText {
+ continue
+ }
+ var discriminator struct {
+ Type string `json:"type"`
+ }
+ _ = json.Unmarshal(payload, &discriminator)
+ if discriminator.Type != "abandon-room" {
+ continue
+ }
+ harness.abandonCount.Add(1)
+ harness.terminalMu.Lock()
+ harness.terminal = append([]byte(nil), payload...)
+ harness.terminalMu.Unlock()
+ harness.abandonOnce.Do(func() { close(harness.abandonObserved) })
+ if harness.ackGate != nil {
+ select {
+ case <-harness.ackGate:
+ case <-request.Context().Done():
+ harness.finish()
+ return
+ }
+ }
+ closed := []byte(`{"type":"room-closed","reason":"host-ended"}`)
+ _ = connection.Write(context.Background(), websocket.MessageText, closed)
+ harness.finish()
+ return
+ }
+ })
+ harness.server = httptest.NewServer(mux)
+ return harness
+}
+
+func (harness *remoteHarness) URL() string {
+ return harness.server.URL
+}
+
+func (harness *remoteHarness) Authentication() []byte {
+ select {
+ case payload := <-harness.authentication:
+ return payload
+ case <-time.After(2 * time.Second):
+ harness.testing.Fatal("fake signaling server did not receive authentication")
+ return nil
+ }
+}
+
+func (harness *remoteHarness) TerminalMessage() []byte {
+ harness.terminalMu.Lock()
+ defer harness.terminalMu.Unlock()
+ return append([]byte(nil), harness.terminal...)
+}
+
+func (harness *remoteHarness) AbandonCount() uint64 {
+ return harness.abandonCount.Load()
+}
+
+func (harness *remoteHarness) Done() <-chan struct{} {
+ return harness.done
+}
+
+func (harness *remoteHarness) AbandonObserved() <-chan struct{} {
+ return harness.abandonObserved
+}
+
+func (harness *remoteHarness) Close() {
+ harness.server.Close()
+}
+
+func (harness *remoteHarness) finish() {
+ harness.doneOnce.Do(func() { close(harness.done) })
+}
+
+func writeHarnessJSON(response http.ResponseWriter, value any) {
+ response.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(response).Encode(value)
+}
+
+type omittedJSONField struct{}
+
+func validRoomResponseJSON(origin, roomID, grantRoomID string, overrides map[string]any) string {
+ expiresAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
+ grant := "g1." + grantRoomID + "." + strconv.FormatInt(expiresAt.Unix(), 10) + "." + strings.Repeat("b", 43)
+ response := map[string]any{
+ "roomId": roomID, "hostToken": "0123456789abcdef0123456789abcdef",
+ "inviteUrl": origin + "/r/" + roomID + "#v=" + grant,
+ "viewerPolicy": privateViewerPolicy, "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339),
+ }
+ for key, value := range overrides {
+ if _, omitted := value.(omittedJSONField); omitted {
+ delete(response, key)
+ continue
+ }
+ response[key] = value
+ }
+ payload, err := json.Marshal(response)
+ if err != nil {
+ panic(err)
+ }
+ return string(payload)
+}
+
+func assertAuthenticateShape(t *testing.T, payload []byte) {
+ t.Helper()
+ var message map[string]any
+ if err := json.Unmarshal(payload, &message); err != nil {
+ t.Fatal(err)
+ }
+ keys := make([]string, 0, len(message))
+ for key := range message {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ want := []string{"clientId", "protocol", "role", "roomId", "shareGeneration", "token", "type"}
+ if len(keys) != len(want) {
+ t.Fatalf("authentication keys = %v", keys)
+ }
+ for index := range want {
+ if keys[index] != want[index] {
+ t.Fatalf("authentication keys = %v, want %v", keys, want)
+ }
+ }
+ if message["type"] != "authenticate" || message["protocol"] != signalingProtocol ||
+ message["role"] != "host" || message["roomId"] != "1" {
+ t.Fatalf("authentication = %v", message)
+ }
+}
diff --git a/native/sender/internal/remote/wire.go b/native/sender/internal/remote/wire.go
new file mode 100644
index 0000000..e0e22fa
--- /dev/null
+++ b/native/sender/internal/remote/wire.go
@@ -0,0 +1,429 @@
+package remote
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+const (
+ signalingProtocol = "screener-v2"
+ maxProtocolViewers = 16
+ maxSignalSDPBytes = 48 << 10
+ maxQualityEvidence = 2 << 10
+)
+
+var opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
+
+type sessionDescription struct {
+ Type string `json:"type"`
+ SDP string `json:"sdp"`
+}
+
+type iceCandidate struct {
+ Candidate string `json:"candidate"`
+ SDPMid *string `json:"sdpMid,omitempty"`
+ SDPMLineIndex *uint16 `json:"sdpMLineIndex,omitempty"`
+ UsernameFragment *string `json:"usernameFragment,omitempty"`
+}
+
+type inboundSignalPayload struct {
+ Kind string
+ ConnectionID string
+ Description *sessionDescription
+ Candidate *iceCandidate
+}
+
+type outboundDescriptionPayload struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Description sessionDescription `json:"description"`
+}
+
+type outboundCandidatePayload struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Candidate *iceCandidate `json:"candidate"`
+}
+
+type outboundSignalMessage struct {
+ Type string `json:"type"`
+ TargetPeerID string `json:"targetPeerId"`
+ Payload any `json:"payload"`
+}
+
+type authenticateMessage struct {
+ Type string `json:"type"`
+ Protocol string `json:"protocol"`
+ RoomID string `json:"roomId"`
+ Role string `json:"role"`
+ Token string `json:"token"`
+ ClientID string `json:"clientId"`
+ ShareGeneration string `json:"shareGeneration"`
+}
+
+type simpleMessage struct {
+ Type string `json:"type"`
+}
+
+type serverMessage struct {
+ Type string
+ Role string
+ PeerID string
+ FromPeerID string
+ ViewerPeerIDs []string
+ ConnectionID string
+ Rebuild bool
+ MaxViewers int
+ IceConfig iceConfig
+ ViewerPolicy string
+ Payload inboundSignalPayload
+ Code string
+ Message string
+ Reason string
+}
+
+func readServerMessage(ctx context.Context, conn *websocket.Conn) (serverMessage, error) {
+ messageType, payload, err := conn.Read(ctx)
+ if err != nil {
+ return serverMessage{}, err
+ }
+ if messageType != websocket.MessageText {
+ return serverMessage{}, errors.New("signaling message is not text")
+ }
+ return decodeServerMessage(payload)
+}
+
+func decodeServerMessage(payload []byte) (serverMessage, error) {
+ var discriminator struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(payload, &discriminator); err != nil || discriminator.Type == "" {
+ return serverMessage{}, errors.New("signaling message is not valid JSON")
+ }
+ switch discriminator.Type {
+ case "authenticated":
+ return decodeAuthenticatedMessage(payload)
+ case "peer-joined", "peer-left":
+ var wire struct {
+ Type string `json:"type"`
+ PeerID string `json:"peerId"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.PeerID) {
+ return serverMessage{}, errors.New("peer presence message is invalid")
+ }
+ return serverMessage{Type: wire.Type, PeerID: wire.PeerID}, nil
+ case "signal":
+ var wire struct {
+ Type string `json:"type"`
+ FromPeerID string `json:"fromPeerId"`
+ Payload json.RawMessage `json:"payload"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.FromPeerID) {
+ return serverMessage{}, errors.New("viewer signal envelope is invalid")
+ }
+ signal, err := decodeInboundSignalPayload(wire.Payload)
+ if err != nil {
+ return serverMessage{}, err
+ }
+ return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, Payload: signal}, nil
+ case "restart-request":
+ var wire struct {
+ Type string `json:"type"`
+ FromPeerID string `json:"fromPeerId"`
+ ConnectionID string `json:"connectionId"`
+ Rebuild *bool `json:"rebuild"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || wire.Rebuild == nil ||
+ !validOpaqueID(wire.FromPeerID) || !validOpaqueID(wire.ConnectionID) {
+ return serverMessage{}, errors.New("viewer restart request is invalid")
+ }
+ return serverMessage{Type: wire.Type, FromPeerID: wire.FromPeerID, ConnectionID: wire.ConnectionID, Rebuild: *wire.Rebuild}, nil
+ case "viewer-quality-evidence":
+ if err := validateViewerQualityEvidence(payload); err != nil {
+ return serverMessage{}, err
+ }
+ return serverMessage{Type: discriminator.Type}, nil
+ case "error":
+ var wire struct {
+ Type string `json:"type"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || !validErrorCode(wire.Code) || len(wire.Message) < 1 || len(wire.Message) > 256 {
+ return serverMessage{}, errors.New("signaling error message is invalid")
+ }
+ return serverMessage{Type: wire.Type, Code: wire.Code, Message: wire.Message}, nil
+ case "room-closed":
+ var wire struct {
+ Type string `json:"type"`
+ Reason string `json:"reason"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || wire.Reason != "host-ended" && wire.Reason != "expired" {
+ return serverMessage{}, errors.New("room closure message is invalid")
+ }
+ return serverMessage{Type: wire.Type, Reason: wire.Reason}, nil
+ default:
+ return serverMessage{}, errors.New("signaling message type is unsupported")
+ }
+}
+
+type viewerQualityEvidenceMetrics struct {
+ Width json.RawMessage `json:"width"`
+ Height json.RawMessage `json:"height"`
+ FramesPerSecond json.RawMessage `json:"framesPerSecond"`
+ BitrateKbps json.RawMessage `json:"bitrateKbps"`
+ PacketsReceivedDelta json.RawMessage `json:"packetsReceivedDelta"`
+ PacketsLostDelta json.RawMessage `json:"packetsLostDelta"`
+ JitterMs json.RawMessage `json:"jitterMs"`
+ FramesDecodedDelta json.RawMessage `json:"framesDecodedDelta"`
+ FramesDroppedDelta json.RawMessage `json:"framesDroppedDelta"`
+ DecodeMsPerFrame json.RawMessage `json:"decodeMsPerFrame"`
+ FreezeCountDelta json.RawMessage `json:"freezeCountDelta"`
+ FreezeDurationMsDelta json.RawMessage `json:"freezeDurationMsDelta"`
+ Codec json.RawMessage `json:"codec"`
+ CodecProfile json.RawMessage `json:"codecProfile"`
+ CodecParameters json.RawMessage `json:"codecParameters"`
+}
+
+func validateViewerQualityEvidence(payload []byte) error {
+ if len(payload) > maxQualityEvidence {
+ return errors.New("viewer quality evidence is too large")
+ }
+ var wire struct {
+ Type string `json:"type"`
+ ViewerPeerID string `json:"viewerPeerId"`
+ ParentPeerID string `json:"parentPeerId"`
+ Guard struct {
+ ConnectionID string `json:"connectionId"`
+ RouteRevision json.RawMessage `json:"routeRevision"`
+ } `json:"guard"`
+ Sequence json.RawMessage `json:"sequence"`
+ WindowMS json.RawMessage `json:"windowMs"`
+ Metrics viewerQualityEvidenceMetrics `json:"metrics"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || wire.Type != "viewer-quality-evidence" ||
+ !validOpaqueID(wire.ViewerPeerID) || !validOpaqueID(wire.ParentPeerID) ||
+ !validOpaqueID(wire.Guard.ConnectionID) ||
+ !allJSONFieldsPresent(
+ wire.Guard.RouteRevision,
+ wire.Sequence,
+ wire.WindowMS,
+ wire.Metrics.Width,
+ wire.Metrics.Height,
+ wire.Metrics.FramesPerSecond,
+ wire.Metrics.BitrateKbps,
+ wire.Metrics.PacketsReceivedDelta,
+ wire.Metrics.PacketsLostDelta,
+ wire.Metrics.JitterMs,
+ wire.Metrics.FramesDecodedDelta,
+ wire.Metrics.FramesDroppedDelta,
+ wire.Metrics.DecodeMsPerFrame,
+ wire.Metrics.FreezeCountDelta,
+ wire.Metrics.FreezeDurationMsDelta,
+ wire.Metrics.Codec,
+ wire.Metrics.CodecProfile,
+ wire.Metrics.CodecParameters,
+ ) {
+ return errors.New("viewer quality evidence envelope is invalid")
+ }
+ return nil
+}
+
+func allJSONFieldsPresent(fields ...json.RawMessage) bool {
+ for _, field := range fields {
+ if len(field) == 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func decodeAuthenticatedMessage(payload []byte) (serverMessage, error) {
+ var wire struct {
+ Type string `json:"type"`
+ Protocol string `json:"protocol"`
+ Role string `json:"role"`
+ PeerID string `json:"peerId"`
+ RoomExpiresAt json.RawMessage `json:"roomExpiresAt"`
+ MaxViewers *int `json:"maxViewers"`
+ HostOnline *bool `json:"hostOnline"`
+ ConnectionID json.RawMessage `json:"connectionId"`
+ ViewerPeerIDs []string `json:"viewerPeerIds"`
+ IceConfig json.RawMessage `json:"iceConfig"`
+ ViewerPolicy string `json:"viewerPolicy"`
+ ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || wire.Type != "authenticated" ||
+ wire.Protocol != signalingProtocol ||
+ wire.Role != "host" || !validOpaqueID(wire.PeerID) || wire.MaxViewers == nil ||
+ *wire.MaxViewers < 1 || *wire.MaxViewers > maxProtocolViewers || wire.HostOnline == nil ||
+ !*wire.HostOnline || !bytes.Equal(bytes.TrimSpace(wire.ConnectionID), []byte("null")) ||
+ wire.ViewerPeerIDs == nil || len(wire.ViewerPeerIDs) > *wire.MaxViewers ||
+ (wire.ViewerPolicy != "private-link" && wire.ViewerPolicy != "public-watch") ||
+ !validOpaqueID(wire.ViewerAuthorizationGeneration) {
+ return serverMessage{}, errors.New("host authentication response is invalid")
+ }
+ if err := validateNullableTime(wire.RoomExpiresAt); err != nil {
+ return serverMessage{}, errors.New("host room expiry is invalid")
+ }
+ for _, peerID := range wire.ViewerPeerIDs {
+ if !validOpaqueID(peerID) {
+ return serverMessage{}, errors.New("host viewer roster is invalid")
+ }
+ }
+ config, err := decodeICEConfig(wire.IceConfig)
+ if err != nil {
+ return serverMessage{}, err
+ }
+ return serverMessage{
+ Type: wire.Type,
+ Role: wire.Role,
+ PeerID: wire.PeerID,
+ MaxViewers: *wire.MaxViewers,
+ ViewerPeerIDs: append([]string(nil), wire.ViewerPeerIDs...),
+ IceConfig: config,
+ ViewerPolicy: wire.ViewerPolicy,
+ }, nil
+}
+
+func decodeInboundSignalPayload(payload []byte) (inboundSignalPayload, error) {
+ var discriminator struct {
+ Kind string `json:"kind"`
+ }
+ if err := json.Unmarshal(payload, &discriminator); err != nil {
+ return inboundSignalPayload{}, errors.New("viewer signal payload is invalid")
+ }
+ switch discriminator.Kind {
+ case "description":
+ var wire struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Description sessionDescription `json:"description"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) ||
+ wire.Description.Type != "answer" || len(wire.Description.SDP) < 1 || len(wire.Description.SDP) > maxSignalSDPBytes {
+ return inboundSignalPayload{}, errors.New("viewer answer payload is invalid")
+ }
+ return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Description: &wire.Description}, nil
+ case "candidate":
+ var wire struct {
+ Kind string `json:"kind"`
+ ConnectionID string `json:"connectionId"`
+ Candidate json.RawMessage `json:"candidate"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || !validOpaqueID(wire.ConnectionID) || len(wire.Candidate) == 0 {
+ return inboundSignalPayload{}, errors.New("viewer candidate payload is invalid")
+ }
+ if bytes.Equal(bytes.TrimSpace(wire.Candidate), []byte("null")) {
+ return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID}, nil
+ }
+ var candidate iceCandidate
+ if err := decodeStrict(wire.Candidate, &candidate); err != nil || len(candidate.Candidate) > 4096 ||
+ candidate.SDPMid != nil && len(*candidate.SDPMid) > 128 ||
+ candidate.SDPMLineIndex != nil && *candidate.SDPMLineIndex > 255 ||
+ candidate.UsernameFragment != nil && len(*candidate.UsernameFragment) > 256 {
+ return inboundSignalPayload{}, errors.New("viewer ICE candidate is invalid")
+ }
+ return inboundSignalPayload{Kind: wire.Kind, ConnectionID: wire.ConnectionID, Candidate: &candidate}, nil
+ default:
+ return inboundSignalPayload{}, errors.New("viewer signal kind is invalid")
+ }
+}
+
+func decodeICEConfig(payload []byte) (iceConfig, error) {
+ var wire struct {
+ IceServers []iceServer `json:"iceServers"`
+ }
+ if err := decodeStrict(payload, &wire); err != nil || wire.IceServers == nil || len(wire.IceServers) > 8 {
+ return iceConfig{}, errors.New("ICE configuration is invalid")
+ }
+ for _, server := range wire.IceServers {
+ var urls []string
+ if err := json.Unmarshal(server.URLs, &urls); err != nil {
+ var single string
+ if secondErr := json.Unmarshal(server.URLs, &single); secondErr != nil || !validStunURL(single) {
+ return iceConfig{}, errors.New("ICE server URLs are invalid")
+ }
+ continue
+ }
+ if len(urls) < 1 || len(urls) > 8 {
+ return iceConfig{}, errors.New("ICE server URLs are invalid")
+ }
+ for _, value := range urls {
+ if !validStunURL(value) {
+ return iceConfig{}, errors.New("ICE server URLs are invalid")
+ }
+ }
+ }
+ return iceConfig{IceServers: wire.IceServers}, nil
+}
+
+func validStunURL(value string) bool {
+ if len(value) < len("stun:a") || len(value) > 512 || !strings.EqualFold(value[:len("stun:")], "stun:") {
+ return false
+ }
+ authority := value[len("stun:"):]
+ if authority == "" || strings.ContainsAny(authority, "/\\\t\r\n ?#") || strings.HasSuffix(authority, ":") {
+ return false
+ }
+ parsed, err := url.Parse("//" + authority)
+ if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return false
+ }
+ if port := parsed.Port(); port != "" {
+ value, portErr := strconv.Atoi(port)
+ return portErr == nil && value > 0 && value <= 65535
+ }
+ return true
+}
+
+func decodeStrict(payload []byte, target any) error {
+ decoder := json.NewDecoder(bytes.NewReader(payload))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return err
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return errors.New("multiple JSON values")
+ }
+ return nil
+}
+
+func validateNullableTime(payload json.RawMessage) error {
+ trimmed := bytes.TrimSpace(payload)
+ if len(trimmed) == 0 {
+ return errors.New("missing time")
+ }
+ if bytes.Equal(trimmed, []byte("null")) {
+ return nil
+ }
+ var value string
+ if err := json.Unmarshal(trimmed, &value); err != nil {
+ return err
+ }
+ _, err := time.Parse(time.RFC3339, value)
+ return err
+}
+
+func validOpaqueID(value string) bool {
+ return opaqueIDPattern.MatchString(value)
+}
+
+func validErrorCode(value string) bool {
+ switch value {
+ case "AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/native/sender/internal/remote/wire_test.go b/native/sender/internal/remote/wire_test.go
new file mode 100644
index 0000000..7da0589
--- /dev/null
+++ b/native/sender/internal/remote/wire_test.go
@@ -0,0 +1,222 @@
+package remote
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+const validHostAuthenticated = `{"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"host-peer","roomExpiresAt":null,"maxViewers":3,"hostOnline":true,"connectionId":null,"viewerPeerIds":[],"iceConfig":{"iceServers":[{"urls":["stun:example.test"]}]},"viewerPolicy":"private-link","viewerAuthorizationGeneration":"viewer_generation_12345678"}`
+
+func TestMarshalHostSignalShapes(t *testing.T) {
+ mid := "0"
+ line := uint16(0)
+ tests := []struct {
+ name string
+ payload any
+ want string
+ }{
+ {
+ name: "offer",
+ payload: outboundDescriptionPayload{
+ Kind: "description", ConnectionID: "connection-1",
+ Description: sessionDescription{Type: "offer", SDP: "v=0"},
+ },
+ want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}}`,
+ },
+ {
+ name: "candidate",
+ payload: outboundCandidatePayload{
+ Kind: "candidate", ConnectionID: "connection-1",
+ Candidate: &iceCandidate{Candidate: "candidate:1", SDPMid: &mid, SDPMLineIndex: &line},
+ },
+ want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}}}`,
+ },
+ {
+ name: "end-of-candidates",
+ payload: outboundCandidatePayload{Kind: "candidate", ConnectionID: "connection-1", Candidate: nil},
+ want: `{"type":"signal","targetPeerId":"viewer-1","payload":{"kind":"candidate","connectionId":"connection-1","candidate":null}}`,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ encoded, err := json.Marshal(outboundSignalMessage{Type: "signal", TargetPeerID: "viewer-1", Payload: test.payload})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(encoded) != test.want {
+ t.Fatalf("signal JSON = %s\nwant = %s", encoded, test.want)
+ }
+ })
+ }
+}
+
+func TestDecodeOrdinaryHostAuthentication(t *testing.T) {
+ message, err := decodeServerMessage([]byte(validHostAuthenticated))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if message.MaxViewers != 3 || message.Type != "authenticated" || message.Role != "host" ||
+ message.ViewerPolicy != "private-link" {
+ t.Fatalf("authentication = %+v", message)
+ }
+}
+
+func TestDecodeOrdinaryHostAuthenticationRejectsInvalidVariants(t *testing.T) {
+ tests := map[string]func(map[string]any){
+ "missing-protocol": func(message map[string]any) { delete(message, "protocol") },
+ "wrong-protocol": func(message map[string]any) { message["protocol"] = "legacy-protocol" },
+ "missing-max": func(message map[string]any) { delete(message, "maxViewers") },
+ "zero-max": func(message map[string]any) { message["maxViewers"] = 0 },
+ "too-large-max": func(message map[string]any) { message["maxViewers"] = 17 },
+ "peer-mode-extra": func(message map[string]any) { message["mediaMode"] = "peer-assisted" },
+ "wrong-role": func(message map[string]any) { message["role"] = "viewer" },
+ "legacy-ice-shape": func(message map[string]any) {
+ message["iceConfig"].(map[string]any)["expiresAt"] = nil
+ },
+ "turn-url": func(message map[string]any) {
+ message["iceConfig"] = map[string]any{"iceServers": []any{map[string]any{"urls": "turn:example.test"}}}
+ },
+ "missing-viewer-policy": func(message map[string]any) { delete(message, "viewerPolicy") },
+ "wrong-viewer-policy": func(message map[string]any) { message["viewerPolicy"] = "friends" },
+ "missing-viewer-generation": func(message map[string]any) { delete(message, "viewerAuthorizationGeneration") },
+ "invalid-viewer-generation": func(message map[string]any) { message["viewerAuthorizationGeneration"] = "short" },
+ }
+ for name, mutate := range tests {
+ t.Run(name, func(t *testing.T) {
+ var candidate map[string]any
+ if err := json.Unmarshal([]byte(validHostAuthenticated), &candidate); err != nil {
+ t.Fatal(err)
+ }
+ mutate(candidate)
+ payload, err := json.Marshal(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = decodeServerMessage(payload); err == nil {
+ t.Fatal("invalid authentication was accepted")
+ }
+ })
+ }
+}
+
+func TestDecodeServerMessageRejectsRemovedICERefresh(t *testing.T) {
+ payload := []byte(`{"type":"ice-config","iceConfig":{"iceServers":[]}}`)
+ if _, err := decodeServerMessage(payload); err == nil {
+ t.Fatal("removed ICE refresh message was accepted")
+ }
+}
+
+func TestDecodeUnknownServerMessageDoesNotReflectUntrustedType(t *testing.T) {
+ const secret = "password=g1.1.1893456000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@https://private.test/path"
+ _, err := decodeServerMessage([]byte(`{"type":"` + secret + `"}`))
+ if err == nil || err.Error() != "signaling message type is unsupported" {
+ t.Fatalf("unknown message error = %v", err)
+ }
+ if strings.Contains(err.Error(), secret) {
+ t.Fatal("unknown message error reflected untrusted input")
+ }
+}
+
+func TestDecodeAndIgnoreBoundedViewerQualityEvidence(t *testing.T) {
+ const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}`
+ message, err := decodeServerMessage([]byte(valid))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if message.Type != "viewer-quality-evidence" {
+ t.Fatalf("message type = %q", message.Type)
+ }
+ if err = (&Session{}).handle(message); err != nil {
+ t.Fatalf("fixed-HIGH rejected read-only viewer evidence: %v", err)
+ }
+}
+
+func TestDecodeViewerQualityEvidenceRejectsInvalidVariants(t *testing.T) {
+ const valid = `{"type":"viewer-quality-evidence","viewerPeerId":"viewer-1","parentPeerId":"host-peer","guard":{"connectionId":"connection-1","routeRevision":0},"sequence":1,"windowMs":2000,"metrics":{"width":1280,"height":720,"framesPerSecond":30,"bitrateKbps":3000,"packetsReceivedDelta":100,"packetsLostDelta":0,"jitterMs":2.5,"framesDecodedDelta":60,"framesDroppedDelta":0,"decodeMsPerFrame":1.5,"freezeCountDelta":0,"freezeDurationMsDelta":0,"codec":"video/VP8","codecProfile":null,"codecParameters":"max-fr=30; max-fs=3600"}}`
+ tests := map[string]func(map[string]any){
+ "extra-envelope-field": func(message map[string]any) { message["roomId"] = "1" },
+ "missing-metric": func(message map[string]any) {
+ delete(message["metrics"].(map[string]any), "bitrateKbps")
+ },
+ "extra-metric": func(message map[string]any) {
+ message["metrics"].(map[string]any)["decoderImplementation"] = "private"
+ },
+ "extra-guard-field": func(message map[string]any) {
+ message["guard"].(map[string]any)["peerId"] = "viewer-1"
+ },
+ "missing-guard-field": func(message map[string]any) {
+ delete(message["guard"].(map[string]any), "routeRevision")
+ },
+ "invalid-viewer-id": func(message map[string]any) {
+ message["viewerPeerId"] = "short"
+ },
+ }
+ for name, mutate := range tests {
+ t.Run(name, func(t *testing.T) {
+ var candidate map[string]any
+ if err := json.Unmarshal([]byte(valid), &candidate); err != nil {
+ t.Fatal(err)
+ }
+ mutate(candidate)
+ payload, err := json.Marshal(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = decodeServerMessage(payload); err == nil {
+ t.Fatal("invalid viewer quality evidence was accepted")
+ }
+ })
+ }
+}
+
+func TestDecodeViewerQualityEvidenceRejectsOversizedPayload(t *testing.T) {
+ payload := make([]byte, maxQualityEvidence+1)
+ if err := validateViewerQualityEvidence(payload); err == nil || err.Error() != "viewer quality evidence is too large" {
+ t.Fatalf("oversized viewer quality evidence error = %v", err)
+ }
+}
+
+func TestPionConfigurationNormalizesUppercaseStunScheme(t *testing.T) {
+ config, err := decodeICEConfig([]byte(`{"iceServers":[{"urls":["STUN:[2001:db8::1]:3478"]}]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pionConfig, err := pionConfiguration(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := pionConfig.ICEServers[0].URLs[0]; got != "stun:[2001:db8::1]:3478" {
+ t.Fatalf("Pion STUN URL = %q", got)
+ }
+}
+
+func TestDecodeInboundSignalRejectsCrossVariantFields(t *testing.T) {
+ tests := []string{
+ `{"kind":"description","connectionId":"connection-1","description":{"type":"answer","sdp":"v=0"},"candidate":null}`,
+ `{"kind":"candidate","connectionId":"connection-1"}`,
+ `{"kind":"description","connectionId":"connection-1","description":{"type":"offer","sdp":"v=0"}}`,
+ }
+ for _, payload := range tests {
+ if _, err := decodeInboundSignalPayload([]byte(payload)); err == nil {
+ t.Fatalf("invalid payload was accepted: %s", payload)
+ }
+ }
+}
+
+func TestDecodeInboundSignalRejectsCandidateLineAboveProtocolBound(t *testing.T) {
+ payload := []byte(`{"kind":"candidate","connectionId":"connection-1","candidate":{"candidate":"candidate:1","sdpMLineIndex":256}}`)
+ if _, err := decodeInboundSignalPayload(payload); err == nil {
+ t.Fatal("candidate line 256 was accepted")
+ }
+}
+
+func TestMarshalAbandonRoomShape(t *testing.T) {
+ encoded, err := json.Marshal(simpleMessage{Type: "abandon-room"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(encoded) != `{"type":"abandon-room"}` {
+ t.Fatalf("abandon JSON = %s", encoded)
+ }
+}
diff --git a/src/server/app.ts b/src/server/app.ts
index e9cd487..2680cc3 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -274,7 +274,10 @@ async function handleRequest(
}
try {
- const room = roomStore.createRoom(parsedRequest.data.viewerPolicy);
+ const room = roomStore.createRoom(
+ parsedRequest.data.viewerPolicy,
+ parsedRequest.data.hostClaimTtlSeconds,
+ );
const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl);
if (room.viewerGrant) {
inviteUrl.hash = `v=${room.viewerGrant}`;
diff --git a/src/server/room-store.ts b/src/server/room-store.ts
index a847a01..4afe8de 100644
--- a/src/server/room-store.ts
+++ b/src/server/room-store.ts
@@ -1,6 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
+ MAX_HOST_CLAIM_TTL_SECONDS,
MAX_VIEWERS_PER_ROOM_LIMIT,
ROOM_CODE_LENGTH,
viewerGrantSchema,
@@ -37,6 +38,7 @@ interface Room {
viewerGrantDigest: Buffer | null;
viewerAuthorizationGeneration: string;
expiresAtMs: number | null;
+ provisionalHostExpiresAtMs: number | null;
host?: Participant;
viewers: Map;
}
@@ -157,6 +159,7 @@ export class RoomStore {
: Buffer.from(storedRoom.viewerGrantDigest),
viewerAuthorizationGeneration: this.newAuthorizationGeneration(),
expiresAtMs: null,
+ provisionalHostExpiresAtMs: null,
viewers: new Map(),
});
}
@@ -166,7 +169,18 @@ export class RoomStore {
}
}
- createRoom(viewerPolicy: ViewerAccessPolicy = "private-link"): CreatedRoom {
+ createRoom(
+ viewerPolicy: ViewerAccessPolicy = "private-link",
+ hostClaimTtlSeconds?: number,
+ ): CreatedRoom {
+ if (
+ hostClaimTtlSeconds !== undefined &&
+ hostClaimTtlSeconds !== MAX_HOST_CLAIM_TTL_SECONDS
+ ) {
+ throw new Error(
+ `Provisional Host TTL must be ${MAX_HOST_CLAIM_TTL_SECONDS} seconds`,
+ );
+ }
if (this.rooms.size >= this.options.maxRooms) {
throw new RoomStoreError("ROOM_LIMIT");
}
@@ -175,22 +189,38 @@ export class RoomStore {
const hostTokenDigest = digest(hostToken);
const viewerAuthorizationGeneration = this.newAuthorizationGeneration();
const createdAtMs = this.now();
+ const provisionalHostExpiresAtMs =
+ hostClaimTtlSeconds === undefined
+ ? null
+ : createdAtMs + hostClaimTtlSeconds * 1_000;
+ if (
+ provisionalHostExpiresAtMs !== null &&
+ !Number.isSafeInteger(provisionalHostExpiresAtMs)
+ ) {
+ throw new Error("Provisional Host expiry exceeds the supported time range");
+ }
let viewerGrant: string | null = null;
let viewerGrantExpiresAtMs: number | null = null;
- const storedRoom = this.options.database?.createRoom(
- hostTokenDigest,
- (roomId) => {
- if (viewerPolicy === "public-watch") {
- return null;
- }
- const persistentGrantExpiresAtMs = toEpochSecondsMs(
- createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS,
- );
- viewerGrantExpiresAtMs = persistentGrantExpiresAtMs;
- viewerGrant = this.createViewerGrant(roomId, persistentGrantExpiresAtMs);
- return digest(viewerGrant);
- },
- );
+ const storedRoom =
+ provisionalHostExpiresAtMs === null
+ ? this.options.database?.createRoom(
+ hostTokenDigest,
+ (roomId) => {
+ if (viewerPolicy === "public-watch") {
+ return null;
+ }
+ const persistentGrantExpiresAtMs = toEpochSecondsMs(
+ createdAtMs + PERSISTENT_VIEWER_GRANT_TTL_MS,
+ );
+ viewerGrantExpiresAtMs = persistentGrantExpiresAtMs;
+ viewerGrant = this.createViewerGrant(
+ roomId,
+ persistentGrantExpiresAtMs,
+ );
+ return digest(viewerGrant);
+ },
+ )
+ : undefined;
const roomId = storedRoom?.roomId ?? this.uniqueRoomCode();
const expiresAtMs = storedRoom ? null : createdAtMs + this.options.ttlMs;
if (!storedRoom && viewerPolicy === "private-link") {
@@ -210,6 +240,7 @@ export class RoomStore {
viewerGrantDigest,
viewerAuthorizationGeneration,
expiresAtMs,
+ provisionalHostExpiresAtMs,
viewers: new Map(),
});
@@ -255,7 +286,9 @@ export class RoomStore {
room.viewerAuthorizationGeneration;
if (action === "public-watch") {
- this.options.database?.updateViewerGrantDigest(roomId, null);
+ if (room.expiresAtMs === null) {
+ this.options.database?.updateViewerGrantDigest(roomId, null);
+ }
room.viewerGrantDigest = null;
return {
viewerPolicy: "public-watch",
@@ -282,9 +315,11 @@ export class RoomStore {
const nextViewerAuthorizationGeneration =
this.newAuthorizationGeneration();
- // Persistence is the commit point. No in-memory authorization or topology
- // state changes before this write succeeds.
- this.options.database?.updateViewerGrantDigest(roomId, nextDigest);
+ // Persistence is the commit point for durable rooms. Provisional native
+ // rooms deliberately never enter the configured database.
+ if (room.expiresAtMs === null) {
+ this.options.database?.updateViewerGrantDigest(roomId, nextDigest);
+ }
const revokedViewers = [...room.viewers.values()].map((viewer) => ({
peerId: viewer.peerId,
@@ -318,6 +353,10 @@ export class RoomStore {
return undefined;
}
room.host.sessionId = undefined;
+ if (room.provisionalHostExpiresAtMs !== null) {
+ room.provisionalHostExpiresAtMs =
+ this.now() + MAX_HOST_CLAIM_TTL_SECONDS * 1_000;
+ }
return { roomId, role: "host", peerId };
}
@@ -371,7 +410,11 @@ export class RoomStore {
if (!room) {
return undefined;
}
- if (this.options.database && !this.options.database.deleteRoom(roomId)) {
+ if (
+ room.expiresAtMs === null &&
+ this.options.database &&
+ !this.options.database.deleteRoom(roomId)
+ ) {
throw new Error("Persistent room is missing from the room database");
}
const sessionIds = connectedSessionIds(room);
@@ -382,9 +425,16 @@ export class RoomStore {
expireRooms(nowMs = this.now()): ClosedRoom[] {
const expired: ClosedRoom[] = [];
for (const [roomId, room] of this.rooms) {
- if (room.expiresAtMs === null || room.expiresAtMs > nowMs) {
+ if (!roomIsExpired(room, nowMs)) {
continue;
}
+ if (
+ room.expiresAtMs === null &&
+ this.options.database &&
+ !this.options.database.deleteRoom(roomId)
+ ) {
+ throw new Error("Persistent room is missing from the room database");
+ }
expired.push({ roomId, sessionIds: connectedSessionIds(room) });
this.rooms.delete(roomId);
}
@@ -476,7 +526,7 @@ export class RoomStore {
// A single response avoids turning room IDs into an enumeration oracle.
throw new RoomStoreError("INVALID_TOKEN");
}
- if (room.expiresAtMs !== null && room.expiresAtMs <= this.now()) {
+ if (roomIsExpired(room, this.now())) {
throw new RoomStoreError("ROOM_EXPIRED");
}
return room;
@@ -562,6 +612,15 @@ export class RoomStore {
}
}
+function roomIsExpired(room: Room, nowMs: number): boolean {
+ return (
+ (room.expiresAtMs !== null && room.expiresAtMs <= nowMs) ||
+ (!room.host?.sessionId &&
+ room.provisionalHostExpiresAtMs !== null &&
+ room.provisionalHostExpiresAtMs <= nowMs)
+ );
+}
+
function isRoomCode(value: string): boolean {
return (
value.length <= ROOM_CODE_LENGTH &&
diff --git a/src/server/signaling.ts b/src/server/signaling.ts
index 8512ef2..7801440 100644
--- a/src/server/signaling.ts
+++ b/src/server/signaling.ts
@@ -362,7 +362,7 @@ export class SignalingServer {
);
} catch (error) {
if (!(error instanceof RoomStoreError)) {
- console.error("Signaling authentication failed unexpectedly", error);
+ console.error("Signaling authentication failed unexpectedly");
}
const code =
error instanceof RoomStoreError && error.code !== "ROOM_LIMIT"
diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts
index 028fd88..0298af1 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -618,9 +618,12 @@ export const createRoomResponseSchema = z
.strict();
export type CreateRoomResponse = z.infer;
+export const MAX_HOST_CLAIM_TTL_SECONDS = 5 * 60;
+
export const createRoomRequestSchema = z
.object({
viewerPolicy: viewerAccessPolicySchema,
+ hostClaimTtlSeconds: z.literal(MAX_HOST_CLAIM_TTL_SECONDS).optional(),
})
.strict();
export type CreateRoomRequest = z.infer;
diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts
index 62a9cec..64a7304 100644
--- a/tests/protocol.test.ts
+++ b/tests/protocol.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
+ MAX_HOST_CLAIM_TTL_SECONDS,
MAX_MEDIA_ROUTE_REVISION,
MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES,
MAX_SFU_TOKEN_LENGTH,
@@ -8,6 +9,7 @@ import {
MAX_VIEWERS_PER_ROOM_LIMIT,
SIGNALING_PROTOCOL,
clientMessageSchema,
+ createRoomRequestSchema,
decodeClientMessage,
participantRouteAssignmentSchema,
serverMessageSchema,
@@ -60,6 +62,29 @@ const parentEdgeQualityEvidence = {
} as const;
describe("client signaling protocol", () => {
+ it("accepts only the fixed provisional Host lease", () => {
+ expect(
+ createRoomRequestSchema.parse({ viewerPolicy: "private-link" }),
+ ).toEqual({ viewerPolicy: "private-link" });
+ expect(
+ createRoomRequestSchema.parse({
+ viewerPolicy: "private-link",
+ hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS,
+ }),
+ ).toEqual({
+ viewerPolicy: "private-link",
+ hostClaimTtlSeconds: MAX_HOST_CLAIM_TTL_SECONDS,
+ });
+ for (const hostClaimTtlSeconds of [0, 1, 299, 301, 1.5]) {
+ expect(
+ createRoomRequestSchema.safeParse({
+ viewerPolicy: "private-link",
+ hostClaimTtlSeconds,
+ }).success,
+ ).toBe(false);
+ }
+ });
+
it("rejects the removed all-room ICE refresh request", () => {
expect(clientMessageSchema.safeParse({ type: "refresh-ice" }).success).toBe(
false,
diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts
index b78883f..d8745eb 100644
--- a/tests/room-store.test.ts
+++ b/tests/room-store.test.ts
@@ -313,6 +313,133 @@ describe("RoomStore", () => {
}
});
+ it("keeps provisional native rooms in memory when persistence is configured", () => {
+ let now = Date.UTC(2026, 7, 18, 12);
+ const directory = mkdtempSync(join(tmpdir(), "screener-provisional-room-"));
+ const databasePath = join(directory, "rooms.sqlite");
+ const database = new RoomDatabase(databasePath);
+ const store = new RoomStore({
+ ttlMs: 14_400_000,
+ maxRooms: 3,
+ maxViewersPerRoom: 3,
+ now: () => now,
+ database,
+ });
+ try {
+ const provisional = store.createRoom("private-link", 300);
+ expect(provisional.expiresAt).not.toBeNull();
+ expect(database.loadRooms()).toEqual([]);
+
+ const persistent = store.createRoom();
+ expect(persistent).toMatchObject({ roomId: "1", expiresAt: null });
+ expect(database.loadRooms()).toHaveLength(1);
+
+ now += 300_001;
+ expect(store.expireRooms()).toEqual([
+ { roomId: provisional.roomId, sessionIds: [] },
+ ]);
+ expect(database.loadRooms()).toHaveLength(1);
+ } finally {
+ store.close();
+ rmSync(directory, { recursive: true, force: true });
+ }
+ });
+
+ it("reclaims only the disconnected current Host after the provisional lease", () => {
+ let now = Date.UTC(2026, 7, 18, 12);
+ const store = new RoomStore({
+ ttlMs: 14_400_000,
+ maxRooms: 2,
+ maxViewersPerRoom: 3,
+ now: () => now,
+ });
+ const room = store.createRoom("private-link", 300);
+ const first = store.connectParticipant({
+ roomId: room.roomId,
+ role: "host",
+ token: room.hostToken,
+ clientId: "native-host",
+ sessionId: "native-session-1",
+ });
+
+ now += 301_000;
+ expect(store.expireRooms()).toEqual([]);
+ const replacement = store.connectParticipant({
+ roomId: room.roomId,
+ role: "host",
+ token: room.hostToken,
+ clientId: "native-host",
+ sessionId: "native-session-2",
+ });
+ expect(replacement.replacedSessionId).toBe("native-session-1");
+ expect(
+ store.disconnectParticipant(room.roomId, first.peerId, "native-session-1"),
+ ).toBeUndefined();
+
+ now += 301_000;
+ expect(store.expireRooms()).toEqual([]);
+ expect(
+ store.disconnectParticipant(
+ room.roomId,
+ replacement.peerId,
+ "native-session-2",
+ ),
+ ).toMatchObject({ role: "host" });
+ expect(store.expireRooms(now + 299_999)).toEqual([]);
+ expect(store.expireRooms(now + 300_001)).toEqual([
+ { roomId: room.roomId, sessionIds: [] },
+ ]);
+
+ const ttlRoom = store.createRoom("private-link", 300);
+ const ttlHost = store.connectParticipant({
+ roomId: ttlRoom.roomId,
+ role: "host",
+ token: ttlRoom.hostToken,
+ clientId: "native-ttl-host",
+ sessionId: "native-ttl-session",
+ });
+ expect(store.expireRooms(now + 14_400_001)).toEqual([
+ { roomId: ttlRoom.roomId, sessionIds: ["native-ttl-session"] },
+ ]);
+ expect(ttlHost.hostOnline).toBe(true);
+ });
+
+ it("never restores a provisional native room from SQLite after restart", () => {
+ const directory = mkdtempSync(join(tmpdir(), "screener-provisional-restart-"));
+ const databasePath = join(directory, "rooms.sqlite");
+ let store: RoomStore | undefined;
+ try {
+ store = new RoomStore({
+ ttlMs: 14_400_000,
+ maxRooms: 2,
+ maxViewersPerRoom: 3,
+ database: new RoomDatabase(databasePath),
+ });
+ const room = store.createRoom("private-link", 300);
+ store.close();
+ store = new RoomStore({
+ ttlMs: 14_400_000,
+ maxRooms: 2,
+ maxViewersPerRoom: 3,
+ database: new RoomDatabase(databasePath),
+ });
+ expectRoomError(
+ () =>
+ store!.connectParticipant({
+ roomId: room.roomId,
+ role: "host",
+ token: room.hostToken,
+ clientId: "native-after-restart",
+ sessionId: "native-after-restart",
+ }),
+ "INVALID_TOKEN",
+ );
+ } finally {
+ store?.close();
+ rmSync(directory, { recursive: true, force: true });
+ }
+ });
+
it("fails private Viewer access closed and scopes grants to one room", () => {
const now = Date.UTC(2026, 7, 18, 12);
const store = new RoomStore({
diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts
index ba4134c..c495104 100644
--- a/tests/server-http.test.ts
+++ b/tests/server-http.test.ts
@@ -86,6 +86,7 @@ async function createRoom(
baseUrl: string,
cookie?: string,
viewerPolicy: "private-link" | "public-watch" = "private-link",
+ hostClaimTtlSeconds?: number,
): Promise {
return fetch(`${baseUrl}/api/rooms`, {
method: "POST",
@@ -94,7 +95,10 @@ async function createRoom(
Origin: allowedOrigin,
...(cookie ? { Cookie: cookie } : {}),
},
- body: JSON.stringify({ viewerPolicy }),
+ body: JSON.stringify({
+ viewerPolicy,
+ ...(hostClaimTtlSeconds === undefined ? {} : { hostClaimTtlSeconds }),
+ }),
});
}
@@ -277,6 +281,25 @@ describe("room HTTP API", () => {
expect("iceConfig" in body).toBe(false);
});
+ it("accepts only the admitted fixed provisional Host lease", async () => {
+ const baseUrl = await start();
+ expect((await createRoom(baseUrl, undefined, "private-link", 300)).status).toBe(
+ 401,
+ );
+
+ const authenticated = await login(baseUrl);
+ const cookie = cookiePair(authenticated);
+ expect((await createRoom(baseUrl, cookie, "private-link", 300)).status).toBe(
+ 201,
+ );
+ expect((await createRoom(baseUrl, cookie, "private-link", 301)).status).toBe(
+ 400,
+ );
+ expect((await createRoom(baseUrl, cookie, "private-link", 299)).status).toBe(
+ 400,
+ );
+ });
+
it("allows explicit public-watch creation without Host admission in local mode", async () => {
const baseUrl = await start(
testConfig({ hostAdmissionPassword: undefined }),
diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts
index 9536026..1b4120d 100644
--- a/tests/server-signal.test.ts
+++ b/tests/server-signal.test.ts
@@ -137,6 +137,7 @@ interface SignalHarness {
webSocketUrl: string;
roomStore: RoomStore;
room: CreatedRoom;
+ database?: RoomDatabase;
peerAssistedRoomIds?: Set;
}
@@ -164,6 +165,7 @@ async function startHarness(
maxUnauthenticatedSignalConnections?: number;
hostAdmissionPassword?: string;
persistent?: boolean;
+ provisionalHostClaimSeconds?: number;
peerAssistedMedia?: boolean;
stunUrls?: readonly string[];
now?: () => number;
@@ -175,13 +177,18 @@ async function startHarness(
config.stunUrls = overrides.stunUrls ?? [];
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
config.maxViewersPerRoom = maxViewersPerRoom;
+ const database = overrides.persistent ? new RoomDatabase(":memory:") : undefined;
const roomStore = new RoomStore({
ttlMs: config.roomTtlMs,
maxRooms: config.maxRooms,
maxViewersPerRoom,
- database: overrides.persistent ? new RoomDatabase(":memory:") : undefined,
+ database,
+ now: overrides.now,
});
- const room = roomStore.createRoom();
+ const room = roomStore.createRoom(
+ "private-link",
+ overrides.provisionalHostClaimSeconds,
+ );
const peerAssistedRoomIds = config.peerAssistedMedia
? new Set([room.roomId])
: undefined;
@@ -205,6 +212,7 @@ async function startHarness(
webSocketUrl: `ws://127.0.0.1:${port}/signal`,
roomStore,
room,
+ database,
peerAssistedRoomIds,
};
}
@@ -750,6 +758,40 @@ describe("WebSocket signaling", () => {
).resolves.toMatchObject({ role: "host" });
});
+ it("rearms an in-memory provisional room lease only after Host disconnect", async () => {
+ let now = Date.UTC(2026, 7, 20, 12);
+ const harness = await startHarness({
+ persistent: true,
+ provisionalHostClaimSeconds: 300,
+ now: () => now,
+ });
+ expect(harness.database!.loadRooms()).toEqual([]);
+ const host = await openClient(harness.webSocketUrl);
+ const authenticated = await authenticate(
+ host,
+ harness.room,
+ "host",
+ "provisional-host",
+ );
+ const viewer = await openClient(harness.webSocketUrl);
+ await authenticate(viewer, harness.room, "viewer", "provisional-viewer");
+ await host.inbox.next("peer-joined");
+
+ now += 301_000;
+ expect(harness.roomStore.expireRooms()).toEqual([]);
+ await closeClient(host);
+ await viewer.inbox.next("host-status");
+ expect(harness.roomStore.getConnectedHost(harness.room.roomId)).toBeUndefined();
+ expect(harness.roomStore.expireRooms(now + 299_999)).toEqual([]);
+ const expired = harness.roomStore.expireRooms(now + 300_001);
+ expect(expired).toHaveLength(1);
+ expect(expired[0]).toMatchObject({ roomId: harness.room.roomId });
+ expect(expired[0]?.sessionIds).toHaveLength(1);
+ await closeClient(viewer);
+ expect(authenticated.role).toBe("host");
+ expect(harness.database!.loadRooms()).toEqual([]);
+ });
+
it("leaves established authorization and media untouched when access persistence fails", async () => {
const harness = await startHarness({ persistent: true });
const host = await openClient(harness.webSocketUrl);