feat: implement v9 viewer entry and presentation
This commit is contained in:
1 parent
37c34d9031
commit
eb926f89b3
23 files changed
+2301
-282
No files matched your search
+24
-8
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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=(.+)$/);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
+506
-167
File diff suppressed because it is too large.
Load diff
+22
-10
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user