feat(browser): implement room v12 and exact audio stats

This commit is contained in:
AAA committed 2026-08-24 22:29:56 +08:00
1 parent 0635ccdb5b
commit cf4987c3c7
33 files changed
+772 -977

No files matched your search

+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 失效,迟到回调不能把新路线标成已播放。
+36 -6
View File
@@ -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<string>(
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");
@@ -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 (
<div className="connection-details-toggle">
@@ -26,17 +23,6 @@ export function ConnectionDetailsToggle({
/>
<span>显示连接详情</span>
</label>
{checked && onExport && (
<button
className="icon-button"
type="button"
title="下载脱敏连接诊断"
aria-label="下载脱敏连接诊断"
onClick={onExport}
>
<Download size={17} aria-hidden="true" />
</button>
)}
</div>
);
}
+2 -2
View File
@@ -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<HTMLFormElement>): void {
event.preventDefault();
const route = roomRouteFromInput(roomId);
const route = roomRouteForExplicitEntry(roomId);
if (!route) {
setError("请输入 1000 至 9999 的四位房间号");
return;
-205
View File
@@ -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,
};
}
+22 -18
View File
@@ -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 {
+1
View File
@@ -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",
+9
View File
@@ -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",
+48 -111
View File
@@ -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<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const signalRef = useRef<SignalingClient | null>(null);
const pendingDiagnosticExportRef = useRef<
DiagnosticConnectionInput[] | null
>(null);
const displayNameRef = useRef(displayName);
const hostClientIdRef = useRef<string | null>(null);
const viewerPasswordActionRef = useRef<string | null | undefined>(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 = {}) {
<ConnectionDetailsToggle
checked={showConnectionDetails}
onChange={setShowConnectionDetails}
onExport={phase === "live"
? () => {
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}
/>
<div className="setup-controls">
@@ -2396,40 +2315,58 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{room && (
<div className="invite-bar">
<div className="invite-primary">
<span className="field-label">邀请链接</span>
<span className="invite-url" title={room.inviteUrl ?? undefined}>
{room.inviteUrl ?? "当前没有有效邀请,请更新生成新链接"}
</span>
<div className="invite-actions">
<div className="invite-heading">
<div className="invite-heading-copy">
<span className="field-label">邀请链接</span>
<span className="invite-status">
{room.inviteUrl ? "可用" : "已撤销"}
</span>
</div>
<div className="invite-icon-actions">
<button
className="icon-button invite-icon-action"
type="button"
title="更新邀请链接"
aria-label="更新邀请链接"
disabled={viewerGrantUpdating || phase !== "live"}
onClick={() => changeViewerGrant("rotate")}
>
<RefreshCw size={17} aria-hidden="true" />
</button>
<button
className="icon-button invite-icon-action is-danger"
type="button"
title="撤销邀请链接"
aria-label="撤销邀请链接"
disabled={
!room.inviteUrl || viewerGrantUpdating || phase !== "live"
}
onClick={() => changeViewerGrant("revoke")}
>
<Link2Off size={17} aria-hidden="true" />
</button>
</div>
</div>
<div className="invite-copy-row">
<span
className="invite-url"
title={room.inviteUrl ?? undefined}
>
{room.inviteUrl ?? "当前没有有效邀请链接"}
</span>
<button
className="button button-secondary invite-action"
className="button button-primary invite-copy-action"
type="button"
disabled={!room.inviteUrl}
disabled={!room.inviteUrl || viewerGrantUpdating}
onClick={() => void copyInvite()}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? (
<Check size={16} aria-hidden="true" />
) : (
<Copy size={16} aria-hidden="true" />
)}
{copied ? "已复制" : "复制邀请链接"}
</button>
<button
className="button button-secondary invite-action"
type="button"
disabled={viewerGrantUpdating || phase !== "live"}
onClick={() => changeViewerGrant("rotate")}
>
<RefreshCw size={16} />
更新邀请
</button>
<button
className="button button-danger invite-action"
type="button"
disabled={
!room.inviteUrl || viewerGrantUpdating || phase !== "live"
}
onClick={() => changeViewerGrant("revoke")}
>
<LockKeyhole size={16} />
撤销邀请
</button>
</div>
</div>
<div className="room-entry-policy">
+6 -80
View File
@@ -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) {
<p className="section-meta">
{codeOnlyDenied
? "请使用分享者提供的邀请链接,或尝试房间密码。"
: presentationState.failure === "ROOM_NOT_FOUND"
? "请确认房间号,或向分享者获取新的邀请链接。"
: viewerGrant
? "请向分享者获取新的邀请链接。"
: "请检查入口后重试。"}
@@ -1759,21 +1705,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
刷新页面
</button>
)}
<button
className="icon-button access-diagnostic-action"
type="button"
title="下载脱敏连接诊断"
aria-label="下载脱敏连接诊断"
onClick={() =>
downloadDiagnosticReport(
"viewer",
diagnosticConnections,
viewerDiagnosticState,
)
}
>
<Download size={17} aria-hidden="true" />
</button>
</section>
)}
</main>
@@ -2027,13 +1958,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<ConnectionDetailsToggle
checked={showConnectionDetails}
onChange={setShowConnectionDetails}
onExport={() =>
downloadDiagnosticReport(
"viewer",
diagnosticConnections,
viewerDiagnosticState,
)
}
/>
{routePresentation.evidence === peerSnapshot && peerSnapshot?.error && (
@@ -2122,6 +2046,8 @@ function viewerFailureFromServerCode(
code: Extract<ServerMessage, { type: "error" }>["code"],
): ViewerFailureCode | null {
switch (code) {
case "ROOM_NOT_FOUND":
return "ROOM_NOT_FOUND";
case "ROOM_ACCESS_DENIED":
return "ROOM_ACCESS_DENIED";
case "INVALID_TOKEN":
+1
View File
@@ -24,6 +24,7 @@ const HOST_SERVER_ERROR_NOTICE: Record<ServerErrorCode, string> = {
AUTH_REQUIRED: "站点访问已失效,请重新验证",
INVALID_MESSAGE: "页面版本已更新,请刷新后重试",
INVALID_TOKEN: "分享凭证已失效,请重新创建房间",
ROOM_NOT_FOUND: "房间不存在或已过期",
ROOM_ACCESS_DENIED: "当前操作没有权限",
ROOM_EXPIRED: "房间已过期,请重新创建",
ROOM_FULL: "房间已满",
+48 -14
View File
@@ -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 {
+61 -26
View File
@@ -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;
}
+12 -2
View File
@@ -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)
) {
+12 -1
View File
@@ -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"
-1
View File
@@ -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);
+24 -69
View File
@@ -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 };
}
+11 -2
View File
@@ -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 {
+4 -6
View File
@@ -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();
+113
View File
@@ -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, unknown>,
): [string, Record<string, unknown>] => [
id,
{ id, type, timestamp, ...values },
];
return new Map<string, Record<string, unknown>>([
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",
});
});
});
+40 -23
View File
@@ -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<string, string>();
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<string, string>();
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<string, string>([
["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<string, string>([
["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 });
+8 -7
View File
@@ -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("下载");
});
});
-353
View File
@@ -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 });
});
});
+1
View File
@@ -13,6 +13,7 @@ describe("host error notices", () => {
AUTH_REQUIRED: "站点访问已失效,请重新验证",
INVALID_MESSAGE: "页面版本已更新,请刷新后重试",
INVALID_TOKEN: "分享凭证已失效,请重新创建房间",
ROOM_NOT_FOUND: "房间不存在或已过期",
ROOM_ACCESS_DENIED: "当前操作没有权限",
ROOM_EXPIRED: "房间已过期,请重新创建",
ROOM_FULL: "房间已满",
+112
View File
@@ -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<string, Record<string, unknown>>,
);
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",
+8 -7
View File
@@ -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({
+56 -10
View File
@@ -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",
);
});
});
+2 -5
View File
@@ -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=");
});
+32 -11
View File
@@ -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<void> {
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(
+88
View File
@@ -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<ConnectionMetrics | null> = [];
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();
+11
View File
@@ -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,
});
});
});