feat: implement v9 viewer entry and presentation

This commit is contained in:
AAA committed 2026-08-24 05:58:15 +08:00
1 parent 37c34d9031
commit eb926f89b3
23 files changed
+2301 -282

No files matched your search

+24 -8
View File
@@ -6,14 +6,13 @@ import {
getSiteAccess,
type SiteAccessStatus,
} from "./lib/api";
import { readViewerRoute } from "./lib/session";
import { parseAppRoute, readViewerRoute } from "./lib/session";
import { HostPage } from "./pages/HostPage";
import { JoinPage } from "./pages/JoinPage";
import { ViewerPage } from "./pages/ViewerPage";
const viewerRoute = readViewerRoute();
const isJoinRoute = /^\/join\/?$/.test(window.location.pathname);
const isHostRoute = /^\/?$/.test(window.location.pathname);
const appRoute = parseAppRoute(window.location.pathname);
const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null;
type AccessState =
| { kind: "checking" }
@@ -34,20 +33,24 @@ function readableError(error: unknown): string {
}
export function App() {
if (viewerRoute) {
if (appRoute.kind === "viewer" && viewerRoute) {
return viewerRoute.viewerGrant ? (
<ViewerPage {...viewerRoute} />
) : (
<SiteAccessGate surface="viewer" />
);
}
if (isHostRoute) {
if (appRoute.kind === "host") {
return <SiteAccessGate surface="host" />;
}
if (isJoinRoute) {
if (appRoute.kind === "join") {
return <SiteAccessGate surface="join" />;
}
return <UnavailableRoute />;
return appRoute.kind === "malformed-room" ? (
<MalformedRoomRoute />
) : (
<UnavailableRoute />
);
}
function SiteAccessGate({
@@ -196,3 +199,16 @@ function UnavailableRoute() {
</div>
);
}
function MalformedRoomRoute() {
return (
<div className="app-shell">
<main className="access-workspace access-workspace-full">
<section className="access-panel">
<h1>房间号格式不正确</h1>
<p className="section-meta">房间号必须是 1000..9999 的四位数字</p>
</section>
</main>
</div>
);
}
+2 -5
View File
@@ -1,6 +1,5 @@
import { Hash, LogIn } from "lucide-react";
import { useState, type FormEvent } from "react";
import { ROOM_CODE_LENGTH } from "../../shared/protocol";
import { roomRouteFromInput } from "../lib/session";
interface RoomCodeEntryProps {
@@ -18,7 +17,7 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod
event.preventDefault();
const route = roomRouteFromInput(roomId);
if (!route) {
setError("房间码格式不正确");
setError("房间号必须是 1000..9999 的四位数字");
return;
}
window.location.assign(route);
@@ -46,14 +45,12 @@ export function RoomCodeEntry({ id, autoFocus = false, inline = false }: RoomCod
id={inputId}
value={roomId}
inputMode="numeric"
pattern="[0-9]*"
maxLength={ROOM_CODE_LENGTH}
autoComplete="off"
autoFocus={autoFocus}
placeholder={inline ? "房间码" : undefined}
aria-invalid={error ? "true" : undefined}
onChange={(event) => {
setRoomId(event.target.value.replace(/\D/g, ""));
setRoomId(event.target.value);
setError(null);
}}
/>
@@ -0,0 +1,48 @@
import { Hash, MonitorUp } from "lucide-react";
import { RoomCodeEntry } from "./RoomCodeEntry";
interface StageEntryActionsProps {
joiningRoom: boolean;
onJoinToggle: () => void;
onStartSharing: () => void;
}
export function StageEntryActions({
joiningRoom,
onJoinToggle,
onStartSharing,
}: StageEntryActionsProps) {
return (
<>
<div className="entry-actions">
<button
className="entry-action"
type="button"
title="开始分享屏幕"
onClick={onStartSharing}
>
<MonitorUp size={18} aria-hidden="true" />
开始分享
</button>
<span className="entry-divider" aria-hidden="true">
或
</span>
<button
className="entry-action"
type="button"
title="输入房间码加入观看"
aria-expanded={joiningRoom}
aria-controls="host-room-code-entry"
onClick={onJoinToggle}
>
<Hash size={18} aria-hidden="true" />
加入房间
</button>
</div>
{joiningRoom && (
<RoomCodeEntry id="host-room-code-entry" autoFocus inline />
)}
</>
);
}
+70 -5
View File
@@ -1,5 +1,12 @@
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;
@@ -75,13 +82,33 @@ export interface DiagnosticConnectionInput {
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(),
routeSnapshot: RouteDiagnosticSnapshot | null = null,
context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null,
) {
const route = role === "host" ? routeSnapshot : 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(),
@@ -95,7 +122,10 @@ export function createDiagnosticReport(
]),
)
: null,
connections: connections.map((connection) => ({
...(role === "viewer" && viewerState
? { viewer: serializeViewerState(viewerState) }
: {}),
connections: safeConnections.map((connection) => ({
scope: connection.scope,
route: connection.route,
direction: connection.direction,
@@ -114,14 +144,14 @@ export function createDiagnosticReport(
export function downloadDiagnosticReport(
role: "host" | "viewer",
connections: readonly DiagnosticConnectionInput[],
routeSnapshot: RouteDiagnosticSnapshot | null = null,
context: RouteDiagnosticSnapshot | ViewerDiagnosticInput | null = null,
): void {
const exportedAt = new Date();
const report = createDiagnosticReport(
role,
connections,
exportedAt,
routeSnapshot,
context,
);
const blob = new Blob([`${JSON.stringify(report, null, 2)}\n`], {
type: "application/json;charset=utf-8",
@@ -138,3 +168,38 @@ export function downloadDiagnosticReport(
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,
};
}
+28 -5
View File
@@ -31,13 +31,36 @@ export interface ViewerRoute {
viewerGrant?: string;
}
export type AppRoute =
| { kind: "host" }
| { kind: "join" }
| { kind: "viewer"; roomId: string }
| { kind: "malformed-room" }
| { kind: "unknown" };
export function isValidRoomId(value: string): boolean {
return roomCodeSchema.safeParse(value).success;
}
export function roomRouteFromInput(value: string): string | null {
const roomId = value.trim();
return isValidRoomId(roomId) ? `/r/${roomId}` : null;
return isValidRoomId(value) ? `/r/${value}` : null;
}
export function parseAppRoute(pathname: string): AppRoute {
if (pathname === "/") {
return { kind: "host" };
}
if (/^\/join\/?$/.test(pathname)) {
return { kind: "join" };
}
const viewerMatch = pathname.match(/^\/r\/([1-9]\d{3})\/?$/);
if (viewerMatch) {
return { kind: "viewer", roomId: viewerMatch[1]! };
}
if (pathname === "/r" || pathname.startsWith("/r/")) {
return { kind: "malformed-room" };
}
return { kind: "unknown" };
}
export function clearHostRoom(): void {
@@ -124,12 +147,12 @@ function writeSessionValue(key: string, value: string): void {
}
export function readViewerRoute(): ViewerRoute | null {
const match = window.location.pathname.match(/^\/r\/(\d+)\/?$/);
if (!match || !isValidRoomId(match[1])) {
const route = parseAppRoute(window.location.pathname);
if (route.kind !== "viewer") {
return null;
}
const roomId = match[1];
const roomId = route.roomId;
const fragment = window.location.hash;
if (fragment) {
const fragmentMatch = fragment.match(/^#v=(.+)$/);
+1
View File
@@ -27,6 +27,7 @@ interface SignalingEvents {
const FATAL_SIGNAL_ERRORS = new Set([
"AUTH_REQUIRED",
"INVALID_TOKEN",
"ROOM_ACCESS_DENIED",
"ROOM_EXPIRED",
"ROOM_FULL",
"HOST_ALREADY_CONNECTED",
+97
View File
@@ -0,0 +1,97 @@
type VideoWithFrameCallback = HTMLVideoElement & {
requestVideoFrameCallback?: (
callback: (now: DOMHighResTimeStamp) => void,
) => number;
cancelVideoFrameCallback?: (handle: number) => void;
webkitDecodedFrameCount?: number;
};
export function isAutoplayPolicyRejection(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "NotAllowedError"
);
}
export function observeCompositedVideoFrame(
video: HTMLVideoElement,
expectedStream: MediaStream,
onFrame: () => void,
): () => void {
const target = video as VideoWithFrameCallback;
let active = true;
let frameCallbackHandle: number | null = null;
const fallbackEvents = ["loadeddata", "timeupdate", "playing"] as const;
const removeFallbackListeners = (): void => {
for (const event of fallbackEvents) {
video.removeEventListener(event, checkFallback);
}
};
const baselineDecodedFrames = decodedFrameCount(target);
const baselineCurrentTime = video.currentTime;
function checkFallback(): void {
if (
!active ||
video.srcObject !== expectedStream ||
video.readyState < 2
) {
return;
}
const currentDecodedFrames = decodedFrameCount(target);
if (
(baselineDecodedFrames !== null &&
currentDecodedFrames !== null &&
currentDecodedFrames > baselineDecodedFrames) ||
video.currentTime > baselineCurrentTime
) {
finish();
}
}
function finish(): void {
if (
!active ||
video.srcObject !== expectedStream ||
video.readyState < 2
) {
return;
}
active = false;
removeFallbackListeners();
onFrame();
}
if (target.requestVideoFrameCallback) {
frameCallbackHandle = target.requestVideoFrameCallback(() => finish());
return () => {
active = false;
if (frameCallbackHandle !== null) {
target.cancelVideoFrameCallback?.(frameCallbackHandle);
}
};
}
for (const event of fallbackEvents) {
video.addEventListener(event, checkFallback);
}
checkFallback();
return () => {
active = false;
removeFallbackListeners();
};
}
function decodedFrameCount(video: VideoWithFrameCallback): number | null {
if (typeof video.getVideoPlaybackQuality === "function") {
const count = video.getVideoPlaybackQuality().totalVideoFrames;
return Number.isFinite(count) ? count : null;
}
return typeof video.webkitDecodedFrameCount === "number" &&
Number.isFinite(video.webkitDecodedFrameCount)
? video.webkitDecodedFrameCount
: null;
}
+626
View File
@@ -0,0 +1,626 @@
import type { SignalConnectionState } from "../types";
export type ViewerHostState =
| "unknown"
| "online"
| "paused"
| "offline"
| "stopped";
export type ViewerRouteKind = "none" | "p2p" | "sfu";
export type ViewerFailureCode =
| "ROOM_ACCESS_DENIED"
| "INVALID_TOKEN"
| "ROOM_CLOSED"
| "ROOM_EXPIRED"
| "ROOM_FULL"
| "ROOM_LOST"
| "STALE_CLIENT"
| "SERVER_ERROR"
| "SESSION_REPLACED"
| "SIGNAL_TERMINATED"
| "HOST_STOPPED"
| "HOST_OFFLINE"
| "ROUTE_EXHAUSTED"
| "AUTOPLAY_BLOCKED"
| "PLAYBACK_FAILED";
export type ViewerStage =
| "joining"
| "access-denied"
| "invalid-invite"
| "room-closed"
| "room-expired"
| "room-full"
| "room-lost"
| "stale-client"
| "server-error"
| "session-replaced"
| "signal-terminated"
| "host-paused"
| "needs-play"
| "playing"
| "recovering"
| "route-failed"
| "playback-failed"
| "waiting-host"
| "host-offline"
| "waiting-sfu"
| "preparing-p2p"
| "preparing-sfu"
| "receiving"
| "allocating";
interface ViewerRouteFact {
revision: number;
phase: "prepare" | "active";
kind: ViewerRouteKind;
}
interface ViewerRouteStatusFact {
revision: number;
state: "waiting" | "failed";
}
interface ViewerMediaFact {
generation: number;
revision: number;
framePresented: boolean;
}
export interface ViewerPresentationState {
access: "checking" | "ready" | "denied";
signal: SignalConnectionState;
host: ViewerHostState;
revision: number | null;
route: ViewerRouteFact | null;
routeStatus: ViewerRouteStatusFact | null;
connection:
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "failed";
media: ViewerMediaFact | null;
retainedFrame: boolean;
autoplayBlockedGeneration: number | null;
failure: ViewerFailureCode | null;
retryAvailable: boolean;
}
export type ViewerPresentationAction =
| {
type: "access";
access: ViewerPresentationState["access"];
failure?: ViewerFailureCode | null;
}
| { type: "signal"; signal: SignalConnectionState }
| { type: "host"; host: ViewerHostState }
| {
type: "route";
revision: number;
phase: "prepare" | "active";
kind: ViewerRouteKind;
}
| {
type: "route-status";
revision: number;
state: "waiting" | "failed";
}
| {
type: "connection";
revision: number;
connection: ViewerPresentationState["connection"];
}
| { type: "media-bound"; generation: number; revision: number }
| { type: "frame-presented"; generation: number; revision: number }
| { type: "autoplay-blocked"; generation: number; revision: number }
| { type: "autoplay-cleared"; generation: number }
| { type: "playback-failed"; generation: number; revision: number }
| { type: "media-cleared" }
| {
type: "failure";
failure: ViewerFailureCode | null;
revision?: number;
}
| { type: "retry-available"; available: boolean };
export interface ViewerPresentation {
stage: ViewerStage;
message: string;
notice: string | null;
overlay: "none" | "status" | "blocking";
showPlay: boolean;
retryAvailable: boolean;
hasCurrentFrame: boolean;
hasRetainedFrame: boolean;
failureCode: ViewerFailureCode | null;
}
export const INITIAL_VIEWER_PRESENTATION_STATE: ViewerPresentationState = {
access: "checking",
signal: "offline",
host: "unknown",
revision: null,
route: null,
routeStatus: null,
connection: "idle",
media: null,
retainedFrame: false,
autoplayBlockedGeneration: null,
failure: null,
retryAvailable: false,
};
export function reduceViewerPresentation(
state: ViewerPresentationState,
action: ViewerPresentationAction,
): ViewerPresentationState {
switch (action.type) {
case "access":
return {
...state,
access: action.access,
failure:
action.failure !== undefined
? action.failure
: action.access === "ready"
? null
: state.failure,
};
case "signal":
return {
...state,
signal: action.signal,
failure:
action.signal === "connected" &&
(state.failure === "SIGNAL_TERMINATED" ||
state.failure === "SESSION_REPLACED")
? null
: state.failure,
};
case "host": {
const failure =
action.host === "offline"
? "HOST_OFFLINE"
: action.host === "stopped"
? "HOST_STOPPED"
: state.failure === "HOST_OFFLINE" ||
state.failure === "HOST_STOPPED"
? null
: state.failure;
return { ...state, host: action.host, failure };
}
case "route": {
if (state.revision !== null && action.revision < state.revision) {
return state;
}
const revisionChanged = state.revision !== action.revision;
const currentFrame = hasCurrentFrame(state);
return {
...state,
revision: action.revision,
route: {
revision: action.revision,
phase: action.phase,
kind: action.kind,
},
routeStatus:
state.routeStatus?.revision === action.revision &&
state.routeStatus.state === "waiting"
? state.routeStatus
: null,
retainedFrame:
state.retainedFrame || (revisionChanged && currentFrame),
failure:
revisionChanged &&
(state.failure === "ROUTE_EXHAUSTED" ||
state.failure === "PLAYBACK_FAILED")
? null
: state.failure,
};
}
case "route-status": {
if (state.revision !== null && action.revision < state.revision) {
return state;
}
const revisionChanged = state.revision !== action.revision;
return {
...state,
revision: action.revision,
routeStatus: {
revision: action.revision,
state: action.state,
},
retainedFrame:
state.retainedFrame || (revisionChanged && hasCurrentFrame(state)),
failure:
action.state === "failed"
? "ROUTE_EXHAUSTED"
: state.failure === "ROUTE_EXHAUSTED"
? null
: state.failure,
};
}
case "connection":
if (state.revision !== null && action.revision < state.revision) {
return state;
}
return {
...state,
revision: Math.max(state.revision ?? 0, action.revision),
connection: action.connection,
};
case "media-bound": {
if (
(state.revision !== null && action.revision < state.revision) ||
(state.media && action.generation <= state.media.generation)
) {
return state;
}
return {
...state,
revision: Math.max(state.revision ?? 0, action.revision),
media: {
generation: action.generation,
revision: action.revision,
framePresented: false,
},
retainedFrame: state.retainedFrame || hasCurrentFrame(state),
autoplayBlockedGeneration: null,
failure:
state.failure === "AUTOPLAY_BLOCKED" ||
state.failure === "PLAYBACK_FAILED" ||
state.failure === "ROUTE_EXHAUSTED"
? null
: state.failure,
};
}
case "frame-presented":
if (
!state.media ||
state.media.generation !== action.generation ||
state.media.revision !== action.revision ||
state.revision !== action.revision
) {
return state;
}
return {
...state,
media: { ...state.media, framePresented: true },
retainedFrame: false,
failure:
state.failure === "PLAYBACK_FAILED" ? null : state.failure,
};
case "autoplay-blocked":
if (
!state.media ||
state.media.generation !== action.generation ||
state.media.revision !== action.revision
) {
return state;
}
return {
...state,
autoplayBlockedGeneration: action.generation,
failure: "AUTOPLAY_BLOCKED",
};
case "autoplay-cleared":
if (state.autoplayBlockedGeneration !== action.generation) {
return state;
}
return {
...state,
autoplayBlockedGeneration: null,
failure:
state.failure === "AUTOPLAY_BLOCKED" ? null : state.failure,
};
case "playback-failed":
if (
!state.media ||
state.media.generation !== action.generation ||
state.media.revision !== action.revision
) {
return state;
}
return { ...state, failure: "PLAYBACK_FAILED" };
case "media-cleared":
return {
...state,
media: null,
retainedFrame: false,
autoplayBlockedGeneration: null,
};
case "failure":
if (
action.revision !== undefined &&
state.revision !== null &&
action.revision < state.revision
) {
return state;
}
return {
...state,
revision:
action.revision === undefined
? state.revision
: Math.max(state.revision ?? 0, action.revision),
failure: action.failure,
};
case "retry-available":
return { ...state, retryAvailable: action.available };
}
}
export function deriveViewerPresentation(
state: ViewerPresentationState,
): ViewerPresentation {
const currentFrame = hasCurrentFrame(state);
const retainedFrame = state.retainedFrame;
const frameOverlay = retainedFrame ? "status" : "blocking";
if (state.access === "checking") {
return presentation("joining", "正在加入房间", "blocking", state);
}
if (state.access === "denied") {
switch (state.failure) {
case "ROOM_ACCESS_DENIED":
return presentation(
"access-denied",
"当前无法通过房间号加入",
"blocking",
state,
);
case "INVALID_TOKEN":
return presentation(
"invalid-invite",
"邀请链接无效或已失效",
"blocking",
state,
);
case "ROOM_EXPIRED":
return presentation("room-expired", "房间已过期", "blocking", state);
case "ROOM_CLOSED":
return presentation("room-closed", "房间已关闭", "blocking", state);
case "ROOM_FULL":
return presentation("room-full", "当前无法加入房间", "blocking", state);
case "ROOM_LOST":
return presentation(
"room-lost",
"服务器已重启,房间已失效",
"blocking",
state,
);
case "STALE_CLIENT":
return presentation(
"stale-client",
"页面版本已更新,请刷新后重试",
"blocking",
state,
);
case "SESSION_REPLACED":
return presentation(
"session-replaced",
"此页面的会话已被另一个标签页接管",
"blocking",
state,
);
case "SIGNAL_TERMINATED":
return presentation(
"signal-terminated",
"连接已终止,请刷新后重试",
"blocking",
state,
);
default:
return presentation(
"server-error",
"暂时无法加入房间",
"blocking",
state,
);
}
}
if (state.host === "paused") {
return presentation(
"host-paused",
"分享者已暂停",
currentFrame || retainedFrame ? "status" : "blocking",
state,
);
}
if (
state.media &&
state.autoplayBlockedGeneration === state.media.generation
) {
return {
...presentation("needs-play", "需要点击播放", "blocking", state),
showPlay: true,
};
}
if (currentFrame) {
const signalRecovering =
state.signal === "reconnecting" || state.signal === "connecting";
const mediaRecovering =
state.connection === "reconnecting" || state.connection === "failed";
return {
...presentation(
signalRecovering || mediaRecovering ? "recovering" : "playing",
signalRecovering || mediaRecovering ? "正在恢复连接" : "正在播放",
"none",
state,
),
notice: state.host === "offline"
? "分享者连接已中断,画面仍然可用"
: signalRecovering
? "信令正在恢复,画面仍在播放"
: mediaRecovering
? "媒体连接正在恢复"
: null,
};
}
switch (state.failure) {
case "ROUTE_EXHAUSTED":
return presentation(
"route-failed",
"没有可用的媒体线路",
frameOverlay,
state,
);
case "PLAYBACK_FAILED":
return presentation(
"playback-failed",
"浏览器无法播放当前画面",
frameOverlay,
state,
);
case "SERVER_ERROR":
return presentation(
"server-error",
"连接服务暂时不可用",
frameOverlay,
state,
);
case "STALE_CLIENT":
return presentation(
"stale-client",
"页面版本已更新,请刷新后重试",
frameOverlay,
state,
);
case "SESSION_REPLACED":
return presentation(
"session-replaced",
"此页面的会话已被另一个标签页接管",
frameOverlay,
state,
);
case "SIGNAL_TERMINATED":
return presentation(
"signal-terminated",
"连接已终止,请刷新后重试",
frameOverlay,
state,
);
case "HOST_OFFLINE":
return presentation(
"host-offline",
"分享者连接已中断",
frameOverlay,
state,
);
case "HOST_STOPPED":
return presentation(
"waiting-host",
"等待开始分享",
frameOverlay,
state,
);
}
if (state.routeStatus?.state === "waiting") {
return presentation(
"waiting-sfu",
"正在连接备用线路",
frameOverlay,
state,
);
}
if (state.media && state.media.revision === state.revision) {
return presentation(
"receiving",
"正在接收画面",
frameOverlay,
state,
);
}
if (state.route?.phase === "prepare") {
return presentation(
state.route.kind === "sfu" ? "preparing-sfu" : "preparing-p2p",
state.route.kind === "sfu" ? "正在连接备用线路" : "正在建立 P2P",
frameOverlay,
state,
);
}
if (
state.signal === "reconnecting" ||
state.connection === "reconnecting" ||
state.connection === "failed"
) {
return presentation(
"recovering",
"正在恢复连接",
frameOverlay,
state,
);
}
if (state.host === "stopped" || state.host === "unknown") {
return presentation(
"waiting-host",
"等待开始分享",
frameOverlay,
state,
);
}
if (state.host === "offline") {
return presentation(
"host-offline",
"分享者连接已中断",
frameOverlay,
state,
);
}
if (state.route?.kind === "p2p") {
return presentation(
"preparing-p2p",
"正在建立 P2P",
frameOverlay,
state,
);
}
if (state.route?.kind === "sfu") {
return presentation(
"preparing-sfu",
"正在连接备用线路",
frameOverlay,
state,
);
}
return presentation(
"allocating",
"正在分配线路",
frameOverlay,
state,
);
}
function hasCurrentFrame(state: ViewerPresentationState): boolean {
return Boolean(
state.media?.framePresented && state.media.revision === state.revision,
);
}
function presentation(
stage: ViewerStage,
message: string,
overlay: ViewerPresentation["overlay"],
state: ViewerPresentationState,
): ViewerPresentation {
return {
stage,
message,
notice: null,
overlay,
showPlay: false,
retryAvailable:
state.access === "ready" && state.host !== "paused" && state.retryAvailable,
hasCurrentFrame: hasCurrentFrame(state),
hasRetainedFrame: state.retainedFrame,
failureCode: state.failure,
};
}
+18 -11
View File
@@ -61,10 +61,14 @@ interface ViewerSfuRouteEvents {
stream: MediaStream,
assignment: ParticipantRouteAssignment,
initialVideoStream: boolean,
revision: number,
) => void;
onSfuVideoAvailability?: (available: boolean, revision: number) => void;
onSfuUpdate?: (metrics: ConnectionMetrics | null, revision: number) => void;
onSfuState?: (
state: "connected" | "reconnecting",
revision: number,
) => void;
onSfuVideoAvailability?: (available: boolean) => void;
onSfuUpdate?: (metrics: ConnectionMetrics | null) => void;
onSfuState?: (state: "connected" | "reconnecting") => void;
send: (message: ClientMessage) => boolean;
createSubscriber?: (
events: {
@@ -309,12 +313,12 @@ export class ViewerSfuRoute {
void this.queueTransition(() => this.promotePendingSfu(slot));
}
} else if (this.active === slot && !slot.failed) {
this.events.onSfuUpdate?.(metrics);
this.events.onSfuUpdate?.(metrics, slot.revision);
}
},
onState: (state: "connected" | "reconnecting") => {
if (this.active === slot && !slot.failed) {
this.events.onSfuState?.(state);
this.events.onSfuState?.(state, slot.revision);
}
},
onDisconnected: () => this.handleFailure(slot),
@@ -393,7 +397,10 @@ export class ViewerSfuRoute {
const active = this.active;
this.pending = null;
this.active = null;
this.events.onSfuUpdate?.(null);
this.events.onSfuUpdate?.(
null,
active?.revision ?? pending?.revision ?? this.route.getRevision() ?? 0,
);
if (active?.activated) {
try {
active.subscriber.deactivate();
@@ -513,7 +520,7 @@ export class ViewerSfuRoute {
const assignment = this.route.getMediaAssignment();
if (assignment?.upstream.kind === "sfu") {
slot.mediaAvailable = true;
this.events.onSfuStream(stream, assignment, false);
this.events.onSfuStream(stream, assignment, false, slot.revision);
}
return;
}
@@ -543,7 +550,7 @@ export class ViewerSfuRoute {
}
this.recovery = null;
slot.mediaAvailable = true;
this.events.onSfuStream(slot.stream, assignment, true);
this.events.onSfuStream(slot.stream, assignment, true, slot.revision);
if (previous && previous !== slot) {
try {
previous.subscriber.deactivate();
@@ -569,7 +576,7 @@ export class ViewerSfuRoute {
}
const changed = slot.mediaAvailable !== available;
slot.mediaAvailable = available;
this.events.onSfuVideoAvailability?.(available);
this.events.onSfuVideoAvailability?.(available, slot.revision);
if (!changed || this.paused) return;
}
@@ -595,7 +602,7 @@ export class ViewerSfuRoute {
}
const active = this.active;
this.active = null;
this.events.onSfuUpdate?.(null);
this.events.onSfuUpdate?.(null, active.revision);
if (active.activated) {
try {
active.subscriber.deactivate();
@@ -617,7 +624,7 @@ export class ViewerSfuRoute {
}
if (wasActive) {
this.active = null;
this.events.onSfuUpdate?.(null);
this.events.onSfuUpdate?.(null, slot.revision);
}
const assignment = this.route.getPlannedAssignment();
+11 -37
View File
@@ -1,7 +1,6 @@
import {
Check,
Copy,
Hash,
KeyRound,
LockKeyhole,
Maximize2,
@@ -34,7 +33,7 @@ import { AppHeader } from "../components/AppHeader";
import { ConnectionSelfCheck } from "../components/ConnectionSelfCheck";
import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
import { RoomCode } from "../components/RoomCode";
import { RoomCodeEntry } from "../components/RoomCodeEntry";
import { StageEntryActions } from "../components/StageEntryActions";
import { qualityLimitationSummary } from "../components/connection-details";
import {
MediaRouteBadge,
@@ -2062,41 +2061,16 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{phase === "ended" && room && (
<span className="stage-status">已停止分享</span>
)}
<div className="entry-actions">
<button
className="entry-action"
type="button"
title="开始分享屏幕"
onClick={() => {
setJoiningRoom(false);
void startSharing();
}}
>
<MonitorUp size={18} aria-hidden="true" />
开始分享
</button>
<span className="entry-divider" aria-hidden="true">
或
</span>
<button
className="entry-action"
type="button"
title="输入房间码加入观看"
aria-expanded={joiningRoom}
aria-controls="host-room-code-entry"
onClick={() => setJoiningRoom((current) => !current)}
>
<Hash size={18} aria-hidden="true" />
加入房间
</button>
{joiningRoom && (
<RoomCodeEntry
id="host-room-code-entry"
autoFocus
inline
/>
)}
</div>
<StageEntryActions
joiningRoom={joiningRoom}
onStartSharing={() => {
setJoiningRoom(false);
void startSharing();
}}
onJoinToggle={() =>
setJoiningRoom((current) => !current)
}
/>
</div>
) : (
<div className="stage-placeholder">
File diff suppressed because it is too large. Load diff
+22 -10
View File
@@ -447,7 +447,7 @@ h3 {
.room-code-entry.is-inline {
display: grid;
min-width: 0;
grid-column: 3;
width: min(520px, calc(100% - 20px));
grid-template-columns: minmax(0, 1fr) 44px;
gap: 8px;
}
@@ -781,6 +781,8 @@ h3 {
.invite-primary,
.room-entry-policy {
min-width: 0;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.invite-actions {
@@ -818,7 +820,7 @@ h3 {
.viewer-password-control {
display: grid;
grid-template-columns: auto minmax(160px, 240px) 40px 40px;
grid-template-columns: minmax(0, 1fr) 40px 40px;
width: 100%;
gap: 6px;
align-items: center;
@@ -827,10 +829,21 @@ h3 {
}
.viewer-password-control > label {
grid-column: 1 / -1;
color: var(--muted);
font-size: 12px;
}
.access-password-retry {
display: grid;
min-width: 0;
gap: 12px;
}
.access-diagnostic-action {
justify-self: end;
}
.viewer-password-control .input-with-icon {
min-width: 0;
}
@@ -1394,10 +1407,6 @@ h3 {
grid-template-columns: minmax(0, 1fr);
}
.room-code-entry.is-inline {
grid-column: 1 / -1;
}
.viewer-title-row {
flex-wrap: wrap;
}
@@ -1449,12 +1458,15 @@ h3 {
grid-template-columns: minmax(0, 1fr);
}
.viewer-password-control {
grid-template-columns: minmax(0, 1fr) 40px 40px;
.room-entry-policy {
padding-top: 12px;
padding-left: 0;
border-top: 1px solid var(--border);
border-left: 0;
}
.viewer-password-control > label {
grid-column: 1 / -1;
.viewer-password-control {
grid-template-columns: minmax(0, 1fr) 40px 40px;
}
.viewer-stats .stats-grid {
+6 -6
View File
@@ -776,12 +776,12 @@ export class HybridMediaRouter {
this.releaseResources(begun.released);
if (!begun.accepted || !begun.operation?.current) {
if (begun.exhausted) {
this.sendViewerRouteStatus(roomId, operation.childPeerId, {
type: "route-status",
revision: controller.snapshot().revision,
state: "failed",
reason: "route-exhausted",
});
this.sendViewerRouteStatus(roomId, operation.childPeerId, {
type: "route-status",
revision: controller.snapshot().revision,
state: "failed",
reason: "route-exhausted",
});
}
continue;
}
+19 -4
View File
@@ -457,10 +457,7 @@ export class SignalingServer {
if (!(error instanceof RoomStoreError)) {
console.error("Signaling authentication failed unexpectedly");
}
const code =
error instanceof RoomStoreError && error.code !== "ROOM_LIMIT"
? error.code
: "SERVER_ERROR";
const code = authenticationErrorCode(message, error);
this.sendError(socket, code, authenticationErrorMessage(code));
socket.close(4003, "Authentication failed");
return;
@@ -2031,6 +2028,8 @@ function viewerConnectionKey(roomId: string, peerId: string): string {
function authenticationErrorMessage(code: ErrorCode): string {
switch (code) {
case "ROOM_ACCESS_DENIED":
return "Room access denied";
case "ROOM_EXPIRED":
return "Room has expired";
case "ROOM_FULL":
@@ -2044,6 +2043,22 @@ function authenticationErrorMessage(code: ErrorCode): string {
}
}
function authenticationErrorCode(
message: Extract<ClientMessage, { type: "authenticate" }>,
error: unknown,
): ErrorCode {
if (!(error instanceof RoomStoreError) || error.code === "ROOM_LIMIT") {
return "SERVER_ERROR";
}
if (message.role !== "viewer") {
return error.code;
}
if (!message.viewerGrant) {
return "ROOM_ACCESS_DENIED";
}
return error.code === "ROOM_EXPIRED" ? "INVALID_TOKEN" : error.code;
}
function rejectUpgrade(socket: Duplex, status: number, message: string): void {
socket.write(
`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
+2 -1
View File
@@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
export const MAX_VIEWERS_PER_ROOM_LIMIT = 20;
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
export const MAX_SIGNAL_BYTES = 64 * 1024;
export const SIGNALING_PROTOCOL = "screener-v8";
export const SIGNALING_PROTOCOL = "screener-v9";
export const ROOM_CODE_LENGTH = 4;
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
@@ -723,6 +723,7 @@ const errorCodeSchema = z.enum([
"AUTH_REQUIRED",
"INVALID_MESSAGE",
"INVALID_TOKEN",
"ROOM_ACCESS_DENIED",
"ROOM_EXPIRED",
"ROOM_FULL",
"HOST_ALREADY_CONNECTED",
+35 -5
View File
@@ -14,6 +14,7 @@ import {
clearHostRoom,
getStableClientId,
isValidRoomId,
parseAppRoute,
mergeAuthenticatedHostRoom,
readHostRoom,
readViewerGrant,
@@ -591,9 +592,38 @@ describe("room codes", () => {
expect(isValidRoomId("0")).toBe(false);
expect(isValidRoomId("0123")).toBe(false);
expect(isValidRoomId("123a")).toBe(false);
expect(roomRouteFromInput(" 1234 ")).toBe("/r/1234");
expect(roomRouteFromInput("1234")).toBe("/r/1234");
expect(roomRouteFromInput(" 1234 ")).toBeNull();
expect(roomRouteFromInput("1234")).toBeNull();
expect(roomRouteFromInput("0123")).toBeNull();
});
it("classifies routes without normalizing malformed room input", () => {
expect(parseAppRoute("/")).toEqual({ kind: "host" });
expect(parseAppRoute("/join")).toEqual({ kind: "join" });
expect(parseAppRoute("/join/")).toEqual({ kind: "join" });
expect(parseAppRoute("/r/1000")).toEqual({
kind: "viewer",
roomId: "1000",
});
expect(parseAppRoute("/r/9999/")).toEqual({
kind: "viewer",
roomId: "9999",
});
for (const pathname of [
"/r",
"/r/",
"/r/123",
"/r/12345",
"/r/0000",
"/r/abcd",
"/r/1234",
"/r/1234/extra",
]) {
expect(parseAppRoute(pathname)).toEqual({ kind: "malformed-room" });
}
expect(parseAppRoute("/other")).toEqual({ kind: "unknown" });
});
});
describe("client signaling recovery policy", () => {
@@ -649,7 +679,7 @@ describe("client signaling recovery policy", () => {
Object.defineProperty(authenticated, "data", {
value: JSON.stringify({
type: "authenticated",
protocol: "screener-v8",
protocol: "screener-v9",
role: "viewer",
peerId: "viewer_12345678",
roomExpiresAt: null,
@@ -723,7 +753,7 @@ describe("client signaling recovery policy", () => {
socket.dispatchEvent(new Event("open"));
receive(socket, {
type: "authenticated",
protocol: "screener-v8",
protocol: "screener-v9",
role: "host",
peerId: "host_12345678",
roomExpiresAt: null,
@@ -848,7 +878,7 @@ describe("client signaling recovery policy", () => {
sockets[0]!.dispatchEvent(new Event("open"));
receive({
type: "authenticated",
protocol: "screener-v8",
protocol: "screener-v9",
role: "viewer",
peerId: "viewer_12345678",
roomExpiresAt: null,
@@ -976,7 +1006,7 @@ describe("client signaling recovery policy", () => {
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
).toMatchObject({
type: "authenticate",
protocol: "screener-v8",
protocol: "screener-v9",
});
const message = new Event("message");
Object.defineProperty(message, "data", { value: payload });
+73
View File
@@ -6,6 +6,7 @@ import {
downloadDiagnosticReport,
summarizeRouteTiming,
type DiagnosticConnectionInput,
type ViewerDiagnosticInput,
} from "../src/client/lib/diagnostic-export.ts";
import { EMPTY_METRICS } from "../src/client/types.ts";
@@ -146,6 +147,78 @@ describe("diagnostic export privacy boundary", () => {
}
});
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() };
+176 -5
View File
@@ -141,7 +141,7 @@ function harness(
return { store, sent, admission, router };
}
describe("HybridMediaRouter v7 runtime", () => {
describe("HybridMediaRouter v9 runtime", () => {
it("wakes at the derived direct boundary and prepares SFU automatically", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
@@ -169,8 +169,7 @@ describe("HybridMediaRouter v7 runtime", () => {
.get(viewer.sessionId)
?.some(
(message) =>
message.type === "error" &&
message.message === "No usable media route is available",
message.type === "route-status" && message.state === "failed",
),
).toBe(false);
} finally {
@@ -236,8 +235,7 @@ describe("HybridMediaRouter v7 runtime", () => {
.get(waiting.sessionId)
?.some(
(message) =>
message.type === "error" &&
message.message === "No usable media route is available",
message.type === "route-status" && message.state === "failed",
),
).toBe(false);
} finally {
@@ -246,6 +244,179 @@ describe("HybridMediaRouter v7 runtime", () => {
}
});
it("reports bounded route exhaustion without raw error text", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const { store, sent, router } = harness(2, true, 300);
try {
const room = await store.createRoom();
const host = connectHost(store, room);
complete(router, host);
const viewer = connectViewer(store, room, "route-exhausted");
complete(router, viewer);
await vi.waitFor(() =>
expect(preparedFor(sent, viewer.sessionId)?.candidate.transport).toBe(
"direct",
),
);
await vi.advanceTimersByTimeAsync(150);
await vi.waitFor(() =>
expect(preparedFor(sent, viewer.sessionId)?.candidate.transport).toBe(
"sfu",
),
);
await vi.advanceTimersByTimeAsync(150);
await vi.waitFor(() =>
expect(
sent
.get(viewer.sessionId)
?.findLast((message) => message.type === "route-status"),
).toMatchObject({
type: "route-status",
state: "failed",
reason: "route-exhausted",
}),
);
expect(
sent
.get(viewer.sessionId)
?.some(
(message) =>
message.type === "error" &&
message.message.includes("media route"),
),
).toBe(false);
} finally {
await router.close();
vi.useRealTimers();
}
});
it("reports typed exhaustion when the exact candidate fails", async () => {
const { store, sent, router } = harness(1);
try {
const room = await store.createRoom();
const host = connectHost(store, room);
complete(router, host);
const viewer = connectViewer(store, room, "candidate-failed");
complete(router, viewer);
await vi.waitFor(() =>
expect(preparedFor(sent, viewer.sessionId)).toBeDefined(),
);
const prepared = preparedFor(sent, viewer.sessionId)!;
router.handleRouteFailed(viewer, {
type: "route-failed",
revision: prepared.revision,
phase: "prepare",
connectionId: prepared.candidate.connectionId,
});
await vi.waitFor(() =>
expect(
sent
.get(viewer.sessionId)
?.findLast((message) => message.type === "route-status"),
).toMatchObject({
state: "failed",
reason: "route-exhausted",
}),
);
} finally {
await router.close();
}
});
it("reports typed exhaustion when reconciliation has no candidate", async () => {
const { store, sent, router } = harness(1);
try {
const room = await store.createRoom();
const host = connectHost(store, room);
complete(router, host);
const first = connectViewer(store, room, "only-slot");
complete(router, first);
await vi.waitFor(() =>
expect(preparedFor(sent, first.sessionId)).toBeDefined(),
);
const firstPrepare = preparedFor(sent, first.sessionId)!;
router.handleRouteReady(first, {
type: "route-ready",
revision: firstPrepare.revision,
phase: "prepare",
});
const blocked = connectViewer(store, room, "no-candidate");
complete(router, blocked);
await vi.waitFor(() =>
expect(
sent
.get(blocked.sessionId)
?.findLast((message) => message.type === "route-status"),
).toMatchObject({
state: "failed",
reason: "route-exhausted",
}),
);
} finally {
await router.close();
}
});
it("reports only SFU admission waiting while bounded capacity is unavailable", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const { store, sent, admission, router } = harness(2, true, 300);
const capacityFences = [
{
roomId: "9001",
shareGeneration: "capacity_share_a",
publicationGeneration: "capacity_publication_a",
},
{
roomId: "9002",
shareGeneration: "capacity_share_b",
publicationGeneration: "capacity_publication_b",
},
] as const;
try {
for (const fence of capacityFences) {
expect(admission?.reservePublication(fence)).toBe(true);
}
const room = await store.createRoom();
const host = connectHost(store, room);
complete(router, host);
const viewer = connectViewer(store, room, "sfu-admission-wait");
complete(router, viewer);
await vi.waitFor(() =>
expect(preparedFor(sent, viewer.sessionId)?.candidate.transport).toBe(
"direct",
),
);
await vi.advanceTimersByTimeAsync(150);
await vi.waitFor(() =>
expect(
sent
.get(viewer.sessionId)
?.findLast((message) => message.type === "route-status"),
).toMatchObject({
type: "route-status",
state: "waiting",
reason: "sfu-admission",
}),
);
} finally {
for (const fence of capacityFences) {
admission?.beginDrain(fence);
admission?.completeDrain(fence);
}
await router.close();
vi.useRealTimers();
}
});
it("orders exact prepare, commits only child proof, and rolls back above P", async () => {
const { store, sent, router } = harness(2);
const room = await store.createRoom();
+72 -11
View File
@@ -66,7 +66,7 @@ const qualityEvidence = {
} as const;
describe("client signaling protocol", () => {
it("keeps executable senders outside the Browser v7 release", () => {
it("keeps executable senders outside the Browser-only v9 checkpoint", () => {
const nativeWire = readFileSync(
join(
import.meta.dirname,
@@ -75,7 +75,7 @@ describe("client signaling protocol", () => {
"utf8",
);
expect(SIGNALING_PROTOCOL).toBe("screener-v8");
expect(SIGNALING_PROTOCOL).toBe("screener-v9");
expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/);
});
@@ -125,6 +125,65 @@ describe("client signaling protocol", () => {
).toBe(false);
});
it("keeps Viewer route status as a strict crossed union", () => {
expect(
serverMessageSchema.parse({
type: "route-status",
revision: 3,
state: "waiting",
reason: "sfu-admission",
}),
).toEqual({
type: "route-status",
revision: 3,
state: "waiting",
reason: "sfu-admission",
});
expect(
serverMessageSchema.parse({
type: "route-status",
revision: 4,
state: "failed",
reason: "route-exhausted",
}),
).toEqual({
type: "route-status",
revision: 4,
state: "failed",
reason: "route-exhausted",
});
for (const invalid of [
{
type: "route-status",
revision: 3,
state: "waiting",
reason: "route-exhausted",
},
{
type: "route-status",
revision: 3,
state: "failed",
reason: "sfu-admission",
},
{
type: "route-status",
revision: 3,
state: "waiting",
reason: "sfu-admission",
retryAfterMs: 1_000,
},
{
type: "route-status",
revision: 3,
state: "waiting",
reason: "sfu-admission",
peerId: "private-peer-id",
},
]) {
expect(serverMessageSchema.safeParse(invalid).success).toBe(false);
}
});
it("accepts an atomic room creation profile", () => {
expect(
createRoomRequestSchema.parse({ codeEntryPolicy: "open" }),
@@ -195,15 +254,17 @@ describe("client signaling protocol", () => {
clientId: "client_12345678",
}).success,
).toBe(false);
expect(
clientMessageSchema.safeParse({
type: "authenticate",
protocol: "screener-v3",
roomId,
role: "viewer",
clientId: "client_12345678",
}).success,
).toBe(false);
for (const protocol of ["screener-v8", "screener-v3"]) {
expect(
clientMessageSchema.safeParse({
type: "authenticate",
protocol,
roomId,
role: "viewer",
clientId: "client_12345678",
}).success,
).toBe(false);
}
});
it("normalizes display names and rejects misleading Unicode boundaries", () => {
+119 -2
View File
@@ -517,7 +517,7 @@ describe("WebSocket signaling", () => {
}),
);
expect(await wrongViewer.inbox.next("error")).toMatchObject({
code: "INVALID_TOKEN",
code: "ROOM_ACCESS_DENIED",
});
const passwordViewer = await openClient(harness.webSocketUrl);
@@ -576,7 +576,7 @@ describe("WebSocket signaling", () => {
}),
);
expect(await removedPasswordViewer.inbox.next("error")).toMatchObject({
code: "INVALID_TOKEN",
code: "ROOM_ACCESS_DENIED",
});
});
@@ -1184,6 +1184,123 @@ describe("WebSocket signaling", () => {
).resolves.toMatchObject({ role: "host" });
});
it("collapses every site-authorized code-only refusal without room enumeration", async () => {
let now = Date.UTC(2026, 7, 24, 12);
const siteAccessPassword = "protected-instance-password";
const harness = await startHarness({
siteAccessPassword,
maxViewersPerRoom: 1,
now: () => now,
});
const login = await fetch(`${harness.baseUrl}/api/site-access`, {
method: "POST",
headers: {
Authorization: `Bearer ${siteAccessPassword}`,
Origin: allowedOrigin,
},
});
let cookie = login.headers.get("set-cookie")?.split(";", 1)[0];
expect(cookie).toBeTruthy();
const disabled = await harness.roomStore.createRoom("disabled");
const protectedRoom = await harness.roomStore.createRoom(
"password",
"correct-password",
);
const expiring = await harness.roomStore.createRoom("open");
const unusedRoomId = Array.from({ length: 9_000 }, (_, index) =>
String(1_000 + index),
).find(
(roomId) =>
![
harness.room.roomId,
disabled.roomId,
protectedRoom.roomId,
expiring.roomId,
].includes(roomId),
)!;
const admitted = await openClient(harness.webSocketUrl, cookie);
await authenticate(
admitted,
harness.room,
"viewer",
"admitted-viewer",
1,
undefined,
{ codeOnly: true },
);
async function expectNeutralDenial(
roomId: string,
clientId: string,
viewerPassword?: string,
): Promise<void> {
const client = await openClient(harness.webSocketUrl, cookie);
const closed = new Promise<{ code: number; reason: string }>((resolve) =>
client.socket.once("close", (code, reason) =>
resolve({ code, reason: reason.toString() }),
),
);
client.socket.send(
JSON.stringify({
type: "authenticate",
protocol: SIGNALING_PROTOCOL,
roomId,
role: "viewer",
clientId,
...(viewerPassword ? { viewerPassword } : {}),
}),
);
expect(await client.inbox.next("error")).toEqual({
type: "error",
code: "ROOM_ACCESS_DENIED",
message: "Room access denied",
});
expect(await closed).toEqual({
code: 4003,
reason: "Authentication failed",
});
}
await expectNeutralDenial(unusedRoomId, "unknown-room-viewer");
await expectNeutralDenial(disabled.roomId, "disabled-room-viewer");
await expectNeutralDenial(protectedRoom.roomId, "missing-password-viewer");
await expectNeutralDenial(
protectedRoom.roomId,
"wrong-password-viewer",
"wrong-password",
);
await expectNeutralDenial(harness.room.roomId, "full-room-viewer");
now += 86_400_001;
const renewedLogin = await fetch(`${harness.baseUrl}/api/site-access`, {
method: "POST",
headers: {
Authorization: `Bearer ${siteAccessPassword}`,
Origin: allowedOrigin,
},
});
cookie = renewedLogin.headers.get("set-cookie")?.split(";", 1)[0];
expect(cookie).toBeTruthy();
await expectNeutralDenial(expiring.roomId, "expired-room-viewer");
const expiredGrant = await openClient(harness.webSocketUrl, cookie);
expiredGrant.socket.send(
JSON.stringify({
type: "authenticate",
protocol: SIGNALING_PROTOCOL,
roomId: expiring.roomId,
role: "viewer",
clientId: "expired-grant-viewer",
viewerGrant: expiring.viewerGrant,
}),
);
expect(await expiredGrant.inbox.next("error")).toMatchObject({
code: "INVALID_TOKEN",
});
});
it("does not expire an active room and arms its lease after Host disconnect", async () => {
let now = Date.UTC(2026, 7, 20, 12);
const harness = await startHarness({
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StageEntryActions } from "../src/client/components/StageEntryActions.tsx";
describe("Host stage entry layout", () => {
it("keeps disclosure as the immediate sibling after the fixed action row", () => {
const closed = renderToStaticMarkup(
createElement(StageEntryActions, {
joiningRoom: false,
onJoinToggle: () => undefined,
onStartSharing: () => undefined,
}),
);
const open = renderToStaticMarkup(
createElement(StageEntryActions, {
joiningRoom: true,
onJoinToggle: () => undefined,
onStartSharing: () => undefined,
}),
);
expect(closed.match(/class="entry-action"/g)).toHaveLength(2);
expect(open.match(/class="entry-action"/g)).toHaveLength(2);
expect(open).toMatch(
/class="entry-actions"[\s\S]*?<\/div><form[^>]*class="room-code-entry is-inline"/,
);
const row = open.match(/<div class="entry-actions">([\s\S]*?)<\/div>/)?.[1];
expect(row).toContain("开始分享");
expect(row).toContain("加入房间");
expect(row).not.toContain('class="room-code-entry');
expect(row).not.toContain("<form");
expect(open).toContain('aria-expanded="true"');
expect(open).toContain('id="host-room-code-entry-input"');
});
it("uses bounded tracks for both the row and its independent disclosure", () => {
const css = readFileSync(
join(import.meta.dirname, "../src/client/styles.css"),
"utf8",
);
expect(css).toMatch(
/\.entry-actions\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) 24px minmax\(0, 1fr\);[\s\S]*?width:\s*min\(520px, calc\(100% - 20px\)\);/,
);
expect(css).toMatch(
/\.entry-action\s*\{[\s\S]*?min-height:\s*44px;/,
);
expect(css).toMatch(
/\.room-code-entry\.is-inline\s*\{[\s\S]*?width:\s*min\(520px, calc\(100% - 20px\)\);[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) 44px;/,
);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import {
isAutoplayPolicyRejection,
observeCompositedVideoFrame,
} from "../src/client/media/video-frame-proof.ts";
interface FakeVideo extends EventTarget {
srcObject: MediaStream | null;
readyState: number;
currentTime: number;
decodedFrames: number;
getVideoPlaybackQuality: () => { totalVideoFrames: number };
requestVideoFrameCallback?: (callback: () => void) => number;
cancelVideoFrameCallback?: (handle: number) => void;
}
function fakeVideo(stream: MediaStream): FakeVideo {
const video = new EventTarget() as FakeVideo;
video.srcObject = stream;
video.readyState = 0;
video.currentTime = 0;
video.decodedFrames = 0;
video.getVideoPlaybackQuality = () => ({
totalVideoFrames: video.decodedFrames,
});
return video;
}
describe("composited video frame proof", () => {
it("recognizes only browser autoplay policy rejection", () => {
expect(isAutoplayPolicyRejection({ name: "NotAllowedError" })).toBe(true);
expect(isAutoplayPolicyRejection({ name: "AbortError" })).toBe(false);
expect(isAutoplayPolicyRejection(new Error("not allowed"))).toBe(false);
});
it("binds requestVideoFrameCallback proof to the expected srcObject", () => {
const stream = {} as MediaStream;
const replacement = {} as MediaStream;
const video = fakeVideo(stream);
let callback: (() => void) | null = null;
video.requestVideoFrameCallback = (next) => {
callback = next;
return 7;
};
video.cancelVideoFrameCallback = vi.fn();
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.srcObject = replacement;
callback!();
expect(onFrame).not.toHaveBeenCalled();
cancel();
expect(video.cancelVideoFrameCallback).toHaveBeenCalledWith(7);
});
it("uses decoded progress plus readiness in the event fallback", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.dispatchEvent(new Event("playing"));
expect(onFrame).not.toHaveBeenCalled();
video.decodedFrames = 1;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).toHaveBeenCalledOnce();
video.decodedFrames = 2;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).toHaveBeenCalledOnce();
cancel();
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
INITIAL_VIEWER_PRESENTATION_STATE,
deriveViewerPresentation,
reduceViewerPresentation,
type ViewerPresentationAction,
type ViewerPresentationState,
} from "../src/client/media/viewer-presentation.ts";
function apply(
...actions: readonly ViewerPresentationAction[]
): ViewerPresentationState {
return actions.reduce(
reduceViewerPresentation,
INITIAL_VIEWER_PRESENTATION_STATE,
);
}
describe("Viewer presentation reducer", () => {
it("derives connection stages from typed current-revision facts", () => {
const preparing = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 1, phase: "prepare", kind: "p2p" },
);
expect(deriveViewerPresentation(preparing)).toMatchObject({
stage: "preparing-p2p",
message: "正在建立 P2P",
overlay: "blocking",
showPlay: false,
});
const receiving = reduceViewerPresentation(preparing, {
type: "media-bound",
generation: 1,
revision: 1,
});
expect(deriveViewerPresentation(receiving).stage).toBe("receiving");
const playing = reduceViewerPresentation(receiving, {
type: "frame-presented",
generation: 1,
revision: 1,
});
expect(deriveViewerPresentation(playing)).toMatchObject({
stage: "playing",
overlay: "none",
hasCurrentFrame: true,
});
});
it("shows Play only for the exact media generation rejected by autoplay", () => {
const blocked = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route", revision: 2, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 4, revision: 2 },
{ type: "autoplay-blocked", generation: 4, revision: 2 },
);
expect(deriveViewerPresentation(blocked)).toMatchObject({
stage: "needs-play",
showPlay: true,
failureCode: "AUTOPLAY_BLOCKED",
});
const staleClear = reduceViewerPresentation(blocked, {
type: "autoplay-cleared",
generation: 3,
});
expect(deriveViewerPresentation(staleClear).showPlay).toBe(true);
const cleared = reduceViewerPresentation(blocked, {
type: "autoplay-cleared",
generation: 4,
});
expect(deriveViewerPresentation(cleared)).toMatchObject({
stage: "receiving",
showPlay: false,
});
});
it("ignores a late playback failure from a replaced media binding", () => {
const rebound = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route", revision: 2, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 1, revision: 2 },
{ type: "media-bound", generation: 2, revision: 2 },
);
const staleFailure = reduceViewerPresentation(rebound, {
type: "playback-failed",
generation: 1,
revision: 2,
});
expect(staleFailure).toBe(rebound);
expect(deriveViewerPresentation(staleFailure).stage).toBe("receiving");
});
it("keeps a proven old frame through recovery without accepting stale proof", () => {
const oldFrame = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route", revision: 5, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 5, revision: 5 },
{ type: "frame-presented", generation: 5, revision: 5 },
);
const recovering = reduceViewerPresentation(oldFrame, {
type: "route",
revision: 6,
phase: "prepare",
kind: "sfu",
});
expect(deriveViewerPresentation(recovering)).toMatchObject({
stage: "preparing-sfu",
overlay: "status",
hasCurrentFrame: false,
hasRetainedFrame: true,
});
const staleProof = reduceViewerPresentation(recovering, {
type: "frame-presented",
generation: 5,
revision: 5,
});
expect(staleProof).toBe(recovering);
const rebound = reduceViewerPresentation(recovering, {
type: "media-bound",
generation: 6,
revision: 6,
});
const proven = reduceViewerPresentation(rebound, {
type: "frame-presented",
generation: 6,
revision: 6,
});
expect(deriveViewerPresentation(proven)).toMatchObject({
stage: "playing",
hasCurrentFrame: true,
hasRetainedFrame: false,
});
});
it("keeps healthy media visible while signaling reconnects", () => {
const state = apply(
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 0, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 1, revision: 0 },
{ type: "frame-presented", generation: 1, revision: 0 },
{ type: "signal", signal: "reconnecting" },
);
expect(deriveViewerPresentation(state)).toMatchObject({
stage: "recovering",
overlay: "none",
notice: "信令正在恢复,画面仍在播放",
});
});
it("honors terminal access, Host pause, and typed route status priority", () => {
const paused = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "paused" },
{ type: "route", revision: 3, phase: "active", kind: "sfu" },
{ type: "media-bound", generation: 1, revision: 3 },
{ type: "autoplay-blocked", generation: 1, revision: 3 },
);
expect(deriveViewerPresentation(paused)).toMatchObject({
stage: "host-paused",
showPlay: false,
});
const waiting = apply(
{ type: "access", access: "ready" },
{ type: "host", host: "online" },
{ type: "route-status", revision: 4, state: "waiting" },
);
expect(deriveViewerPresentation(waiting).stage).toBe("waiting-sfu");
const failed = reduceViewerPresentation(waiting, {
type: "route-status",
revision: 4,
state: "failed",
});
expect(deriveViewerPresentation(failed)).toMatchObject({
stage: "route-failed",
failureCode: "ROUTE_EXHAUSTED",
});
const denied = reduceViewerPresentation(failed, {
type: "access",
access: "denied",
failure: "ROOM_ACCESS_DENIED",
});
expect(deriveViewerPresentation(denied)).toMatchObject({
stage: "access-denied",
message: "当前无法通过房间号加入",
retryAvailable: false,
});
});
});