From cf4987c3c7ff729a1505178df2aaa3c5d6044176 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:29:56 +0800 Subject: [PATCH] feat(browser): implement room v12 and exact audio stats --- docs/research/browser-background-capture.md | 4 +- docs/todo.md | 2 +- docs/方案设计.md | 2 +- scripts/peer-assisted-benchmark.ts | 42 ++- .../components/ConnectionDetailsToggle.tsx | 14 - src/client/components/RoomCodeEntry.tsx | 4 +- src/client/lib/diagnostic-export.ts | 205 ---------- src/client/lib/session.ts | 40 +- src/client/lib/signaling.ts | 1 + src/client/media/viewer-presentation.ts | 9 + src/client/pages/HostPage.tsx | 159 +++----- src/client/pages/ViewerPage.tsx | 86 +---- src/client/pages/host-page-notices.ts | 1 + src/client/sfu/publisher.ts | 62 ++- src/client/styles.css | 87 +++-- src/client/webrtc/host-peer.ts | 14 +- src/client/webrtc/stats.ts | 13 +- src/server/app.ts | 1 - src/server/room-store.ts | 93 ++--- src/server/signaling.ts | 13 +- src/shared/protocol.ts | 10 +- tests/audio-stats.test.ts | 113 ++++++ tests/client-state.test.ts | 63 ++-- tests/connection-details-toggle.test.ts | 15 +- tests/diagnostic-export.test.ts | 353 ------------------ tests/host-page-notice.test.ts | 1 + tests/host-peer-tracks.test.ts | 112 ++++++ tests/protocol.test.ts | 15 +- tests/room-store.test.ts | 66 +++- tests/server-http.test.ts | 7 +- tests/server-signal.test.ts | 43 ++- tests/sfu-client.test.ts | 88 +++++ tests/viewer-presentation.test.ts | 11 + 33 files changed, 772 insertions(+), 977 deletions(-) delete mode 100644 src/client/lib/diagnostic-export.ts create mode 100644 tests/audio-stats.test.ts delete mode 100644 tests/diagnostic-export.test.ts diff --git a/docs/research/browser-background-capture.md b/docs/research/browser-background-capture.md index 96e5f21..72af6ad 100644 --- a/docs/research/browser-background-capture.md +++ b/docs/research/browser-background-capture.md @@ -99,8 +99,8 @@ deployment health supplies no physical background-capture evidence. work, limitation reason, codec, encoder, and receiver metrics. Its interval calculations use RTCStats timestamps, so delayed UI timer delivery must not be inferred as an equal media-rate drop. -- `src/client/lib/diagnostic-export.ts` exports only the latest bounded sample. - It is a useful cross-check, not a historical trace. +- The local connection-details UI shows only the latest bounded sample; it is a + cross-check rather than a historical trace and no longer exports a file. - The short-lived pending-candidate decoded-frame observer reads cumulative RTP progress only for the exact pending route. It neither drives capture nor proves document activity, and it stops when that operation settles. diff --git a/docs/todo.md b/docs/todo.md index b8c088b..96cbe1d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,7 +7,7 @@ Only items in **Now** are executable after their stated decision gate. A branch ## Now 1. **Fix and physically validate live screen-audio statistics.** Bind ordinary Host and SFU publisher audio stats to the exact current sender track, merge the SFU video/audio reports, and reset the baseline when the audio identity changes or disappears. Then reproduce the reported `1 kbps` state with known audible content and switch the 64/128/256 kbps ceilings across direct, browser-relay, and SFU paths while checking sender/receiver counters, route continuity, audible result, and A/V synchronization. A real muted/idle track may legitimately use about 1 kbps; do not multiply the value or display the configured ceiling as observed bitrate. -2. **Implement the accepted Browser v12 room/access/UI contract.** Replace the time-bearing 256-bit Viewer grant with a 22-character 128-bit opaque grant whose digest belongs to the exact room incarnation, remove independent grant expiry fields, add `ROOM_NOT_FOUND` for unallocated/reclaimed/expired code-only entry without a password prompt, remove Host/Viewer diagnostic-download buttons and browser file export, and replace the Host raw-link block with a compact copy-first invitation layout. Preserve the Host-only bounded route snapshot used by automatic acceptance, plus copy-room-code, `open | password | disabled`, password, rotate, revoke, fragment/sessionStorage privacy, and strict atomic wire replacement; do not retain a v11 parser or add a short-link store. +2. **Implement the accepted Browser v12 room/access/UI contract.** Replace the time-bearing 256-bit Viewer grant with a 22-character 128-bit opaque grant whose digest belongs to the exact room incarnation, remove independent grant expiry fields, add `ROOM_NOT_FOUND` for unallocated/reclaimed/expired code-only entry without a password prompt, remove Host/Viewer diagnostic-download buttons and browser file export, and show the shorter full URL in a compact copy-first Host invitation layout. Preserve the Host-only bounded route snapshot used by automatic acceptance, plus copy-room-code, `open | password | disabled`, password, rotate, revoke, fragment/sessionStorage privacy, and strict atomic wire replacement; do not retain a v11 parser or add a short-link store. 3. **Physically validate fixed Browser VP8 with no video hint.** Production exact deployed application/runtime revision `c4962f54443ad5f98bc65861195a3d9c74a48996`, release `c4962f5`, runs strict v11, VP8-only direct/browser-relay offers, VP8 SFU publication with no backup codec, unset video `contentHint`, and no codec UI/state/wire. Confirm actual codec, capture, outbound, and decoded stats on direct, browser-relay, and SFU paths; automated gates and the owner SFU connectivity proof do not close the full codec gate. 4. **Finish real-network route acceptance.** Measure standard ICE/STUN direct paths, peer relay, SFU ingress/subscription, exact first-frame commit and rollback, relay-ingress repair with subtree retention, disconnect/capacity drain, Pause/Resume, and bounded explicit failure across representative IPv4/IPv6 NATs and mobile networks. Include an all-UDP-blocked case to establish the current failure boundary. Do not add port prediction, NAT classification, TCP probes, guessed candidates, route scores, or quality-driven reparenting. diff --git a/docs/方案设计.md b/docs/方案设计.md index f17a598..18a50b0 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -293,7 +293,7 @@ Host 与 relay Viewer 复用同一个纯 presentation reducer 保存远端 `View 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。 - 访问入口:未鉴权根路径、`/join` 和合法 code-only `/r/{code}` 显示“站点访问”与“站点口令”;malformed `/r/...` 直接显示房间号必须是 `1000..9999` 四位数字,其他未知路径只显示“无法访问”。有效 `/r/{code}#v={grant}` 直接进入;其余 Viewer 通过 site access 后以 code-only 方式进入。未分配、已回收或已过期的 well-formed code 显示“房间不存在或已过期”,不展开或提供密码输入;现存房间的 `ROOM_ACCESS_DENIED` 保留“输入房间密码”和使用 Host 邀请的动作。已有有效 site-access cookie 不重复显示整站验证;站点口令和房间密码不共享输入值或授权状态。 -- 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑四位房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;固定 action row 只能包含这两个按钮及分隔,disclosure 是紧随其后的 sibling,不得参与 action-row track sizing。前者直接开始现有分享流程,后者在当前舞台内展开共享的房间码表单,提交后才导航到 `/r/{code}`。按钮同步 `aria-expanded`/`aria-controls`,展开后输入获得焦点,DOM 与焦点顺序保持触发器后接表单;窄屏允许 disclosure 自己换行,不改变两个 action 的矩形或产生横向滚动。不增加首页、向导或拓扑选择。直播时舞台显示本地预览及独立全屏图标,底部保留停止、同步暂停音视频和质量档位。邀请控制使用紧凑 copy-first 布局:完整 raw URL 不占据主视觉,主动作直接复制含 22 字符 grant 的邀请链接;更新和撤销使用有 tooltip/accessible name 的 familiar icons,房间号复制、`开放 | 密码 | 关闭` code-entry segmented control 与密码编辑保持职责分离。页面失焦仅暂停本地 video element,并在舞台提示“本地预览已暂停,分享仍在继续”;媒体暂停则禁用 capture stream 轨道,两者都保留现有连接。 +- 分享者根页面:顶部显示“{分享者显示名} 的屏幕”和房间状态,并在标题旁显示可单独复制的紧凑四位房间号;Viewer 同一位置显示权威 Host 名称,不再在标题下重复。主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;固定 action row 只能包含这两个按钮及分隔,disclosure 是紧随其后的 sibling,不得参与 action-row track sizing。前者直接开始现有分享流程,后者在当前舞台内展开共享的房间码表单,提交后才导航到 `/r/{code}`。按钮同步 `aria-expanded`/`aria-controls`,展开后输入获得焦点,DOM 与焦点顺序保持触发器后接表单;窄屏允许 disclosure 自己换行,不改变两个 action 的矩形或产生横向滚动。不增加首页、向导或拓扑选择。直播时舞台显示本地预览及独立全屏图标,底部保留停止、同步暂停音视频和质量档位。邀请控制使用紧凑 copy-first 布局:包含 22 字符 grant 的完整短 URL 保持可见,复制是主动作;更新和撤销使用有 tooltip/accessible name 的 familiar icons,房间号复制、`开放 | 密码 | 关闭` code-entry segmented control 与密码编辑保持职责分离。页面失焦仅暂停本地 video element,并在舞台提示“本地预览已暂停,分享仍在继续”;媒体暂停则禁用 capture stream 轨道,两者都保留现有连接。 - `/join`:保留为可直接访问的回退入口,并复用同一个四位房间码表单;一个纯 route parser 返回 `host | join | viewer | malformed-room | unknown`,只把精确 `/r/{1000..9999}` 当成 Viewer route。输入保留用户原文,`inputMode="numeric"` 只提示软键盘,提交时由共享 schema 拒绝而不是 `trim`、删除非数字或接受 Unicode 数字。通过 site access 后输入合法 code 并进入 `/r/{code}`,后续行为由 code-only admission 决定。 - `/r/{code}` 观看者:首次解析合法 fragment grant 后立即清理地址栏并直达;仅有 code 时先通过 site access,再按 `open/password/disabled` policy 进入。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制;Host 主动暂停时复用视频舞台遮罩显示“分享者已暂停”,恢复后立即移除。视频舞台用单一派生 presentation reducer 合成 access、Host presence、route prepare/active、peer/SFU connection、remote track、首个 composited frame、autoplay rejection 和 bounded failure。支持时用 `requestVideoFrameCallback()` 确认第一帧已提交,旧浏览器回退到当前代 decoded-frame delta 与 video readiness;`playing`、track object 或 ICE `connected` 单独都不足以结束占位层。连接阶段占用固定布局且不显示虚假百分比,播放按钮只处理实际 autoplay rejection,失败重试只调用现有有界 controller 入口。 - 首帧观察绑定当前 assignment/connection/media-binding generation 以及适用的 publication generation;`srcObject` 或 route identity 变化立即使旧回调和旧 decoded baseline 失效,迟到回调不能把新路线标成已播放。 diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index d1ca461..c2c8655 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -30,11 +30,6 @@ import { type QualityProfileId, type QualitySettings, } from "../src/client/media/quality"; -import { - summarizeRouteTiming, - type RouteTimingDistribution, -} from "../src/client/lib/diagnostic-export"; - const PROFILE_SETTINGS = QUALITY_PROFILES; type ProfileId = QualityProfileId; type PageRole = "host" | "viewer"; @@ -47,6 +42,14 @@ const ROUTE_TIMING_KEYS = [ "finalMs", ] as const satisfies readonly (keyof RouteDiagnosticSnapshot["children"][number])[]; type RouteTimingKey = (typeof ROUTE_TIMING_KEYS)[number]; +interface RouteTimingDistribution { + sampleCount: number; + pendingCount: number; + rawMs: number[]; + p50Ms: number | null; + p95Ms: number | null; + maxMs: number | null; +} export type BenchmarkRouteTimingSamples = { [Key in RouteTimingKey]: Array< RouteDiagnosticSnapshot["children"][number][Key] @@ -385,6 +388,30 @@ export function summarizeBenchmarkRouteTiming( ) as BenchmarkRouteTimingSummary; } +function summarizeRouteTiming( + values: readonly (number | null)[], +): RouteTimingDistribution { + const rawMs = values + .filter((value): value is number => value !== null) + .toSorted((left, right) => left - right); + return { + sampleCount: rawMs.length, + pendingCount: values.length - rawMs.length, + rawMs, + p50Ms: nearestRank(rawMs, 0.5), + p95Ms: nearestRank(rawMs, 0.95), + maxMs: rawMs.at(-1) ?? null, + }; +} + +function nearestRank( + sortedValues: readonly number[], + percentile: number, +): number | null { + if (sortedValues.length === 0) return null; + return sortedValues[Math.ceil(percentile * sortedValues.length) - 1] ?? null; +} + export function buildRouteTimingCheck( status: BenchmarkRun["routeTimingStatus"], summary: BenchmarkRouteTimingSummary | null, @@ -2696,7 +2723,10 @@ async function runCase( const viewerUrl = await evaluate( cdp, hostPage, - "document.querySelector('.invite-url')?.getAttribute('title') || ''", + `(() => { + const grant = sessionStorage.getItem('screener:viewer-grant:${roomId}'); + return grant ? location.origin + '/r/${roomId}#v=' + grant : ''; + })()`, ); if (!viewerUrl.startsWith(`${baseUrl}/r/${roomId}#v=`)) { throw new Error("Host private Viewer invite was unavailable"); diff --git a/src/client/components/ConnectionDetailsToggle.tsx b/src/client/components/ConnectionDetailsToggle.tsx index af69017..5846d5f 100644 --- a/src/client/components/ConnectionDetailsToggle.tsx +++ b/src/client/components/ConnectionDetailsToggle.tsx @@ -1,16 +1,13 @@ -import { Download } from "lucide-react"; import type { ChangeEvent } from "react"; interface ConnectionDetailsToggleProps { checked: boolean; onChange: (checked: boolean) => void; - onExport?: () => void; } export function ConnectionDetailsToggle({ checked, onChange, - onExport, }: ConnectionDetailsToggleProps) { return (
@@ -26,17 +23,6 @@ export function ConnectionDetailsToggle({ /> 显示连接详情 - {checked && onExport && ( - - )}
); } diff --git a/src/client/components/RoomCodeEntry.tsx b/src/client/components/RoomCodeEntry.tsx index 58b5582..1767f41 100644 --- a/src/client/components/RoomCodeEntry.tsx +++ b/src/client/components/RoomCodeEntry.tsx @@ -1,6 +1,6 @@ import { Hash, LogIn } from "lucide-react"; import { useState, type FormEvent } from "react"; -import { roomRouteFromInput } from "../lib/session"; +import { roomRouteForExplicitEntry } from "../lib/session"; interface RoomCodeEntryProps { id: string; @@ -15,7 +15,7 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod function join(event: FormEvent): void { event.preventDefault(); - const route = roomRouteFromInput(roomId); + const route = roomRouteForExplicitEntry(roomId); if (!route) { setError("请输入 1000 至 9999 的四位房间号"); return; diff --git a/src/client/lib/diagnostic-export.ts b/src/client/lib/diagnostic-export.ts deleted file mode 100644 index 41eb902..0000000 --- a/src/client/lib/diagnostic-export.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { RouteDiagnosticSnapshot } from "../../shared/protocol"; -import type { ConnectionMetrics } from "../types"; -import type { - ViewerFailureCode, - ViewerHostState, - ViewerRouteKind, - ViewerStage, -} from "../media/viewer-presentation"; -import type { SignalConnectionState } from "../types"; - -export const DIAGNOSTIC_SCHEMA_VERSION = 2; - -const ROUTE_TIMING_KEYS = [ - "queueWaitMs", - "candidateStartMs", - "firstDecodedFrameMs", - "finalMs", -] as const satisfies readonly (keyof RouteDiagnosticSnapshot["children"][number])[]; - -export interface RouteTimingDistribution { - sampleCount: number; - pendingCount: number; - rawMs: number[]; - p50Ms: number | null; - p95Ms: number | null; - maxMs: number | null; -} - -export function summarizeRouteTiming( - values: readonly (number | null)[], -): RouteTimingDistribution { - const rawMs = values - .filter((value): value is number => value !== null) - .toSorted((left, right) => left - right); - return { - sampleCount: rawMs.length, - pendingCount: values.length - rawMs.length, - rawMs, - p50Ms: nearestRank(rawMs, 0.5), - p95Ms: nearestRank(rawMs, 0.95), - maxMs: rawMs.at(-1) ?? null, - }; -} - -function nearestRank( - sortedValues: readonly number[], - percentile: number, -): number | null { - if (sortedValues.length === 0) return null; - return sortedValues[Math.ceil(percentile * sortedValues.length) - 1] ?? null; -} - -const DIAGNOSTIC_METRIC_KEYS = [ - "sampleWindowMs", "captureWidth", "captureHeight", - "candidatePairResponsesReceived", "intervalCandidatePairResponsesReceived", - "candidatePairSampleWindowMs", - "captureFramesPerSecond", "mediaSourceFramesPerSecond", "path", - "iceProtocol", "localCandidateType", - "remoteCandidateType", "rttMs", "bitrateKbps", "availableOutgoingKbps", - "framesPerSecond", "frameWidth", "frameHeight", "resolution", - "packetsLost", "intervalPacketsSent", "intervalPacketsReceived", - "intervalPacketsLost", "packetLossPercent", "jitterMs", "framesDropped", - "intervalFramesDecoded", "intervalFramesDropped", "intervalFreezeCount", - "intervalFreezeDurationMs", "intervalRetransmittedPackets", - "intervalRetransmittedBytes", "codec", "codecProfile", "codecParameters", - "audioBitrateKbps", "audioPacketLossPercent", "audioJitterMs", - "audioVideoPlayoutDeltaMs", "videoJitterBufferDelayMs", - "audioJitterBufferDelayMs", "audioConcealedSamplesPercent", - "intervalAudioConcealmentEvents", "audioCodec", "audioCodecClockRate", - "audioCodecChannels", "audioCodecParameters", "scalabilityMode", - "encoderImplementation", "powerEfficientEncoder", "intervalFramesEncoded", - "intervalEncodeTimeMs", "intervalEncodeMs", "intervalDecodeMs", - "qualityLimitationReason", -] as const satisfies readonly (keyof ConnectionMetrics)[]; - -export interface DiagnosticConnectionInput { - scope: "host-sfu" | "upstream" | "viewer-edge" | "relay-edge"; - route: "p2p" | "sfu"; - direction: "send" | "receive"; - connectionState?: RTCPeerConnectionState | "reconnecting" | null; - iceConnectionState?: RTCIceConnectionState | null; - metrics: ConnectionMetrics; -} - -export interface ViewerDiagnosticInput { - authenticated: boolean; - stage: ViewerStage; - revision: number | null; - failureCode: ViewerFailureCode | null; - signalState: SignalConnectionState; - hostState: ViewerHostState; - routeKind: ViewerRouteKind; - frameProof: "none" | "previous" | "current"; -} - -export function createDiagnosticReport( - role: "host" | "viewer", - connections: readonly DiagnosticConnectionInput[], - exportedAt = new Date(), - context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null, -) { - const route = - role === "host" && isRouteDiagnosticSnapshot(context) ? context : null; - const viewerState = - role === "viewer" && isViewerDiagnosticInput(context) - ? context - : undefined; - const safeConnections = - role === "viewer" && viewerState && !viewerState.authenticated - ? [] - : connections; - return { - schemaVersion: DIAGNOSTIC_SCHEMA_VERSION, - exportedAt: exportedAt.toISOString(), - role, - route, - routeTimingSummary: route - ? Object.fromEntries( - ROUTE_TIMING_KEYS.map((key) => [ - key, - summarizeRouteTiming(route.children.map((child) => child[key])), - ]), - ) - : null, - ...(role === "viewer" && viewerState - ? { viewer: serializeViewerState(viewerState) } - : {}), - connections: safeConnections.map((connection) => ({ - scope: connection.scope, - route: connection.route, - direction: connection.direction, - connectionState: connection.connectionState ?? null, - iceConnectionState: connection.iceConnectionState ?? null, - metrics: Object.fromEntries( - DIAGNOSTIC_METRIC_KEYS.map((key) => [ - key, - connection.metrics[key] ?? null, - ]), - ), - })), - }; -} - -export function downloadDiagnosticReport( - role: "host" | "viewer", - connections: readonly DiagnosticConnectionInput[], - context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null, -): void { - const exportedAt = new Date(); - const report = createDiagnosticReport( - role, - connections, - exportedAt, - context, - ); - const blob = new Blob([`${JSON.stringify(report, null, 2)}\n`], { - type: "application/json;charset=utf-8", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `screener-diagnostics-${role}-${exportedAt - .toISOString() - .replace(/[:.]/g, "-")}.json`; - link.hidden = true; - document.body.append(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); -} - -function isRouteDiagnosticSnapshot( - value: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null, -): value is RouteDiagnosticSnapshot { - return value !== null && "children" in value; -} - -function isViewerDiagnosticInput( - value: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null, -): value is ViewerDiagnosticInput { - return value !== null && "authenticated" in value; -} - -function serializeViewerState(state: ViewerDiagnosticInput) { - if (!state.authenticated) { - return { - stage: state.stage, - failureCode: state.failureCode, - }; - } - return { - stage: state.stage, - revision: - state.revision === null - ? "none" - : state.revision === 0 - ? "initial" - : "changed", - failureCode: state.failureCode, - signalState: state.signalState, - hostState: state.hostState, - routeKind: state.routeKind, - frameProof: state.frameProof, - }; -} diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts index 73f568b..7cdd8e7 100644 --- a/src/client/lib/session.ts +++ b/src/client/lib/session.ts @@ -46,6 +46,14 @@ export function roomRouteFromInput(value: string): string | null { return isValidRoomId(value) ? `/r/${value}` : null; } +export function roomRouteForExplicitEntry(value: string): string | null { + const route = roomRouteFromInput(value); + if (route) { + clearViewerGrant(value); + } + return route; +} + export function parseAppRoute(pathname: string): AppRoute { if (pathname === "/") { return { kind: "host" }; @@ -157,10 +165,9 @@ export function readViewerRoute(): ViewerRoute | null { if (fragment) { const fragmentMatch = fragment.match(/^#v=(.+)$/); const viewerGrant = fragmentMatch?.[1]; - const validGrant = - viewerGrant && isViewerGrantForRoom(viewerGrant, roomId) - ? viewerGrant - : null; + const validGrant = viewerGrant && isValidViewerGrant(viewerGrant) + ? viewerGrant + : null; if (validGrant) { writeSessionValue(viewerGrantStorageKey(roomId), validGrant); } else { @@ -171,7 +178,7 @@ export function readViewerRoute(): ViewerRoute | null { } const storedGrant = readSessionValue(viewerGrantStorageKey(roomId)); - if (storedGrant && isViewerGrantForRoom(storedGrant, roomId)) { + if (storedGrant && isValidViewerGrant(storedGrant)) { return { roomId, viewerGrant: storedGrant }; } if (storedGrant) { @@ -195,9 +202,15 @@ export function replaceViewerInvite( clearViewerGrant(roomId); return; } + const inviteRoute = parseAppRoute(parsed.pathname); const match = parsed.hash.match(/^#v=(.+)$/); const viewerGrant = match?.[1]; - if (viewerGrant && isViewerGrantForRoom(viewerGrant, roomId)) { + if ( + inviteRoute.kind === "viewer" && + inviteRoute.roomId === roomId && + viewerGrant && + isValidViewerGrant(viewerGrant) + ) { writeSessionValue(viewerGrantStorageKey(roomId), viewerGrant); return; } @@ -206,7 +219,7 @@ export function replaceViewerInvite( export function readViewerGrant(roomId: string): string | null { const value = readSessionValue(viewerGrantStorageKey(roomId)); - if (value && isViewerGrantForRoom(value, roomId)) { + if (value && isValidViewerGrant(value)) { return value; } if (value) { @@ -255,17 +268,8 @@ function viewerGrantStorageKey(roomId: string): string { return `screener:viewer-grant:${roomId}`; } -function isViewerGrantForRoom(value: string, roomId: string): boolean { - if (!viewerGrantSchema.safeParse(value).success) { - return false; - } - const [, grantRoomId, expiresAtText] = value.split(".", 4); - const expiresAtSeconds = Number(expiresAtText); - return ( - grantRoomId === roomId && - Number.isSafeInteger(expiresAtSeconds) && - expiresAtSeconds > Math.floor(Date.now() / 1_000) - ); +function isValidViewerGrant(value: string): boolean { + return viewerGrantSchema.safeParse(value).success; } export function getStableClientId(role: "host" | "viewer", roomId: string): string { diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index c7485fb..2059696 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -33,6 +33,7 @@ export type SignalingTerminationReason = const FATAL_SIGNAL_ERRORS = new Set([ "AUTH_REQUIRED", "INVALID_TOKEN", + "ROOM_NOT_FOUND", "ROOM_ACCESS_DENIED", "ROOM_EXPIRED", "ROOM_FULL", diff --git a/src/client/media/viewer-presentation.ts b/src/client/media/viewer-presentation.ts index b38dee5..f4fdd1f 100644 --- a/src/client/media/viewer-presentation.ts +++ b/src/client/media/viewer-presentation.ts @@ -10,6 +10,7 @@ export type ViewerHostState = export type ViewerRouteKind = "none" | "p2p" | "sfu"; export type ViewerFailureCode = + | "ROOM_NOT_FOUND" | "ROOM_ACCESS_DENIED" | "INVALID_TOKEN" | "ROOM_CLOSED" @@ -28,6 +29,7 @@ export type ViewerFailureCode = export type ViewerStage = | "joining" + | "room-not-found" | "access-denied" | "invalid-invite" | "room-closed" @@ -399,6 +401,13 @@ export function deriveViewerPresentation( } if (state.access === "denied") { switch (state.failure) { + case "ROOM_NOT_FOUND": + return presentation( + "room-not-found", + "房间不存在或已过期", + "blocking", + state, + ); case "ROOM_ACCESS_DENIED": return presentation( "access-denied", diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index c060a8d..a26ea03 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -2,7 +2,7 @@ import { Check, Copy, KeyRound, - LockKeyhole, + Link2Off, Maximize2, MonitorUp, Network, @@ -51,7 +51,6 @@ import { type HostCreationProfile, } from "../lib/creation-profile"; import { createOpaqueId } from "../lib/opaque-id"; -import { downloadDiagnosticReport, type DiagnosticConnectionInput } from "../lib/diagnostic-export"; import { defaultHostDisplayName, readDisplayName, @@ -100,7 +99,6 @@ import { import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer"; import { classifyHostViewerQualityEvidence, - freshViewerQualityEvidence, metricsFromQualityEvidence, nextViewerQualityEvidencePresentationExpiryAt, presentViewerQualityEvidence, @@ -279,9 +277,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const videoRef = useRef(null); const streamRef = useRef(null); const signalRef = useRef(null); - const pendingDiagnosticExportRef = useRef< - DiagnosticConnectionInput[] | null - >(null); const displayNameRef = useRef(displayName); const hostClientIdRef = useRef(null); const viewerPasswordActionRef = useRef(undefined); @@ -328,46 +323,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ) ?? null, [participantPresence], ); - const diagnosticConnections: DiagnosticConnectionInput[] = []; - if (sfuPublisherSnapshot) { - diagnosticConnections.push({ - scope: "host-sfu", - route: "sfu", - direction: "send", - metrics: sfuPublisherSnapshot.metrics, - }); - } - for (const viewer of viewers) { - const snapshot = - viewer.upstream.kind === "peer" && - viewer.upstream.peerId === - (hostPresence?.peerId ?? hostPeerIdRef.current) - ? peerSnapshots.get(viewer.peerId) - : undefined; - if (snapshot && hasPeerRouteEvidence(snapshot)) { - diagnosticConnections.push({ - scope: "viewer-edge", - route: "p2p", - direction: "send", - connectionState: snapshot.connectionState, - iceConnectionState: snapshot.iceConnectionState, - metrics: snapshot.metrics, - }); - } - const presentation = viewerQualityEvidence.get(viewer.peerId); - const evidence = freshViewerQualityEvidence(presentation); - if ( - viewer.upstream.kind === "peer" && - evidence?.parentPeerId === viewer.upstream.peerId - ) { - diagnosticConnections.push({ - scope: "viewer-edge", - route: "p2p", - direction: "receive", - metrics: metricsFromQualityEvidence(evidence), - }); - } - } const selectedQualityProfileId = useMemo( () => matchingQualityProfileId(qualitySettings), [qualitySettings], @@ -429,7 +384,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { activeHostChildPeerIdsRef.current = []; endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN; hostPeerIdRef.current = null; - pendingDiagnosticExportRef.current = null; viewerQualityEvidenceTimersRef.current.forEach((timer) => window.clearTimeout(timer), ); @@ -536,7 +490,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { signal.stop(); } signalRef.current = null; - pendingDiagnosticExportRef.current = null; shareGenerationRef.current = null; peersRef.current.forEach((peer) => peer.dispose()); peersRef.current.clear(); @@ -1291,13 +1244,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setParticipantPresence(message.viewers); return; } - if (message.type === "route-diagnostic-snapshot") { - const connections = pendingDiagnosticExportRef.current; - if (!connections) return; - pendingDiagnosticExportRef.current = null; - downloadDiagnosticReport("host", connections, message.snapshot); - return; - } if (message.type === "pause-sharing-source") { if (message.shareGeneration !== shareGenerationRef.current) { return; @@ -1541,9 +1487,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { isCurrentGeneration(generation) && signalRef.current === signal ) { - if (status !== "connected") { - pendingDiagnosticExportRef.current = null; - } setSignalStatus(status); } }, @@ -1838,7 +1781,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setCopied(false); }, 1_500); } catch { - setNotice("无法写入剪贴板,请手动复制邀请链接"); + setNotice("无法复制邀请链接,请稍后重试"); } } @@ -2178,30 +2121,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { { - if (pendingDiagnosticExportRef.current) return; - const connections = diagnosticConnections.map( - (connection) => ({ - ...connection, - metrics: { ...connection.metrics }, - }), - ); - pendingDiagnosticExportRef.current = connections; - if ( - !signalRef.current?.send({ - type: "request-route-diagnostic", - }) - ) { - pendingDiagnosticExportRef.current = null; - downloadDiagnosticReport( - "host", - connections, - null, - ); - } - } - : undefined} />
@@ -2396,40 +2315,58 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {room && (
- 邀请链接 - - {room.inviteUrl ?? "当前没有有效邀请,请更新生成新链接"} - -
+
+
+ 邀请链接 + + {room.inviteUrl ? "可用" : "已撤销"} + +
+
+ + +
+
+
+ + {room.inviteUrl ?? "当前没有有效邀请链接"} + - -
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 616f81c..0f493a1 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -1,5 +1,4 @@ import { - Download, KeyRound, LoaderCircle, Maximize2, @@ -41,17 +40,11 @@ import { readDisplayName, saveDisplayName } from "../lib/display-name"; import { clearViewerGrant, getStableClientId } from "../lib/session"; import { SignalingClient } from "../lib/signaling"; import { labelParticipantSnapshot } from "../lib/viewer-presence"; -import { - downloadDiagnosticReport, - type DiagnosticConnectionInput, - type ViewerDiagnosticInput, -} from "../lib/diagnostic-export"; import { DecodedFrameStallDetector } from "../media/decoded-frame-stall"; import type { QualitySettings } from "../media/quality"; import { relayCapacityMessageForBrowser } from "../media/relay-capability"; import { SfuStandbyPrewarmer } from "../media/sfu-standby-prewarmer"; import { - freshViewerQualityEvidence, metricsFromQualityEvidence, nextViewerQualityEvidencePresentationExpiryAt, presentViewerQualityEvidence, @@ -223,57 +216,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { routePresentation.evidence?.connectionState ?? (hostOnline ? "routing" : "waiting"); const routeMetrics = routePresentation.evidence?.metrics ?? null; - const diagnosticConnections: DiagnosticConnectionInput[] = []; - if (routePresentation.route && routeMetrics) { - diagnosticConnections.push({ - scope: "upstream", - route: routePresentation.route, - direction: "receive", - connectionState: routePresentation.evidence?.connectionState, - iceConnectionState: peerSnapshot && routePresentation.evidence === peerSnapshot - ? peerSnapshot.iceConnectionState - : null, - metrics: routeMetrics, - }); - } - if (relaySnapshot) { - diagnosticConnections.push({ - scope: "relay-edge", - route: "p2p", - direction: "send", - connectionState: relaySnapshot.connectionState, - iceConnectionState: relaySnapshot.iceConnectionState, - metrics: relaySnapshot.metrics, - }); - } - const freshRelayChildEvidence = freshViewerQualityEvidence( - relayChildEvidence, - ); - if (freshRelayChildEvidence) { - diagnosticConnections.push({ - scope: "relay-edge", - route: "p2p", - direction: "receive", - metrics: metricsFromQualityEvidence(freshRelayChildEvidence), - }); - } - const viewerDiagnosticState: ViewerDiagnosticInput = { - authenticated: presentationState.revision !== null, - stage: presentation.stage, - revision: presentationState.revision, - failureCode: presentation.failureCode, - signalState: presentationState.signal, - hostState: presentationState.host, - routeKind: - assignedRoute === null - ? "none" - : routeKindFromAssignment(assignedRoute.upstream), - frameProof: presentation.hasCurrentFrame - ? "current" - : presentation.hasRetainedFrame - ? "previous" - : "none", - }; function bindRemoteStream(stream: MediaStream, revision: number): void { const videoTrackKey = stream @@ -1420,6 +1362,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { [ "AUTH_REQUIRED", "INVALID_TOKEN", + "ROOM_NOT_FOUND", "ROOM_ACCESS_DENIED", "ROOM_EXPIRED", "ROOM_FULL", @@ -1433,6 +1376,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { const failure = hadAuthenticated && (message.code === "INVALID_TOKEN" || + message.code === "ROOM_NOT_FOUND" || message.code === "ROOM_ACCESS_DENIED") ? "ROOM_LOST" : viewerFailureFromServerCode(message.code); @@ -1699,6 +1643,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {

{codeOnlyDenied ? "请使用分享者提供的邀请链接,或尝试房间密码。" + : presentationState.failure === "ROOM_NOT_FOUND" + ? "请确认房间号,或向分享者获取新的邀请链接。" : viewerGrant ? "请向分享者获取新的邀请链接。" : "请检查入口后重试。"} @@ -1759,21 +1705,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { 刷新页面 )} - )} @@ -2027,13 +1958,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { - downloadDiagnosticReport( - "viewer", - diagnosticConnections, - viewerDiagnosticState, - ) - } /> {routePresentation.evidence === peerSnapshot && peerSnapshot?.error && ( @@ -2122,6 +2046,8 @@ function viewerFailureFromServerCode( code: Extract["code"], ): ViewerFailureCode | null { switch (code) { + case "ROOM_NOT_FOUND": + return "ROOM_NOT_FOUND"; case "ROOM_ACCESS_DENIED": return "ROOM_ACCESS_DENIED"; case "INVALID_TOKEN": diff --git a/src/client/pages/host-page-notices.ts b/src/client/pages/host-page-notices.ts index b0fc0c5..d231b93 100644 --- a/src/client/pages/host-page-notices.ts +++ b/src/client/pages/host-page-notices.ts @@ -24,6 +24,7 @@ const HOST_SERVER_ERROR_NOTICE: Record = { AUTH_REQUIRED: "站点访问已失效,请重新验证", INVALID_MESSAGE: "页面版本已更新,请刷新后重试", INVALID_TOKEN: "分享凭证已失效,请重新创建房间", + ROOM_NOT_FOUND: "房间不存在或已过期", ROOM_ACCESS_DENIED: "当前操作没有权限", ROOM_EXPIRED: "房间已过期,请重新创建", ROOM_FULL: "房间已满", diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 2f6766d..a0aa837 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -27,6 +27,7 @@ import { captureMetrics, collectConnectionMetricsFromReport, createStatsAccumulator, + mergeStatsReports, type StatsAccumulator, } from "../webrtc/stats"; import { sfuRoomConnectOptions } from "./connection-options"; @@ -67,8 +68,11 @@ interface PublishedAudioConfiguration { interface PublisherStatsIdentity { video: PublishedTrack; - sender: RTCRtpSender; - rawTrack: MediaStreamTrack; + videoSender: RTCRtpSender; + videoTrack: MediaStreamTrack; + audio: PublishedTrack | null; + audioSender: RTCRtpSender | null; + audioTrack: MediaStreamTrack | null; accumulator: StatsAccumulator; } @@ -885,23 +889,37 @@ export class SfuPublisher { ) { return; } - const sender = video.publication.videoTrack?.sender; - if (!sender || sender.track !== video.rawTrack) { + const videoSender = video.publication.videoTrack?.sender; + const audio = this.audio; + const audioSender = audio?.publication.audioTrack?.sender ?? null; + if ( + !videoSender || + videoSender.track !== video.rawTrack || + (audio !== null && + (!audioSender || audioSender.track !== audio.rawTrack)) + ) { this.resetStatsIdentity(); return; } + const audioTrack = audio?.rawTrack ?? null; let identity = this.statsIdentity; if ( !identity || identity.video !== video || - identity.sender !== sender || - identity.rawTrack !== video.rawTrack + identity.videoSender !== videoSender || + identity.videoTrack !== video.rawTrack || + identity.audio !== audio || + identity.audioSender !== audioSender || + identity.audioTrack !== audioTrack ) { this.resetStatsIdentity(); identity = { video, - sender, - rawTrack: video.rawTrack, + videoSender, + videoTrack: video.rawTrack, + audio, + audioSender, + audioTrack, accumulator: createStatsAccumulator(), }; this.statsIdentity = identity; @@ -911,14 +929,26 @@ export class SfuPublisher { } this.statsInFlight = identity; try { - const report = await identity.sender.getStats(); + const report = mergeStatsReports( + await Promise.all([ + identity.videoSender.getStats(), + identity.audioSender?.getStats(), + ]), + ); if ( + !report || this.state !== "active" || this.statsIdentity !== identity || this.video !== identity.video || - identity.video.rawTrack !== identity.rawTrack || - identity.video.publication.videoTrack?.sender !== identity.sender || - identity.sender.track !== identity.rawTrack + identity.video.rawTrack !== identity.videoTrack || + identity.video.publication.videoTrack?.sender !== + identity.videoSender || + identity.videoSender.track !== identity.videoTrack || + this.audio !== identity.audio || + (identity.audio?.rawTrack ?? null) !== identity.audioTrack || + (identity.audio?.publication.audioTrack?.sender ?? null) !== + identity.audioSender || + (identity.audioSender?.track ?? null) !== identity.audioTrack ) { return; } @@ -927,9 +957,13 @@ export class SfuPublisher { report, "send", identity.accumulator, - { trackIdentifier: identity.rawTrack.id, rid: "h" }, + { + trackIdentifier: identity.videoTrack.id, + rid: "h", + audioTrackIdentifier: identity.audioTrack?.id ?? null, + }, ), - ...captureMetrics(identity.rawTrack), + ...captureMetrics(identity.videoTrack), }; this.events.onStats?.(metrics); } catch { diff --git a/src/client/styles.css b/src/client/styles.css index 2f3f4cc..c43ea0c 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -779,7 +779,7 @@ h3 { .invite-bar { display: grid; - grid-template-columns: minmax(280px, 3fr) minmax(260px, 2fr); + grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); min-width: 0; gap: 12px; align-items: start; @@ -800,16 +800,50 @@ h3 { border-left: 1px solid var(--border); } -.invite-actions { +.invite-heading, +.invite-heading-copy, +.invite-icon-actions { display: flex; - flex-wrap: wrap; + min-width: 0; + align-items: center; +} + +.invite-heading { + min-height: 40px; + justify-content: space-between; + gap: 10px; +} + +.invite-heading-copy { gap: 6px; +} + +.invite-icon-actions { + flex: 0 0 auto; + gap: 6px; +} + +.invite-icon-action.is-danger { + color: var(--red); +} + +.invite-copy-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + gap: 6px; + align-items: start; margin-top: 8px; } -.invite-action { - min-height: 34px; - padding: 6px 9px; +.invite-copy-action { + min-height: 40px; + white-space: nowrap; +} + +.invite-status { + color: var(--muted); + font-size: 12px; } .room-entry-heading { @@ -855,10 +889,6 @@ h3 { gap: 12px; } -.access-diagnostic-action { - justify-self: end; -} - .viewer-password-control .input-with-icon { min-width: 0; } @@ -868,19 +898,24 @@ h3 { width: 100%; } -.invite-primary .field-label, .room-entry-heading .field-label { margin-bottom: 3px; } .invite-url { - display: block; - overflow: hidden; + display: flex; + min-width: 0; + min-height: 40px; + align-items: center; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 5px; color: var(--text); + background: var(--surface-raised); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; + white-space: normal; } .viewer-panel { @@ -1415,6 +1450,17 @@ h3 { .viewer-stats .stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .invite-bar { + grid-template-columns: minmax(0, 1fr); + } + + .room-entry-policy { + padding-top: 12px; + padding-left: 0; + border-top: 1px solid var(--border); + border-left: 0; + } } @media (max-width: 520px) { @@ -1469,17 +1515,6 @@ h3 { white-space: normal; } - .invite-bar { - grid-template-columns: minmax(0, 1fr); - } - - .room-entry-policy { - padding-top: 12px; - padding-left: 0; - border-top: 1px solid var(--border); - border-left: 0; - } - .viewer-password-control { grid-template-columns: minmax(0, 1fr) 40px 40px; } diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 6ea86b8..1df0abe 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -459,7 +459,12 @@ export class HostPeer { return; } const captureTrack = this.videoSender?.track ?? null; - if (!captureTrack || this.stream.getVideoTracks()[0] !== captureTrack) { + const captureAudioTrack = this.audioSender?.track ?? null; + if ( + !captureTrack || + this.stream.getVideoTracks()[0] !== captureTrack || + (this.stream.getAudioTracks()[0] ?? null) !== captureAudioTrack + ) { return; } this.statsInFlight = true; @@ -469,7 +474,10 @@ export class HostPeer { this.connection, "send", statsAccumulator, - { trackIdentifier: captureTrack.id }, + { + trackIdentifier: captureTrack.id, + audioTrackIdentifier: captureAudioTrack?.id ?? null, + }, ); const capture = captureMetrics(captureTrack); const metrics = { ...(await metricsPromise), ...capture }; @@ -479,6 +487,8 @@ export class HostPeer { this.statsAccumulator !== statsAccumulator || this.videoSender?.track !== captureTrack || this.stream.getVideoTracks()[0] !== captureTrack || + (this.audioSender?.track ?? null) !== captureAudioTrack || + (this.stream.getAudioTracks()[0] ?? null) !== captureAudioTrack || (metrics.trackIdentifier !== null && metrics.trackIdentifier !== captureTrack.id) ) { diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts index 3ecaff4..99f370a 100644 --- a/src/client/webrtc/stats.ts +++ b/src/client/webrtc/stats.ts @@ -53,6 +53,7 @@ export interface StatsAccumulator { export interface StatsMediaSelector { trackIdentifier: string | null; rid?: string | null; + audioTrackIdentifier?: string | null; } export function captureMetrics( @@ -679,7 +680,17 @@ export function collectConnectionMetricsFromReport( const codecEvidence = deriveCodecEvidence(linkedCodec); const mediaSource = direction === "send" ? mediaSourceRecord(report, media) : null; - const audio = mediaRecord(report, direction, null, "audio"); + const audio = + selector?.audioTrackIdentifier === null + ? null + : mediaRecord( + report, + direction, + selector?.audioTrackIdentifier === undefined + ? null + : { trackIdentifier: selector.audioTrackIdentifier }, + "audio", + ); const audioTransport = transportRecord(report, audio); const audioRemoteInbound = direction === "send" diff --git a/src/server/app.ts b/src/server/app.ts index b3fef50..0ccad68 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -393,7 +393,6 @@ async function handleRequest( hostToken: room.hostToken, inviteUrl: inviteUrl.toString(), codeEntryPolicy: room.codeEntryPolicy, - viewerGrantExpiresAt: room.viewerGrantExpiresAt, expiresAt: room.expiresAt, }; sendJson(response, 201, responseBody); diff --git a/src/server/room-store.ts b/src/server/room-store.ts index 598798b..edefa1d 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -7,7 +7,6 @@ import { import { MAX_VIEWERS_PER_ROOM_LIMIT, - ROOM_CODE_LENGTH, viewerGrantSchema, viewerPasswordSchema, type CodeEntryPolicy, @@ -16,12 +15,12 @@ import { export type RoomStoreErrorCode = | "INVALID_TOKEN" + | "ROOM_NOT_FOUND" | "ROOM_EXPIRED" | "ROOM_FULL" | "HOST_ALREADY_CONNECTED" | "ROOM_LIMIT"; -const VIEWER_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1_000; const ROOM_CODE_COUNT = 9_000; const ROOM_CODE_FIRST = 1_000; const VIEWER_PASSWORD_SALT_BYTES = 16; @@ -58,7 +57,6 @@ interface Room { roomId: string; hostTokenDigest: Buffer; viewerGrantDigest: Buffer; - viewerGrantExpiresAtMs: number | null; viewerPasswordMaterial: Buffer | null; viewerAuthorizationGeneration: string; codeEntryPolicy: CodeEntryPolicy; @@ -73,7 +71,6 @@ export interface CreatedRoom { hostToken: string; codeEntryPolicy: CodeEntryPolicy; viewerGrant: string | null; - viewerGrantExpiresAt: string | null; expiresAt: string | null; } @@ -136,7 +133,6 @@ export interface RevokedViewer { export interface ViewerGrantUpdate { viewerGrant: string | null; - viewerGrantExpiresAt: string | null; viewerAuthorizationGeneration: string; previousViewerAuthorizationGeneration: string; revokedViewers: readonly RevokedViewer[]; @@ -204,9 +200,6 @@ export class RoomStore { } const createdAtMs = this.now(); - const viewerGrantExpiresAtMs = toEpochSecondsMs( - createdAtMs + VIEWER_GRANT_TTL_MS, - ); const viewerPasswordMaterial = roomPassword ? await this.createViewerPasswordMaterial(roomPassword) : null; @@ -220,17 +213,13 @@ export class RoomStore { throw new Error("Host token random source must return 32 bytes"); } const hostToken = hostTokenBytes.toString("base64url"); - const viewerGrant = this.createViewerGrant( - roomId, - viewerGrantExpiresAtMs, - ); + const viewerGrant = this.createViewerGrant(); const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); this.rooms.set(roomId, { roomId, hostTokenDigest: digest(hostToken), viewerGrantDigest: digest(viewerGrant), - viewerGrantExpiresAtMs, viewerPasswordMaterial, viewerAuthorizationGeneration, codeEntryPolicy, @@ -244,7 +233,6 @@ export class RoomStore { hostToken, codeEntryPolicy, viewerGrant, - viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs), expiresAt: formatExpiresAt(createdAtMs + this.options.leaseMs), }; } catch (error) { @@ -254,7 +242,20 @@ export class RoomStore { } connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { - const room = this.getAvailableRoom(input.roomId); + let room: Room; + try { + room = this.getAvailableRoom(input.roomId); + } catch (error) { + if ( + input.role === "viewer" && + !input.viewerGrant && + error instanceof RoomStoreError && + (error.code === "INVALID_TOKEN" || error.code === "ROOM_EXPIRED") + ) { + throw new RoomStoreError("ROOM_NOT_FOUND"); + } + throw error; + } if (input.role === "host") { if (!verifyDigest(input.token, room.hostTokenDigest)) { throw new RoomStoreError("INVALID_TOKEN"); @@ -322,7 +323,7 @@ export class RoomStore { currentRoom = this.getAvailableRoom(input.roomId); } catch (error) { if (error instanceof RoomStoreError) { - throw new RoomStoreError("INVALID_TOKEN"); + throw new RoomStoreError("ROOM_NOT_FOUND"); } throw error; } @@ -409,14 +410,7 @@ export class RoomStore { } const previousViewerAuthorizationGeneration = room.viewerAuthorizationGeneration; - const viewerGrantExpiresAtMs = - action === "rotate" - ? toEpochSecondsMs(this.now() + VIEWER_GRANT_TTL_MS) - : null; - const viewerGrant = - viewerGrantExpiresAtMs === null - ? null - : this.createViewerGrant(room.roomId, viewerGrantExpiresAtMs); + const viewerGrant = action === "rotate" ? this.createViewerGrant() : null; const revokedViewers = [...room.viewers.values()] .filter((viewer) => viewer.admittedBy === "grant") @@ -429,7 +423,6 @@ export class RoomStore { : this.randomDigest(); const viewerAuthorizationGeneration = this.newAuthorizationGeneration(); room.viewerGrantDigest = viewerGrantDigest; - room.viewerGrantExpiresAtMs = viewerGrantExpiresAtMs; room.viewerAuthorizationGeneration = viewerAuthorizationGeneration; for (const [clientId, viewer] of room.viewers) { if (viewer.admittedBy === "grant") { @@ -438,7 +431,6 @@ export class RoomStore { } return { viewerGrant, - viewerGrantExpiresAt: formatExpiresAt(viewerGrantExpiresAtMs), viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, previousViewerAuthorizationGeneration, revokedViewers, @@ -702,24 +694,15 @@ export class RoomStore { if (!grant || !viewerGrantSchema.safeParse(grant).success) { return false; } - const parsed = parseViewerGrant(grant); - return Boolean( - parsed && - parsed.roomId === room.roomId && - parsed.expiresAtMs > this.now() && - room.viewerGrantExpiresAtMs !== null && - parsed.expiresAtMs <= room.viewerGrantExpiresAtMs && - verifyDigest(grant, room.viewerGrantDigest), - ); + return verifyDigest(grant, room.viewerGrantDigest); } - private createViewerGrant(roomId: string, expiresAtMs: number): string { - const expiresAtSeconds = Math.floor(expiresAtMs / 1_000); - const secret = this.random(32); - if (secret.byteLength !== 32) { - throw new Error("Viewer grant random source must return 32 bytes"); + private createViewerGrant(): string { + const secret = this.random(16); + if (secret.byteLength !== 16) { + throw new Error("Viewer grant random source must return 16 bytes"); } - return `g1.${roomId}.${expiresAtSeconds}.${secret.toString("base64url")}`; + return secret.toString("base64url"); } private async createViewerPasswordMaterial( @@ -764,18 +747,10 @@ function roomIsExpired(room: Room, nowMs: number): boolean { ); } -function isRoomCode(value: string): boolean { - return value.length === ROOM_CODE_LENGTH && /^[1-9]\d{3}$/.test(value); -} - function formatExpiresAt(expiresAtMs: number | null): string | null { return expiresAtMs === null ? null : new Date(expiresAtMs).toISOString(); } -function toEpochSecondsMs(value: number): number { - return Math.floor(value / 1_000) * 1_000; -} - function findViewerByPeerId( room: Room, peerId: string, @@ -876,23 +851,3 @@ const viewerPasswordKdfGate = new AsyncGate( VIEWER_PASSWORD_KDF_CONCURRENCY, VIEWER_PASSWORD_KDF_PENDING_LIMIT, ); - -function parseViewerGrant( - value: string, -): { roomId: string; expiresAtMs: number } | null { - const [version, roomId, expiresAtText, secret, ...extra] = value.split("."); - if ( - version !== "g1" || - extra.length > 0 || - !isRoomCode(roomId ?? "") || - !/^[1-9]\d{0,12}$/.test(expiresAtText ?? "") || - !/^[A-Za-z0-9_-]{43}$/.test(secret ?? "") - ) { - return null; - } - const expiresAtSeconds = Number(expiresAtText); - if (!Number.isSafeInteger(expiresAtSeconds)) { - return null; - } - return { roomId: roomId!, expiresAtMs: expiresAtSeconds * 1_000 }; -} diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 733071e..9cd62e9 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -1151,7 +1151,6 @@ export class SignalingServer { type: "viewer-grant-updated", viewerAuthorizationGeneration: update.viewerAuthorizationGeneration, inviteUrl, - viewerGrantExpiresAt: update.viewerGrantExpiresAt, }); } @@ -2075,6 +2074,8 @@ function authenticationErrorMessage(code: ErrorCode): string { switch (code) { case "ROOM_ACCESS_DENIED": return "Room access denied"; + case "ROOM_NOT_FOUND": + return "Room not found or expired"; case "ROOM_EXPIRED": return "Room has expired"; case "ROOM_FULL": @@ -2099,9 +2100,17 @@ function authenticationErrorCode( return error.code; } if (!message.viewerGrant) { + if ( + error.code === "ROOM_NOT_FOUND" || + error.code === "ROOM_EXPIRED" + ) { + return "ROOM_NOT_FOUND"; + } return "ROOM_ACCESS_DENIED"; } - return error.code === "ROOM_EXPIRED" ? "INVALID_TOKEN" : error.code; + return error.code === "ROOM_EXPIRED" || error.code === "ROOM_NOT_FOUND" + ? "INVALID_TOKEN" + : error.code; } function rejectUpgrade(socket: Duplex, status: number, message: string): void { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 42f7c5e..d655e43 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v11"; +export const SIGNALING_PROTOCOL = "screener-v12"; export const SIGNAL_CLOSE_CODES = { sessionReplaced: 4001, clientReconnect: 4002, @@ -116,9 +116,8 @@ const tokenSchema = z export const viewerGrantSchema = z .string() - .min(50) - .max(96) - .regex(/^g1\.[1-9]\d{3}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/); + .length(22) + .regex(/^[A-Za-z0-9_-]{21}[AQgw]$/); export const viewerPasswordSchema = z .string() @@ -742,6 +741,7 @@ const errorCodeSchema = z.enum([ "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", + "ROOM_NOT_FOUND", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", @@ -949,7 +949,6 @@ export const serverMessageSchema = z.union([ type: z.literal("viewer-grant-updated"), viewerAuthorizationGeneration: opaqueIdSchema, inviteUrl: z.string().url().max(2048).nullable(), - viewerGrantExpiresAt: z.string().datetime().nullable(), }) .strict(), z @@ -987,7 +986,6 @@ export const createRoomResponseSchema = z hostToken: tokenSchema, inviteUrl: z.string().url().max(2048), codeEntryPolicy: codeEntryPolicySchema, - viewerGrantExpiresAt: z.string().datetime().nullable(), expiresAt: z.string().datetime().nullable(), }) .strict(); diff --git a/tests/audio-stats.test.ts b/tests/audio-stats.test.ts new file mode 100644 index 0000000..18a2503 --- /dev/null +++ b/tests/audio-stats.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + collectConnectionMetricsFromReport, + createStatsAccumulator, +} from "../src/client/webrtc/stats.ts"; + +function outboundReport( + timestamp: number, + audioABytes: number, + audioBBytes: number, +): RTCStatsReport { + const record = ( + id: string, + type: string, + values: Record, + ): [string, Record] => [ + id, + { id, type, timestamp, ...values }, + ]; + return new Map>([ + record("transport", "transport", {}), + record("video-out", "outbound-rtp", { + kind: "video", + transportId: "transport", + mediaSourceId: "video-source", + bytesSent: timestamp * 10, + framesEncoded: timestamp / 20, + }), + record("video-source", "media-source", { + kind: "video", + trackIdentifier: "video-current", + }), + record("audio-a-out", "outbound-rtp", { + kind: "audio", + ssrc: 101, + transportId: "transport", + mediaSourceId: "audio-a-source", + codecId: "audio-a-codec", + bytesSent: audioABytes, + }), + record("audio-a-source", "media-source", { + kind: "audio", + trackIdentifier: "audio-a", + }), + record("audio-a-codec", "codec", { + transportId: "transport", + mimeType: "audio/opus", + sdpFmtpLine: "stereo=1", + }), + record("audio-b-out", "outbound-rtp", { + kind: "audio", + ssrc: 202, + transportId: "transport", + mediaSourceId: "audio-b-source", + codecId: "audio-b-codec", + bytesSent: audioBBytes, + }), + record("audio-b-source", "media-source", { + kind: "audio", + trackIdentifier: "audio-b", + }), + record("audio-b-codec", "codec", { + transportId: "transport", + mimeType: "audio/opus", + sdpFmtpLine: "stereo=0", + }), + ]) as unknown as RTCStatsReport; +} + +describe("audio stats selection", () => { + it("binds exact audio tracks and resets the baseline across identity and absence", () => { + const accumulator = createStatsAccumulator(); + const sample = ( + timestamp: number, + audioABytes: number, + audioBBytes: number, + audioTrackIdentifier: string | null, + ) => + collectConnectionMetricsFromReport( + outboundReport(timestamp, audioABytes, audioBBytes), + "send", + accumulator, + { trackIdentifier: "video-current", audioTrackIdentifier }, + ); + + expect(sample(1_000, 10_000, 100_000, "audio-a")).toMatchObject({ + audioBitrateKbps: null, + audioCodecParameters: "stereo=1", + }); + expect(sample(3_000, 50_000, 300_000, "audio-a")).toMatchObject({ + audioBitrateKbps: 160, + audioCodecParameters: "stereo=1", + }); + expect(sample(5_000, 90_000, 340_000, "audio-b")).toMatchObject({ + audioBitrateKbps: null, + audioCodecParameters: "stereo=0", + }); + expect(sample(7_000, 130_000, 380_000, "audio-b")).toMatchObject({ + audioBitrateKbps: 160, + audioCodecParameters: "stereo=0", + }); + expect(sample(9_000, 170_000, 420_000, null)).toMatchObject({ + audioBitrateKbps: null, + audioCodec: null, + audioCodecParameters: null, + }); + expect(sample(11_000, 210_000, 460_000, "audio-b")).toMatchObject({ + audioBitrateKbps: null, + audioCodecParameters: "stereo=0", + }); + }); +}); diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 2f57eeb..79d1502 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -20,6 +20,7 @@ import { readViewerGrant, readViewerRoute, replaceViewerInvite, + roomRouteForExplicitEntry, roomRouteFromInput, writeHostRoom, } from "../src/client/lib/session.ts"; @@ -269,9 +270,8 @@ describe("client session identity", () => { const room = { roomId: "1234", hostToken: "a".repeat(32), - inviteUrl: `https://share.test/r/1234#v=g1.1234.1893456000.${"b".repeat(43)}`, + inviteUrl: `https://share.test/r/1234#v=${"b".repeat(21)}A`, codeEntryPolicy: "open" as const, - viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z", expiresAt: null, }; @@ -291,7 +291,7 @@ describe("client session identity", () => { inviteUrl: "https://share.test/r/1234", }), ); - expect([...values.values()].join(" ")).not.toContain("g1.1234."); + expect([...values.values()].join(" ")).not.toContain(`${"b".repeat(21)}A`); clearHostRoom(); expect(readHostRoom()).toBeNull(); }); @@ -353,7 +353,7 @@ describe("client session identity", () => { it("consumes a Viewer grant fragment once into room-scoped session storage", () => { const values = new Map(); const replaceState = vi.fn(); - const grant = `g1.1234.1893456000.${"c".repeat(43)}`; + const grant = `${"c".repeat(21)}g`; vi.stubGlobal("window", { location: new URL(`https://share.test/r/1234#v=${grant}`), history: { state: { navigation: 1 }, replaceState }, @@ -376,7 +376,7 @@ describe("client session identity", () => { it("fails malformed Viewer fragments closed without leaking across rooms", () => { const values = new Map(); - const oldGrant = `g1.1234.1893456000.${"d".repeat(43)}`; + const oldGrant = `${"d".repeat(21)}w`; values.set("screener:viewer-grant:1234", oldGrant); vi.stubGlobal("window", { location: new URL("https://share.test/r/1234#v=malformed"), @@ -390,12 +390,11 @@ describe("client session identity", () => { expect(readViewerRoute()).toEqual({ roomId: "1234" }); expect(readViewerGrant("1234")).toBeNull(); - replaceViewerInvite( - "8", - `https://share.test/r/8#v=g1.1234.1893456000.${"e".repeat(43)}`, - ); + replaceViewerInvite("1234", `https://share.test/r/1234#v=${"e".repeat(21)}Q`); + expect(readViewerGrant("1234")).toBe(`${"e".repeat(21)}Q`); + replaceViewerInvite("1234", `https://share.test/r/5678#v=${"f".repeat(21)}A`); expect(readViewerGrant("1234")).toBeNull(); - expect(readViewerGrant("8")).toBeNull(); + expect(readViewerGrant("5678")).toBeNull(); }); it("persists a rotated Viewer invitation across reload and clears it on revoke", () => { @@ -407,8 +406,8 @@ describe("client session identity", () => { removeItem: (key: string) => values.delete(key), }, }); - const firstGrant = `g1.1234.1893456000.${"g".repeat(43)}`; - const rotatedGrant = `g1.1234.1893456000.${"h".repeat(43)}`; + const firstGrant = `${"g".repeat(21)}g`; + const rotatedGrant = `${"h".repeat(21)}w`; replaceViewerInvite("1234", `https://share.test/r/1234#v=${firstGrant}`); replaceViewerInvite("1234", `https://share.test/r/1234#v=${rotatedGrant}`); @@ -429,8 +428,8 @@ describe("client session identity", () => { removeItem: (key: string) => values.delete(key), }, }); - const oldInvite = `https://share.test/r/1234#v=g1.1234.1893456000.${"i".repeat(43)}`; - const rotatedInvite = `https://share.test/r/1234#v=g1.1234.1893456000.${"j".repeat(43)}`; + const oldInvite = `https://share.test/r/1234#v=${"i".repeat(21)}Q`; + const rotatedInvite = `https://share.test/r/1234#v=${"j".repeat(21)}A`; replaceViewerInvite("1234", oldInvite); const activeRoom = { roomId: "1234", @@ -468,7 +467,7 @@ describe("client session identity", () => { ).toBeNull(); }); - it("removes an expired Viewer grant from room-scoped session storage", () => { + it("removes a legacy Viewer grant from room-scoped session storage", () => { const values = new Map([ ["screener:viewer-grant:1234", `g1.1234.1.${"f".repeat(43)}`], ]); @@ -581,9 +580,8 @@ describe("site access API", () => { const room = { roomId: "1234", hostToken: "c".repeat(32), - inviteUrl: `https://share.test/r/1234#v=g1.1234.1893456000.${"f".repeat(43)}`, + inviteUrl: `https://share.test/r/1234#v=${"f".repeat(21)}A`, codeEntryPolicy: "open", - viewerGrantExpiresAt: "2026-08-25T00:00:00.000Z", expiresAt: "2026-08-24T00:00:00.000Z", }; vi.stubGlobal( @@ -615,6 +613,25 @@ describe("room codes", () => { expect(roomRouteFromInput("0123")).toBeNull(); }); + it("drops only the stored grant for an explicit valid room-code entry", () => { + const values = new Map([ + ["screener:viewer-grant:1234", "AAAAAAAAAAAAAAAAAAAAAA"], + ["screener:viewer-grant:5678", "AAAAAAAAAAAAAAAAAAAAAA"], + ]); + vi.stubGlobal("window", { + sessionStorage: { + removeItem: (key: string) => values.delete(key), + }, + }); + + expect(roomRouteForExplicitEntry("1234")).toBe("/r/1234"); + expect(values.has("screener:viewer-grant:1234")).toBe(false); + expect(values.has("screener:viewer-grant:5678")).toBe(true); + + expect(roomRouteForExplicitEntry(" 5678 ")).toBeNull(); + expect(values.has("screener:viewer-grant:5678")).toBe(true); + }); + it("classifies routes without normalizing malformed room input", () => { expect(parseAppRoute("/")).toEqual({ kind: "host" }); expect(parseAppRoute("/join")).toEqual({ kind: "join" }); @@ -746,7 +763,7 @@ describe("client signaling recovery policy", () => { Object.defineProperty(authenticated, "data", { value: JSON.stringify({ type: "authenticated", - protocol: "screener-v11", + protocol: "screener-v12", role: "viewer", peerId: "viewer_12345678", roomExpiresAt: null, @@ -815,7 +832,7 @@ describe("client signaling recovery policy", () => { socket.dispatchEvent(new Event("open")); receive(socket, { type: "authenticated", - protocol: "screener-v11", + protocol: "screener-v12", role: "host", peerId: "host_12345678", roomExpiresAt: null, @@ -851,7 +868,7 @@ describe("client signaling recovery policy", () => { }); receive(sockets[1]!, { type: "authenticated", - protocol: "screener-v11", + protocol: "screener-v12", role: "host", peerId: "host_12345678", roomExpiresAt: null, @@ -936,7 +953,7 @@ describe("client signaling recovery policy", () => { socket.dispatchEvent(new Event("open")); receive(socket, { type: "authenticated", - protocol: "screener-v11", + protocol: "screener-v12", role: "host", peerId: "host_12345678", roomExpiresAt: null, @@ -1061,7 +1078,7 @@ describe("client signaling recovery policy", () => { sockets[0]!.dispatchEvent(new Event("open")); receive({ type: "authenticated", - protocol: "screener-v11", + protocol: "screener-v12", role: "viewer", peerId: "viewer_12345678", roomExpiresAt: null, @@ -1188,7 +1205,7 @@ describe("client signaling recovery policy", () => { JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])), ).toMatchObject({ type: "authenticate", - protocol: "screener-v11", + protocol: "screener-v12", }); const message = new Event("message"); Object.defineProperty(message, "data", { value: payload }); diff --git a/tests/connection-details-toggle.test.ts b/tests/connection-details-toggle.test.ts index 7d588bb..bd1e71a 100644 --- a/tests/connection-details-toggle.test.ts +++ b/tests/connection-details-toggle.test.ts @@ -4,25 +4,26 @@ import { describe, expect, it, vi } from "vitest"; import { ConnectionDetailsToggle } from "../src/client/components/ConnectionDetailsToggle.tsx"; -describe("ConnectionDetailsToggle diagnostic export", () => { - it("offers the familiar download action only with expanded exportable details", () => { +describe("ConnectionDetailsToggle", () => { + it("renders only the controlled connection-details checkbox", () => { const expanded = renderToStaticMarkup( createElement(ConnectionDetailsToggle, { checked: true, onChange: vi.fn(), - onExport: vi.fn(), }), ); const collapsed = renderToStaticMarkup( createElement(ConnectionDetailsToggle, { checked: false, onChange: vi.fn(), - onExport: vi.fn(), }), ); - expect(expanded).toContain("下载脱敏连接诊断"); - expect(expanded).toContain("lucide-download"); - expect(collapsed).not.toContain("下载脱敏连接诊断"); + expect(expanded).toContain("显示连接详情"); + expect(expanded).toContain('type="checkbox"'); + expect(expanded).toContain("checked=\"\""); + expect(expanded).not.toContain("button"); + expect(collapsed).not.toContain("checked=\"\""); + expect(collapsed).not.toContain("下载"); }); }); diff --git a/tests/diagnostic-export.test.ts b/tests/diagnostic-export.test.ts deleted file mode 100644 index a34b568..0000000 --- a/tests/diagnostic-export.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - createDiagnosticReport, - DIAGNOSTIC_SCHEMA_VERSION, - downloadDiagnosticReport, - summarizeRouteTiming, - type DiagnosticConnectionInput, - type ViewerDiagnosticInput, -} from "../src/client/lib/diagnostic-export.ts"; -import { EMPTY_METRICS } from "../src/client/types.ts"; - -describe("diagnostic export privacy boundary", () => { - it("exports a versioned allowlist and preserves unavailable values as null", () => { - const report = createDiagnosticReport( - "viewer", - [ - { - scope: "upstream", - route: "p2p", - direction: "receive", - connectionState: "connected", - iceConnectionState: "connected", - metrics: { - ...EMPTY_METRICS, - path: "direct", - iceProtocol: "udp", - localCandidateType: "srflx", - remoteCandidateType: "host", - bitrateKbps: 4_200, - framesPerSecond: 59.8, - codec: "video/VP8", - audioVideoPlayoutDeltaMs: -12.5, - candidatePairResponsesReceived: 18, - intervalCandidatePairResponsesReceived: 1, - candidatePairSampleWindowMs: 2_000, - }, - }, - ], - new Date("2026-08-22T12:00:00.000Z"), - ); - - expect(report.schemaVersion).toBe(DIAGNOSTIC_SCHEMA_VERSION); - expect(report.exportedAt).toBe("2026-08-22T12:00:00.000Z"); - expect(report.connections[0]).toMatchObject({ - scope: "upstream", - route: "p2p", - direction: "receive", - connectionState: "connected", - iceConnectionState: "connected", - metrics: { - iceProtocol: "udp", - localCandidateType: "srflx", - bitrateKbps: 4_200, - framesPerSecond: 59.8, - codec: "video/VP8", - audioVideoPlayoutDeltaMs: -12.5, - candidatePairResponsesReceived: 18, - intervalCandidatePairResponsesReceived: 1, - candidatePairSampleWindowMs: 2_000, - audioJitterMs: null, - }, - }); - }); - - it("cannot serialize addresses, identities, raw signaling, URLs, or credentials", () => { - const secrets = { - localCandidateAddress: "sensitive-local-address", - localCandidatePort: 50_000, - remoteCandidateAddress: "sensitive-remote-address", - remoteCandidatePort: 50_001, - selectedCandidatePairId: "sensitive-pair-id", - rtpStatsId: "sensitive-rtp-id", - rtpSsrc: 4_242, - rtpMid: "sensitive-mid", - rtpRid: "sensitive-rid", - trackIdentifier: "sensitive-track-id", - rawCandidate: "candidate-sensitive", - sdp: "sdp-sensitive", - privateUrl: "https://secret.example", - stunUrl: "stun:secret.example", - username: "username-sensitive", - credential: "credential-sensitive", - token: "token-sensitive", - errorText: "error-sensitive", - }; - const unsafeInput = { - scope: "viewer-edge", - route: "p2p", - direction: "send", - connectionState: "connected", - iceConnectionState: "connected", - peerId: "peer-sensitive", - clientId: "client-sensitive", - roomCode: "room-sensitive", - roomId: "room-id-sensitive", - name: "name-sensitive", - hostToken: "host-token-sensitive", - viewerGrant: "viewer-grant-sensitive", - roomPassword: "password-sensitive", - metrics: { ...EMPTY_METRICS, ...secrets }, - } as unknown as DiagnosticConnectionInput; - - const json = JSON.stringify(createDiagnosticReport("host", [unsafeInput])); - for (const value of [ - ...Object.values(secrets).map(String), - "peer-sensitive", - "client-sensitive", - "room-sensitive", - "room-id-sensitive", - "name-sensitive", - "host-token-sensitive", - "viewer-grant-sensitive", - "password-sensitive", - ]) { - expect(json).not.toContain(value); - } - for (const key of [ - "localCandidateAddress", - "localCandidatePort", - "remoteCandidateAddress", - "remoteCandidatePort", - "selectedCandidatePairId", - "rtpStatsId", - "rtpSsrc", - "rtpMid", - "rtpRid", - "trackIdentifier", - "peerId", - "clientId", - "roomCode", - "roomId", - "name", - "hostToken", - "viewerGrant", - "roomPassword", - "rawCandidate", - "sdp", - "turnUrl", - "stunUrl", - "username", - "credential", - "token", - "errorText", - ]) { - expect(json).not.toContain(`"${key}"`); - } - }); - - it("exports authenticated Viewer state even with zero connection metrics", () => { - const viewerState: ViewerDiagnosticInput = { - authenticated: true, - stage: "route-failed", - revision: 7, - failureCode: "ROUTE_EXHAUSTED", - signalState: "connected", - hostState: "online", - routeKind: "sfu", - frameProof: "none", - }; - const report = createDiagnosticReport( - "viewer", - [], - new Date("2026-08-24T12:00:00.000Z"), - viewerState, - ); - - expect(report.viewer).toEqual({ - stage: "route-failed", - revision: "changed", - failureCode: "ROUTE_EXHAUSTED", - signalState: "connected", - hostState: "online", - routeKind: "sfu", - frameProof: "none", - }); - expect(report.connections).toEqual([]); - }); - - it("limits unauthenticated room denial diagnostics to stage and code", () => { - const unsafe = { - authenticated: false, - stage: "access-denied", - revision: 42, - failureCode: "ROOM_ACCESS_DENIED", - signalState: "reconnecting", - hostState: "online", - routeKind: "p2p", - frameProof: "current", - roomId: "private-room", - viewerGrant: "private-grant", - rawMessage: "private-error", - } as unknown as ViewerDiagnosticInput; - const report = createDiagnosticReport( - "viewer", - [ - { - scope: "upstream", - route: "p2p", - direction: "receive", - metrics: { ...EMPTY_METRICS, bitrateKbps: 9_999 }, - }, - ], - new Date("2026-08-24T12:00:00.000Z"), - unsafe, - ); - - expect(report.viewer).toEqual({ - stage: "access-denied", - failureCode: "ROOM_ACCESS_DENIED", - }); - expect(report.connections).toEqual([]); - const json = JSON.stringify(report); - expect(json).not.toContain("private-"); - expect(json).not.toContain("revision"); - expect(json).not.toContain("signalState"); - expect(json).not.toContain("hostState"); - expect(json).not.toContain("routeKind"); - expect(json).not.toContain("frameProof"); - }); - - it("creates one local JSON Blob and revokes its URL after download", async () => { - let createdBlob: Blob | null = null; - const link = { href: "", download: "", hidden: false, click: vi.fn(), remove: vi.fn() }; - const append = vi.fn(); - const revokeObjectURL = vi.fn(); - vi.stubGlobal("document", { - createElement: vi.fn(() => link), - body: { append }, - }); - vi.stubGlobal("URL", { - createObjectURL: vi.fn((blob: Blob) => { - createdBlob = blob; - return "blob:diagnostic"; - }), - revokeObjectURL, - }); - vi.stubGlobal("window", { - setTimeout: (callback: () => void) => { - callback(); - return 1; - }, - }); - - try { - downloadDiagnosticReport("host", [{ - scope: "host-sfu", - route: "sfu", - direction: "send", - metrics: EMPTY_METRICS, - }]); - expect(link.click).toHaveBeenCalledOnce(); - expect(link.remove).toHaveBeenCalledOnce(); - expect(append).toHaveBeenCalledWith(link); - expect(link.download).toMatch(/^screener-diagnostics-host-.*\.json$/); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:diagnostic"); - expect(await createdBlob!.text()).toContain(`"schemaVersion": 2`); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("reports raw 20-Viewer timings with nearest-rank percentiles", () => { - const values = Array.from({ length: 20 }, (_, index) => - index % 5 === 0 ? null : (20 - index) * 10, - ); - - const expected = { - sampleCount: 16, - pendingCount: 4, - rawMs: [ - 10, 20, 30, 40, 60, 70, 80, 90, 110, 120, 130, 140, 160, 170, - 180, 190, - ], - p50Ms: 90, - p95Ms: 190, - maxMs: 190, - }; - expect(summarizeRouteTiming(values)).toEqual(expected); - const report = createDiagnosticReport( - "host", - [], - new Date("2026-08-24T12:00:00.000Z"), - { - children: values.map((finalMs, index) => ({ - ordinal: index + 1, - parent: { kind: "none" as const }, - effectiveCapacity: 0, - childCount: 0, - demandAgeMs: 200, - queueWaitMs: finalMs, - candidateStartMs: finalMs, - firstDecodedFrameMs: finalMs, - finalMs, - finalRoute: finalMs === null ? "waiting" as const : "direct" as const, - rejectionBucket: "none" as const, - })), - operation: null, - }, - ); - expect(report.route?.children).toHaveLength(20); - expect(report.routeTimingSummary?.finalMs).toEqual(expected); - expect(summarizeRouteTiming([null, null])).toEqual({ - sampleCount: 0, - pendingCount: 2, - rawMs: [], - p50Ms: null, - p95Ms: null, - maxMs: null, - }); - }); - - it("includes only sanitized route fields in a Host export", () => { - const route = { - children: [ - { - ordinal: 1, - parent: { kind: "host" as const }, - effectiveCapacity: 2, - childCount: 0, - demandAgeMs: 120, - queueWaitMs: 10, - candidateStartMs: 20, - firstDecodedFrameMs: 70, - finalMs: 70, - finalRoute: "direct" as const, - rejectionBucket: "none" as const, - }, - ], - operation: null, - }; - const report = createDiagnosticReport( - "host", - [], - new Date("2026-08-24T12:00:00.000Z"), - route, - ); - - expect(report.route).toEqual(route); - expect(report.routeTimingSummary).toMatchObject({ - queueWaitMs: { sampleCount: 1, pendingCount: 0, rawMs: [10] }, - finalMs: { p50Ms: 70, p95Ms: 70, maxMs: 70 }, - }); - expect( - createDiagnosticReport( - "viewer", - [], - new Date("2026-08-24T12:00:00.000Z"), - route, - ), - ).toMatchObject({ route: null, routeTimingSummary: null }); - }); -}); diff --git a/tests/host-page-notice.test.ts b/tests/host-page-notice.test.ts index 33b36cd..a4c19f9 100644 --- a/tests/host-page-notice.test.ts +++ b/tests/host-page-notice.test.ts @@ -13,6 +13,7 @@ describe("host error notices", () => { AUTH_REQUIRED: "站点访问已失效,请重新验证", INVALID_MESSAGE: "页面版本已更新,请刷新后重试", INVALID_TOKEN: "分享凭证已失效,请重新创建房间", + ROOM_NOT_FOUND: "房间不存在或已过期", ROOM_ACCESS_DENIED: "当前操作没有权限", ROOM_EXPIRED: "房间已过期,请重新创建", ROOM_FULL: "房间已满", diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index d4f3519..bea8541 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -308,6 +308,39 @@ function sendStatsReport({ ]) as unknown as RTCStatsReport; } +function withOutboundAudio( + report: RTCStatsReport, + tracks: readonly { + id: string; + trackIdentifier: string; + bytesSent: number; + }[], +): RTCStatsReport { + const merged = new Map( + report as unknown as Map>, + ); + for (const [index, track] of tracks.entries()) { + const sourceId = `${track.id}-source`; + merged.set(track.id, { + id: track.id, + type: "outbound-rtp", + timestamp: Number(merged.get("outbound-video")?.timestamp), + kind: "audio", + ssrc: index + 1, + mediaSourceId: sourceId, + bytesSent: track.bytesSent, + }); + merged.set(sourceId, { + id: sourceId, + type: "media-source", + timestamp: Number(merged.get("outbound-video")?.timestamp), + kind: "audio", + trackIdentifier: track.trackIdentifier, + }); + } + return merged as unknown as RTCStatsReport; +} + function createTrack(kind: "video" | "audio", id: string): MediaStreamTrack { return { id, kind } as MediaStreamTrack; } @@ -936,6 +969,85 @@ describe("HostPeer source replacement", () => { }); }); + it("samples only the current audio sender and excludes old audio after removal", async () => { + const video = createConfiguredVideoTrack("capture-video", 1920, 1080, 60); + const audio = createTrack("audio", "current-audio"); + const updates: PeerSnapshot[] = []; + const peer = createPeer( + createStream(video, audio), + (snapshot) => updates.push(snapshot), + ); + await expect(peer.start()).resolves.toBe(true); + const connection = FakePeerConnection.latest!; + const report = (timestamp: number, currentBytes: number, oldBytes: number) => + withOutboundAudio( + sendStatsReport({ + bytesSent: timestamp * 1_000, + framesEncoded: timestamp / 20, + timestamp, + qualityLimitationReason: "none", + trackIdentifier: video.id, + }), + [ + { + id: "current-audio-out", + trackIdentifier: audio.id, + bytesSent: currentBytes, + }, + { + id: "old-audio-out", + trackIdentifier: "old-audio", + bytesSent: oldBytes, + }, + ], + ); + connection.statsReports.push( + report(1_000, 10_000, 100_000), + report(3_000, 50_000, 300_000), + ); + + statsCallbacks[0]!(); + await vi.waitFor(() => + expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(1_000), + ); + statsCallbacks[0]!(); + await vi.waitFor(() => + expect(updates.at(-1)?.metrics.audioBitrateKbps).toBe(160), + ); + + const nextVideo = createConfiguredVideoTrack("next-video", 1280, 720, 30); + await expect( + peer.replaceStream(createStream(nextVideo, null)), + ).resolves.toBe(true); + connection.statsReports.push( + withOutboundAudio( + sendStatsReport({ + bytesSent: 4_000_000, + framesEncoded: 200, + timestamp: 4_000, + qualityLimitationReason: "none", + trackIdentifier: nextVideo.id, + }), + [ + { + id: "old-audio-out", + trackIdentifier: audio.id, + bytesSent: 400_000, + }, + ], + ), + ); + statsCallbacks[0]!(); + await vi.waitFor(() => + expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(4_000), + ); + expect(updates.at(-1)?.metrics).toMatchObject({ + trackIdentifier: nextVideo.id, + audioBitrateKbps: null, + audioCodec: null, + }); + }); + it.each([ { outcome: "succeeds", diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 6701098..4cb3f68 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -24,7 +24,7 @@ import { const token = "a".repeat(43); const roomId = "1234"; -const viewerGrant = `g1.${roomId}.1787076000.${"b".repeat(43)}`; +const viewerGrant = `${"b".repeat(21)}g`; const qualitySettings = { resolution: "1080p", maxFramerate: 60, @@ -71,7 +71,7 @@ const qualityEvidence = { } as const; describe("client signaling protocol", () => { - it("keeps executable senders outside the Browser-only v11 checkpoint", () => { + it("keeps executable senders outside the Browser-only v12 checkpoint", () => { const nativeWire = readFileSync( join( import.meta.dirname, @@ -80,7 +80,7 @@ describe("client signaling protocol", () => { "utf8", ); - expect(SIGNALING_PROTOCOL).toBe("screener-v11"); + expect(SIGNALING_PROTOCOL).toBe("screener-v12"); expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/); }); @@ -333,10 +333,11 @@ describe("client signaling protocol", () => { it("accepts only canonical bounded Viewer grants and access actions", () => { for (const malformedGrant of [ - `g0.${roomId}.1787076000.${"b".repeat(43)}`, - `g1.${roomId}.0.${"b".repeat(43)}`, - `g1.${roomId}.1787076000.${"b".repeat(42)}`, - `${viewerGrant}.extra`, + "b".repeat(21), + "b".repeat(23), + `${"b".repeat(21)}=`, + `${"b".repeat(21)}b`, + `g1.${roomId}.1787076000.${"b".repeat(43)}`, ]) { expect( clientMessageSchema.safeParse({ diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index 2757cf5..c5626e1 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -137,6 +137,20 @@ describe("RoomStore", () => { ).toBe("viewer"); }); + it("keeps the exact Viewer grant valid for the room incarnation", async () => { + const { clock, store: roomStore } = store({ leaseMs: 1_000 }); + const room = await roomStore.createRoom("disabled"); + roomStore.connectParticipant(hostInput(room.roomId, room.hostToken)); + + expect(room.viewerGrant).toMatch(/^[A-Za-z0-9_-]{21}[AQgw]$/); + clock.nowMs = 8 * 24 * 60 * 60 * 1_000; + expect( + roomStore.connectParticipant( + viewerInput(room.roomId, "room-lived-grant", room.viewerGrant!), + ).role, + ).toBe("viewer"); + }); + it("supports open, password, and disabled code entry", async () => { const { store: roomStore } = store({ maxRooms: 3 }); const open = await roomStore.createRoom("open"); @@ -189,10 +203,16 @@ describe("RoomStore", () => { results.every( (result) => result.status === "rejected" && - result.reason instanceof RoomStoreError && - result.reason.code === "INVALID_TOKEN", + result.reason instanceof RoomStoreError, ), ).toBe(true); + expect( + results.filter( + (result) => + result.status === "rejected" && + result.reason.code === "ROOM_NOT_FOUND", + ), + ).toHaveLength(2); }); it("rotates and revokes Viewer grants without changing code entry", async () => { @@ -242,14 +262,17 @@ describe("RoomStore", () => { }); it("keeps the old grant and viewers when rotation cannot create a generation", async () => { - let failGeneration = false; + let failedRandomCall: number | null = null; + let randomCalls = 0; let randomValue = 0; const { store: roomStore } = store({ - random: (size) => - Buffer.alloc( - failGeneration && size === 16 ? size - 1 : size, + random: (size) => { + randomCalls += 1; + return Buffer.alloc( + randomCalls === failedRandomCall ? size - 1 : size, ++randomValue, - ), + ); + }, }); const room = await roomStore.createRoom("disabled"); roomStore.connectParticipant(hostInput(room.roomId, room.hostToken)); @@ -257,11 +280,11 @@ describe("RoomStore", () => { viewerInput(room.roomId, "existing", room.viewerGrant!), ); - failGeneration = true; + failedRandomCall = randomCalls + 2; expect(() => roomStore.setViewerGrant(room.roomId, "rotate", "host-session"), ).toThrow("Authorization generation random source must return 16 bytes"); - failGeneration = false; + failedRandomCall = null; expect( roomStore.getConnectedViewer(room.roomId, viewer.peerId), @@ -301,6 +324,29 @@ describe("RoomStore", () => { clientId: "viewer", sessionId: "session", }), - ).rejects.toThrow(new RoomStoreError("INVALID_TOKEN")); + ).rejects.toThrow(new RoomStoreError("ROOM_NOT_FOUND")); + }); + + it("rejects an old grant when a new room reuses the same code", async () => { + const firstStore = store({ + random: (size) => Buffer.alloc(size, size === 8 ? 0 : 1), + }).store; + const firstRoom = await firstStore.createRoom("disabled"); + firstStore.abandonRoom(firstRoom.roomId); + + const secondStore = store({ + random: (size) => Buffer.alloc(size, size === 8 ? 0 : 2), + }).store; + const secondRoom = await secondStore.createRoom("disabled"); + + expect(secondRoom.roomId).toBe(firstRoom.roomId); + expect(secondRoom.viewerGrant).not.toBe(firstRoom.viewerGrant); + expectRoomError( + () => + secondStore.connectParticipant( + viewerInput(secondRoom.roomId, "old-incarnation", firstRoom.viewerGrant!), + ), + "INVALID_TOKEN", + ); }); }); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index dae134a..14d1e4c 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -266,12 +266,10 @@ describe("room HTTP API", () => { expect(body.roomId).toMatch(/^[1-9]\d{3}$/); expect(invite.origin).toBe("https://share.example.test"); expect(invite.pathname).toBe(`/r/${body.roomId}`); - expect(invite.hash).toMatch( - new RegExp(`^#v=g1\\.${body.roomId}\\.\\d+\\.[A-Za-z0-9_-]{43}$`), - ); + expect(invite.hash).toMatch(/^#v=[A-Za-z0-9_-]{21}[AQgw]$/); expect(invite.search).toBe(""); expect(body.codeEntryPolicy).toBe("open"); - expect(body.viewerGrantExpiresAt).toBeTruthy(); + expect("viewerGrantExpiresAt" in body).toBe(false); expect(body.expiresAt).toBeTruthy(); expect("iceConfig" in body).toBe(false); }); @@ -318,7 +316,6 @@ describe("room HTTP API", () => { const body = createRoomResponseSchema.parse(await response.json()); expect(body).toMatchObject({ codeEntryPolicy: "open", - viewerGrantExpiresAt: expect.any(String), }); expect(new URL(body.inviteUrl).hash).toContain("#v="); }); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 4da801a..8eebc66 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -1130,7 +1130,7 @@ describe("WebSocket signaling", () => { roomId: publicRoom.roomId, role: "viewer", clientId: "public-viewer-with-forged-grant", - viewerGrant: `g1.${publicRoom.roomId}.1893456000.${"x".repeat(43)}`, + viewerGrant: `${"x".repeat(21)}g`, }), ); await expect( @@ -1189,7 +1189,7 @@ describe("WebSocket signaling", () => { ).resolves.toMatchObject({ role: "host" }); }); - it("collapses every site-authorized code-only refusal without room enumeration", async () => { + it("distinguishes missing rooms from existing-room code-only denials", async () => { let now = Date.UTC(2026, 7, 24, 12); const siteAccessPassword = "protected-instance-password"; const harness = await startHarness({ @@ -1236,9 +1236,10 @@ describe("WebSocket signaling", () => { { codeOnly: true }, ); - async function expectNeutralDenial( + async function expectDenial( roomId: string, clientId: string, + code: "ROOM_NOT_FOUND" | "ROOM_ACCESS_DENIED", viewerPassword?: string, ): Promise { const client = await openClient(harness.webSocketUrl, cookie); @@ -1259,8 +1260,11 @@ describe("WebSocket signaling", () => { ); expect(await client.inbox.next("error")).toEqual({ type: "error", - code: "ROOM_ACCESS_DENIED", - message: "Room access denied", + code, + message: + code === "ROOM_NOT_FOUND" + ? "Room not found or expired" + : "Room access denied", }); expect(await closed).toEqual({ code: 4003, @@ -1268,15 +1272,28 @@ describe("WebSocket signaling", () => { }); } - await expectNeutralDenial(unusedRoomId, "unknown-room-viewer"); - await expectNeutralDenial(disabled.roomId, "disabled-room-viewer"); - await expectNeutralDenial(protectedRoom.roomId, "missing-password-viewer"); - await expectNeutralDenial( + await expectDenial(unusedRoomId, "unknown-room-viewer", "ROOM_NOT_FOUND"); + await expectDenial( + disabled.roomId, + "disabled-room-viewer", + "ROOM_ACCESS_DENIED", + ); + await expectDenial( + protectedRoom.roomId, + "missing-password-viewer", + "ROOM_ACCESS_DENIED", + ); + await expectDenial( protectedRoom.roomId, "wrong-password-viewer", + "ROOM_ACCESS_DENIED", "wrong-password", ); - await expectNeutralDenial(harness.room.roomId, "full-room-viewer"); + await expectDenial( + harness.room.roomId, + "full-room-viewer", + "ROOM_ACCESS_DENIED", + ); now += 86_400_001; const renewedLogin = await fetch(`${harness.baseUrl}/api/site-access`, { @@ -1288,7 +1305,11 @@ describe("WebSocket signaling", () => { }); cookie = renewedLogin.headers.get("set-cookie")?.split(";", 1)[0]; expect(cookie).toBeTruthy(); - await expectNeutralDenial(expiring.roomId, "expired-room-viewer"); + await expectDenial( + expiring.roomId, + "expired-room-viewer", + "ROOM_NOT_FOUND", + ); const expiredGrant = await openClient(harness.webSocketUrl, cookie); expiredGrant.socket.send( diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index 6f1b34f..bbdd205 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -442,6 +442,45 @@ function senderReport( ]); } +function audioSenderReport( + trackId: string, + timestamp: number, + bytesSent: number, +): RTCStatsReport { + return statsReport([ + { + id: "audio-out", + type: "outbound-rtp", + timestamp, + kind: "audio", + ssrc: 202, + transportId: "audio-transport", + mediaSourceId: "audio-source", + codecId: "audio-codec", + bytesSent, + }, + { + id: "audio-source", + type: "media-source", + timestamp, + kind: "audio", + trackIdentifier: trackId, + }, + { + id: "audio-transport", + type: "transport", + timestamp, + }, + { + id: "audio-codec", + type: "codec", + timestamp, + transportId: "audio-transport", + mimeType: "audio/opus", + }, + ]); +} + function stream(...tracks: MediaStreamTrack[]): MediaStream { return new FakeMediaStream(tracks) as unknown as MediaStream; } @@ -560,6 +599,55 @@ describe("SfuPublisher", () => { expect(updates.at(-1)).toBeNull(); }); + it("merges the current audio sender report and resets after its sender changes", async () => { + vi.useFakeTimers(); + const updates: Array = []; + const publisher = new SfuPublisher({ + onStats: (metrics) => updates.push(metrics), + }); + const video = track("video", "video-1"); + const audio = track("audio", "audio-1"); + await publisher.connect(connection); + await publisher.activate(stream(video, audio), qualityProfile); + const room = livekit.state.rooms[0]; + const videoSender = room.localParticipant.publications[0].track.sender; + const audioTrack = room.localParticipant.publications[1].track; + const audioSender = audioTrack.sender; + videoSender.getStats + .mockResolvedValueOnce(senderReport(video.id, 1_000, 100_000, 60)) + .mockResolvedValueOnce(senderReport(video.id, 3_000, 300_000, 180)); + audioSender.getStats + .mockResolvedValueOnce(audioSenderReport(audio.id, 1_000, 10_000)) + .mockResolvedValueOnce(audioSenderReport(audio.id, 3_000, 50_000)); + + await vi.advanceTimersByTimeAsync(2_000); + expect(updates.at(-1)).toMatchObject({ + audioBitrateKbps: null, + audioCodec: "audio/opus", + }); + await vi.advanceTimersByTimeAsync(2_000); + expect(updates.at(-1)).toMatchObject({ + audioBitrateKbps: 160, + audioCodec: "audio/opus", + }); + + const replacementSender = audioTrack.replaceSenderForTest(); + videoSender.getStats + .mockResolvedValueOnce(senderReport(video.id, 5_000, 500_000, 300)) + .mockResolvedValueOnce(senderReport(video.id, 7_000, 700_000, 420)); + replacementSender.getStats + .mockResolvedValueOnce(audioSenderReport(audio.id, 5_000, 90_000)) + .mockResolvedValueOnce(audioSenderReport(audio.id, 7_000, 130_000)); + + await vi.advanceTimersByTimeAsync(2_000); + expect(updates.at(-2)).toBeNull(); + expect(updates.at(-1)?.audioBitrateKbps).toBeNull(); + await vi.advanceTimersByTimeAsync(2_000); + expect(updates.at(-1)?.audioBitrateKbps).toBe(160); + expect(audioSender.getStats).toHaveBeenCalledTimes(2); + expect(replacementSender.getStats).toHaveBeenCalledTimes(2); + }); + it("explicitly keeps Dynacast off while preparing SFU fallback", async () => { const publisher = new SfuPublisher(); diff --git a/tests/viewer-presentation.test.ts b/tests/viewer-presentation.test.ts index 520b8cb..2272aee 100644 --- a/tests/viewer-presentation.test.ts +++ b/tests/viewer-presentation.test.ts @@ -319,5 +319,16 @@ describe("Viewer presentation reducer", () => { message: "当前无法通过房间号加入", retryAvailable: false, }); + + const notFound = reduceViewerPresentation(failed, { + type: "access", + access: "denied", + failure: "ROOM_NOT_FOUND", + }); + expect(deriveViewerPresentation(notFound)).toMatchObject({ + stage: "room-not-found", + message: "房间不存在或已过期", + retryAvailable: false, + }); }); });