feat(browser): implement room v12 and exact audio stats
This commit is contained in:
1 parent
0635ccdb5b
commit
cf4987c3c7
33 files changed
+772
-977
No files matched your search
@@ -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
@@ -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
@@ -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 失效,迟到回调不能把新路线标成已播放。
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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("下载");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ describe("host error notices", () => {
|
||||
AUTH_REQUIRED: "站点访问已失效,请重新验证",
|
||||
INVALID_MESSAGE: "页面版本已更新,请刷新后重试",
|
||||
INVALID_TOKEN: "分享凭证已失效,请重新创建房间",
|
||||
ROOM_NOT_FOUND: "房间不存在或已过期",
|
||||
ROOM_ACCESS_DENIED: "当前操作没有权限",
|
||||
ROOM_EXPIRED: "房间已过期,请重新创建",
|
||||
ROOM_FULL: "房间已满",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user