1
0

feat: stream private office screens

This commit is contained in:
2026-08-11 21:31:15 -07:00
parent d841575315
commit 5ca214e4bb
28 changed files with 1754 additions and 35 deletions
+239 -8
View File
@@ -89,7 +89,9 @@ import {
createOfficeScreenPanel,
type MediaSurfaceDescriptor,
type OfficeScreenPanel,
type OfficeScreenRemoteStatus,
} from "./media/index.ts";
import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
@@ -454,7 +456,18 @@ let sharedScreen: {
stream: MediaStream;
video: HTMLVideoElement;
texture: VideoTexture;
remote: RemoteOfficeMedia | null;
} | null = null;
let remoteViewedScreen: {
screenId: string;
officeId: string;
video: HTMLVideoElement;
texture: VideoTexture;
remote: RemoteOfficeMedia;
bound: boolean;
} | null = null;
let remoteMediaOperation = 0;
let remoteViewerRequestScreenId: string | null = null;
/**
* The plan panel's other occupant.
*
@@ -2194,6 +2207,7 @@ async function initializeRealtimePresence(): Promise<void> {
window.addEventListener("pagehide", () => {
realtimePageActive = false;
disposeOfficeScreenUi();
realtimeOperation += 1;
stopRealtimeSubscription?.();
stopRealtimeSubscription = null;
@@ -2266,11 +2280,206 @@ function openProfileEditor(): void {
editor.open();
}
function screenBinding(surface: MediaSurfaceDescriptor) {
return {
officeId: surface.officeId,
levelId: surface.levelId,
roomId: surface.roomId,
screenId: surface.screenId,
};
}
function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void {
officeScreenPanel?.setRemoteStatus(screenId, status);
renderOfficeBadge();
}
function isRemoteAuthorizationError(error: unknown): boolean {
return error instanceof Error && /\((?:401|403)\)/.test(error.message);
}
function releaseRemoteViewer(
status: OfficeScreenRemoteStatus = "off",
action: "revoke" | "dispose" = "revoke",
): void {
const viewing = remoteViewedScreen;
const pendingScreenId = remoteViewerRequestScreenId;
remoteMediaOperation += 1;
remoteViewerRequestScreenId = null;
if (pendingScreenId && pendingScreenId !== viewing?.screenId) {
remotePanelStatus(pendingScreenId, status);
}
if (!viewing) return;
remoteViewedScreen = null;
office?.clearMediaSurface(viewing.screenId);
// `OfficeScreenPanel` owns defensive descriptor snapshots rather than a
// live view of OfficeScene. Refresh after clear just as the bind path does,
// otherwise the panel can keep saying "Media active" after remote teardown.
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
viewing.texture.dispose();
viewing.video.pause();
viewing.video.srcObject = null;
remotePanelStatus(viewing.screenId, status);
const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose();
void finish.catch(() => undefined);
}
function syncRemoteViewerState(
viewing: NonNullable<typeof remoteViewedScreen>,
state: RemoteMediaState,
): void {
if (remoteViewedScreen !== viewing) return;
if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting");
else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting");
else if (state.status === "live") remotePanelStatus(viewing.screenId, "live");
if (state.hasRemoteVideo && !viewing.bound) {
// Playback is a consequence of the user's screen-specific opt-in click;
// the transport itself deliberately never calls `play()`.
void viewing.video.play().then(() => {
if (remoteViewedScreen !== viewing || !office || !inside) return;
viewing.bound = office.bindMediaSurface(
viewing.screenId,
{ canView: true, optedIn: true },
viewing.texture,
);
officeScreenPanel?.update(office.listMediaSurfaces());
remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error");
}).catch(() => {
if (remoteViewedScreen === viewing) {
showDetail("The remote screen arrived, but this browser could not start video playback.");
releaseRemoteViewer("error", "dispose");
}
});
}
if (state.status === "stopped" || state.status === "revoked") {
showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped.");
releaseRemoteViewer("stopped", "dispose");
}
}
async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void> {
if (access.subject === null || !inside || !office || office.depth !== "full") return;
releaseRemoteViewer("off");
const operation = ++remoteMediaOperation;
remoteViewerRequestScreenId = surface.screenId;
remotePanelStatus(surface.screenId, "connecting");
showDetail(`Connecting to ${surface.screenId}`);
try {
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
import("./media/remoteMedia.ts"),
import("./media/iceClient.ts"),
]);
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
office.depth !== "full" || officeId !== surface.officeId ||
!(officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false)) return;
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = false;
const texture = new VideoTexture(video);
texture.colorSpace = SRGBColorSpace;
texture.generateMipmaps = false;
let viewing: NonNullable<typeof remoteViewedScreen>;
const remote = createRemoteOfficeMedia({
role: "viewer",
binding: screenBinding(surface),
authenticatedFetch: authFetch,
peerConnectionConfiguration: ice.configuration,
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
onError: (error) => {
if (remoteViewedScreen !== viewing) return;
if (isRemoteAuthorizationError(error)) {
showDetail("Remote screen authorization ended.");
releaseRemoteViewer("unavailable", "dispose");
} else {
remotePanelStatus(surface.screenId, "reconnecting");
}
},
});
viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false };
remoteViewerRequestScreenId = null;
remoteViewedScreen = viewing;
await remote.startViewer({ viewerOptIn: true, video });
if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`);
} catch (error) {
if (operation !== remoteMediaOperation) return;
remoteViewerRequestScreenId = null;
releaseRemoteViewer("unavailable", "dispose");
remotePanelStatus(surface.screenId, "unavailable");
showDetail(isRemoteAuthorizationError(error)
? "Remote screen authorization ended. Sign in again to reconnect."
: "No authorized remote share is available for that screen.");
}
}
async function startRemotePresenter(
surface: MediaSurfaceDescriptor,
shared: NonNullable<typeof sharedScreen>,
): Promise<void> {
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
remotePanelStatus(surface.screenId, "connecting");
try {
const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([
import("./media/remoteMedia.ts"),
import("./media/iceClient.ts"),
]);
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch });
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
const remote = createRemoteOfficeMedia({
role: "presenter",
binding: screenBinding(surface),
authenticatedFetch: authFetch,
peerConnectionConfiguration: ice.configuration,
onStateChange(state) {
if (sharedScreen !== shared) return;
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting");
else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting");
else if (state.status === "stopped" || state.status === "revoked") {
showDetail("The hosted screen share ended.");
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
}
},
onError: (error) => {
if (sharedScreen !== shared) return;
if (isRemoteAuthorizationError(error)) {
showDetail("Screen sharing stopped because authorization ended.");
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
} else {
remotePanelStatus(surface.screenId, "reconnecting");
}
},
});
shared.remote = remote;
await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video });
if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`);
} catch (error) {
if (sharedScreen !== shared) return;
if (isRemoteAuthorizationError(error)) {
showDetail("Screen sharing stopped because authorization ended. Sign in again to share.");
stopLocalScreenShare();
return;
}
void shared.remote?.dispose().catch(() => undefined);
shared.remote = null;
remotePanelStatus(surface.screenId, "unavailable");
showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`);
}
}
function stopLocalScreenShare(): void {
const shared = sharedScreen;
if (!shared) return;
sharedScreen = null;
remoteMediaOperation += 1;
office?.clearMediaSurface(shared.screenId);
remotePanelStatus(shared.screenId, "stopped");
void shared.remote?.stop().catch(() => undefined);
for (const track of shared.stream.getTracks()) track.stop();
shared.texture.dispose();
shared.video.pause();
@@ -2281,6 +2490,7 @@ function stopLocalScreenShare(): void {
function disposeOfficeScreenUi(): void {
stopLocalScreenShare();
releaseRemoteViewer("off", "dispose");
officeScreenPanel?.dispose();
officeScreenPanel = null;
if (screensOverlay) screensOverlay.hidden = true;
@@ -2288,11 +2498,9 @@ function disposeOfficeScreenUi(): void {
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
if (!office || !inside || office.depth !== "full") return;
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
if (!optedIn) {
showDetail("Opt in to view this screen before starting a local preview.");
return;
}
// One authored surface role at a time. A viewer texture must not survive
// behind a new presenter preview or be cleared later over the presenter.
releaseRemoteViewer("off");
if (!navigator.mediaDevices?.getDisplayMedia) {
showDetail("This browser does not provide tab or window sharing.");
return;
@@ -2331,7 +2539,8 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
showDetail("That screen is no longer available.");
return;
}
sharedScreen = { screenId: surface.screenId, stream, video, texture };
const shared = { screenId: surface.screenId, stream, video, texture, remote: null };
sharedScreen = shared;
pendingStream = null;
pendingVideo = null;
pendingTexture = null;
@@ -2339,6 +2548,9 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
officeScreenPanel?.update(office.listMediaSurfaces());
renderOfficeBadge();
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
// Anonymous/self-host-only behavior ends here exactly as before. The
// transport chunk is fetched only for a signed-in explicit share.
if (access.subject !== null) void startRemotePresenter(surface, shared);
} catch (error) {
for (const track of pendingStream?.getTracks() ?? []) track.stop();
pendingTexture?.dispose();
@@ -2362,9 +2574,28 @@ function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
container: screensOverlay,
surfaces: office.listMediaSurfaces(),
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
onStopShare: () => stopLocalScreenShare(),
onSelect(surface) {
if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) ||
(remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) {
releaseRemoteViewer("off");
}
},
onStopShare: (surface) => {
if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
releaseRemoteViewer("stopped");
}
},
onViewerOptIn(surface, optedIn) {
if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
if (!optedIn) {
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
releaseRemoteViewer("off");
} else {
remotePanelStatus(surface.screenId, "off");
}
} else if (access.subject !== null) {
void startRemoteViewer(surface);
}
},
});
const syncOverlay = () => queueMicrotask(() => {
+90
View File
@@ -0,0 +1,90 @@
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
export interface EphemeralIceConfiguration {
readonly configuration: RTCConfiguration;
readonly expiresAtMs: number;
}
export interface FetchEphemeralIceConfigurationOptions {
authenticatedFetch: typeof globalThis.fetch;
endpoint?: string;
now?: () => number;
requestId?: () => string;
}
/** A validated, correlated server backoff response. No credential is retained. */
export class IceConfigurationUnavailableError extends Error {
readonly name = "IceConfigurationUnavailableError";
readonly retryAfterMs: number;
readonly status: number;
constructor(retryAfterMs: number, status: number) {
super("ICE configuration is temporarily unavailable");
this.retryAfterMs = retryAfterMs;
this.status = status;
}
}
/**
* Fetches one short-lived WebRTC configuration after the caller has established
* authenticated, explicit media intent. The result is never cached or stored;
* its lifetime remains visible so the caller can discard it on expiry.
*/
export async function fetchEphemeralIceConfiguration(
options: FetchEphemeralIceConfigurationOptions,
): Promise<EphemeralIceConfiguration> {
if (typeof options.authenticatedFetch !== "function") {
throw new TypeError("ICE configuration: authenticated fetch is required");
}
const now = options.now ?? Date.now;
const id = (options.requestId ?? (() => `tera-ice-${crypto.randomUUID()}`))();
const response = await options.authenticatedFetch(options.endpoint ?? "/api/v1/media/ice", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
redirect: "error",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
type: "ice-config-request",
protocolVersion: ICE_CONFIG_PROTOCOL_VERSION,
requestId: id,
}),
});
let body: unknown;
try {
body = await response.json() as unknown;
} catch {
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
throw new Error("ICE configuration response is not valid JSON");
}
const parsed = parseIceConfigResponse(body);
if (!parsed.ok) {
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
throw new Error(parsed.error);
}
if (parsed.value.requestId !== id) throw new Error("ICE configuration response did not match the request");
if (parsed.value.type === "ice-config-unavailable") {
if (response.ok || response.status === 429 || response.status === 503) {
throw new IceConfigurationUnavailableError(parsed.value.retryAfterMs, response.status);
}
throw new Error(`ICE configuration request failed (${response.status})`);
}
if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`);
if (!isIceConfigGrantActive(parsed.value, now())) {
throw new Error("ICE configuration grant is not active");
}
return {
configuration: {
iceServers: parsed.value.iceServers.map((server) => "username" in server
? {
urls: [...server.urls],
username: server.username,
credential: server.credential,
credentialType: server.credentialType,
}
: { urls: [...server.urls] }),
},
expiresAtMs: parsed.value.expiresAtMs,
};
}
+42
View File
@@ -0,0 +1,42 @@
/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */
export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const;
export interface IceConfigRequest {
type: "ice-config-request";
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
requestId: string;
}
export interface StunIceServer {
urls: readonly string[];
}
export interface TurnIceServer {
urls: readonly string[];
username: string;
credential: string;
credentialType: "password";
}
export interface IceConfigGrant {
type: "ice-config-grant";
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
requestId: string;
issuedAtMs: number;
expiresAtMs: number;
iceServers: readonly (StunIceServer | TurnIceServer)[];
}
export interface IceConfigUnavailable {
type: "ice-config-unavailable";
protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION;
requestId: string;
retryAfterMs: number;
}
export type IceConfigResponse = IceConfigGrant | IceConfigUnavailable;
export type IceConfigValidationResult<T> =
| { ok: true; value: T }
| { ok: false; error: string };
+123
View File
@@ -0,0 +1,123 @@
import {
ICE_CONFIG_PROTOCOL_VERSION,
type IceConfigGrant,
type IceConfigRequest,
type IceConfigResponse,
type IceConfigValidationResult,
type StunIceServer,
type TurnIceServer,
} from "./iceTypes.ts";
export const MAX_ICE_URLS = 8;
export const MAX_ICE_CREDENTIAL_LIFETIME_MS = 3_600_000;
type JsonRecord = Record<string, unknown>;
export function isSafeIceUrl(value: unknown): value is string {
if (typeof value !== "string" || value.length < 6 || value.length > 512) return false;
if (/[\u0000-\u0020\u007f]/.test(value) || /[/#@]/.test(value)) return false;
// TURN's one useful query parameter is the transport selector. Accept that
// exact grammar so deployments can offer UDP, TCP, and TLS fallbacks without
// opening a generic query-string channel for credentials or vendor options.
const match = /^(stun|stuns|turn|turns):([^?]+)(?:\?transport=(udp|tcp))?$/.exec(value);
if (!match) return false;
const scheme = match[1] as string;
const authority = match[2] as string;
const transport = match[3];
if (transport !== undefined && scheme !== "turn" && scheme !== "turns") return false;
if (authority.startsWith("[") && /^\[[0-9a-fA-F:.]+\](?::[1-9][0-9]{0,4})?$/.test(authority)) {
return validPort(authority);
}
if (!/^[a-zA-Z0-9.-]+(?::[1-9][0-9]{0,4})?$/.test(authority)) return false;
const host = authority.replace(/:[0-9]+$/, "");
return host.length <= 253 && !host.startsWith(".") && !host.endsWith(".") &&
!host.includes("..") && validPort(authority);
}
export function parseIceConfigRequest(value: unknown): IceConfigValidationResult<IceConfigRequest> {
if (!exact(value, ["type", "protocolVersion", "requestId"]) ||
value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION ||
!identifier(value.requestId)) return failure("invalid request");
return success(value as unknown as IceConfigRequest);
}
export function parseIceConfigResponse(value: unknown): IceConfigValidationResult<IceConfigResponse> {
if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) {
return failure("invalid response envelope");
}
if (value.type === "ice-config-unavailable") {
if (!exact(value, ["type", "protocolVersion", "requestId", "retryAfterMs"]) ||
!boundedInteger(value.retryAfterMs, 1_000, 3_600_000)) return failure("invalid unavailable response");
return success(value as unknown as IceConfigResponse);
}
if (value.type !== "ice-config-grant" ||
!exact(value, ["type", "protocolVersion", "requestId", "issuedAtMs", "expiresAtMs", "iceServers"]) ||
!timestamp(value.issuedAtMs) || !timestamp(value.expiresAtMs) ||
value.expiresAtMs <= value.issuedAtMs ||
value.expiresAtMs - value.issuedAtMs > MAX_ICE_CREDENTIAL_LIFETIME_MS ||
!Array.isArray(value.iceServers) || value.iceServers.length < 1 || value.iceServers.length > 2) {
return failure("invalid grant");
}
const servers = value.iceServers;
if (!servers.every(iceServer) || !servers.some(isTurnIceServer)) return failure("grant requires TURN");
return success(value as unknown as IceConfigGrant);
}
export function isIceConfigGrantActive(value: IceConfigGrant, nowMs: number): boolean {
return parseIceConfigResponse(value).ok && timestamp(nowMs) &&
nowMs >= value.issuedAtMs && nowMs < value.expiresAtMs;
}
function iceServer(value: unknown): value is StunIceServer | TurnIceServer {
if (!plain(value)) return false;
if (exact(value, ["urls"])) return urls(value.urls, ["stun:", "stuns:"]);
return isTurnIceServer(value);
}
function isTurnIceServer(value: unknown): value is TurnIceServer {
return exact(value, ["urls", "username", "credential", "credentialType"]) &&
urls(value.urls, ["turn:", "turns:"]) && opaque(value.username, 16, 256) &&
opaque(value.credential, 20, 256) && value.credentialType === "password";
}
function urls(value: unknown, schemes: readonly string[]): value is readonly string[] {
return Array.isArray(value) && value.length > 0 && value.length <= MAX_ICE_URLS &&
value.every((url) => isSafeIceUrl(url) && schemes.some((scheme) => url.startsWith(scheme))) &&
new Set(value).size === value.length;
}
function validPort(authority: string): boolean {
const match = /:([0-9]+)$/.exec(authority);
return match === null || Number(match[1]) <= 65_535;
}
function timestamp(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 8_640_000_000_000_000;
}
function boundedInteger(value: unknown, minimum: number, maximum: number): value is number {
return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum;
}
function identifier(value: unknown): value is string {
return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value);
}
function opaque(value: unknown, minimum: number, maximum: number): value is string {
return typeof value === "string" && value.length >= minimum && value.length <= maximum &&
/^[a-zA-Z0-9_:+/=.-]+$/.test(value);
}
function plain(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype;
}
function exact(value: unknown, keys: readonly string[]): value is JsonRecord {
if (!plain(value)) return false;
const actual = Object.keys(value);
return actual.length === keys.length && actual.every((key) => keys.includes(key));
}
function success<T>(value: T): IceConfigValidationResult<T> { return { ok: true, value }; }
function failure<T>(error: string): IceConfigValidationResult<T> { return { ok: false, error: `ice config: ${error}` }; }
+14
View File
@@ -21,6 +21,7 @@ export {
createOfficeScreenPanel,
type OfficeScreenPanel,
type OfficeScreenPanelOptions,
type OfficeScreenRemoteStatus,
type OfficeScreenPanelState,
} from "./officeScreenPanel.ts";
export {
@@ -43,3 +44,16 @@ export {
parseScreenShareClientMessage,
parseScreenShareServerMessage,
} from "./signalingValidation.ts";
export * from "./iceTypes.ts";
export {
isIceConfigGrantActive,
isSafeIceUrl,
parseIceConfigRequest,
parseIceConfigResponse,
} from "./iceValidation.ts";
export {
fetchEphemeralIceConfiguration,
IceConfigurationUnavailableError,
type EphemeralIceConfiguration,
type FetchEphemeralIceConfigurationOptions,
} from "./iceClient.ts";
+50 -6
View File
@@ -2,6 +2,8 @@
import type { MediaSurfaceDescriptor } from "./presentation.ts";
export type OfficeScreenRemoteStatus = "off" | "connecting" | "live" | "reconnecting" | "stopped" | "unavailable" | "error";
export interface OfficeScreenPanelOptions {
container: HTMLElement;
surfaces: readonly MediaSurfaceDescriptor[];
@@ -19,6 +21,7 @@ export interface OfficeScreenPanelState {
/** Explicit, local viewing choices. Empty initially and after disposal. */
optedInScreenIds: string[];
surfaces: MediaSurfaceDescriptor[];
remoteStatusByScreen: Record<string, OfficeScreenRemoteStatus>;
}
export interface OfficeScreenPanel {
@@ -27,6 +30,7 @@ export interface OfficeScreenPanel {
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
close(): OfficeScreenPanelState;
state(): OfficeScreenPanelState;
setRemoteStatus(screenId: string, status: OfficeScreenRemoteStatus): OfficeScreenPanelState;
dispose(): void;
}
@@ -53,6 +57,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
let surfaces = validateSurfaces(options.surfaces);
let selectedId: string | null = surfaces[0]?.screenId ?? null;
const optedIn = new Set<string>();
const remoteStatuses = new Map<string, OfficeScreenRemoteStatus>();
let isOpen = false;
let disposed = false;
let invoker: HTMLElement | null = null;
@@ -79,7 +84,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
const intro = doc.createElement("p");
intro.id = introId;
intro.className = "tera-screen-panel__intro";
intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing.";
intro.textContent = "Media is off by default. Opt in to view a remote screen, or choose Share screen to present your own.";
const list = doc.createElement("div");
list.className = "tera-screen-panel__list";
list.setAttribute("role", "list");
@@ -97,6 +102,11 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
return surfaces.find((surface) => surface.screenId === selectedId) ?? null;
}
function remoteCanStop(screenId: string): boolean {
const status = remoteStatuses.get(screenId);
return status === "connecting" || status === "live" || status === "reconnecting";
}
function render(): void {
for (const child of [...list.children]) child.remove();
if (surfaces.length === 0) {
@@ -117,7 +127,15 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
meta.className = "tera-screen-panel__meta";
const room = surface.roomId ?? "Unassigned room";
const status = surface.bound ? "Media active" : "Media off";
meta.textContent = `${surface.levelId} · ${room} · ${status}`;
const remote = remoteStatuses.get(surface.screenId) ?? "off";
const remoteLabel = remote === "off" ? "Remote off"
: remote === "connecting" ? "Remote connecting"
: remote === "live" ? "Remote live"
: remote === "reconnecting" ? "Remote reconnecting"
: remote === "stopped" ? "Remote stopped"
: remote === "unavailable" ? "No remote share"
: "Remote error";
meta.textContent = `${surface.levelId} · ${room} · ${status} · ${remoteLabel}`;
row.append(name, meta);
row.addEventListener("click", () => {
selectedId = surface.screenId;
@@ -129,15 +147,26 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
const surface = selected();
const viewing = surface ? optedIn.has(surface.screenId) : false;
optButton.disabled = surface === null;
shareButton.disabled = surface === null || !viewing;
stopButton.disabled = surface === null || !surface.bound;
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view";
// Presenting is its own explicit action. Requiring viewer opt-in here would
// start a remote viewer join immediately before replacing it with capture.
shareButton.disabled = surface === null;
stopButton.disabled = surface === null || (!surface.bound && !remoteCanStop(surface.screenId));
const retry = surface !== null && viewing && ["stopped", "unavailable", "error"]
.includes(remoteStatuses.get(surface.screenId) ?? "off");
optButton.textContent = retry ? "Retry remote" : viewing ? "Stop viewing" : "Opt in to view";
optButton.setAttribute("aria-pressed", String(viewing));
}
optButton.addEventListener("click", () => {
const surface = selected();
if (!surface) return;
const remote = remoteStatuses.get(surface.screenId);
if (optedIn.has(surface.screenId) &&
(remote === "stopped" || remote === "unavailable" || remote === "error")) {
render();
options.onViewerOptIn?.(copySurface(surface), true);
return;
}
const next = !optedIn.has(surface.screenId);
if (next) optedIn.add(surface.screenId);
else optedIn.delete(surface.screenId);
@@ -150,7 +179,9 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
});
stopButton.addEventListener("click", () => {
const surface = selected();
if (surface?.bound) options.onStopShare?.(copySurface(surface));
if (surface && (surface.bound || remoteCanStop(surface.screenId))) {
options.onStopShare?.(copySurface(surface));
}
});
closeButton.addEventListener("click", () => close());
@@ -160,6 +191,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
selectedId,
optedInScreenIds: [...optedIn],
surfaces: surfaces.map(copySurface),
remoteStatusByScreen: Object.fromEntries(remoteStatuses),
};
}
@@ -221,17 +253,29 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
surfaces = validateSurfaces(next);
const ids = new Set(surfaces.map((surface) => surface.screenId));
for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id);
for (const id of remoteStatuses.keys()) if (!ids.has(id)) remoteStatuses.delete(id);
if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
render();
return snapshot();
},
close,
state: snapshot,
setRemoteStatus(screenId, status) {
if (disposed || !surfaces.some((surface) => surface.screenId === screenId)) return snapshot();
if (!["off", "connecting", "live", "reconnecting", "stopped", "unavailable", "error"].includes(status)) {
throw new TypeError("office screen panel: invalid remote status");
}
if (status === "off") remoteStatuses.delete(screenId);
else remoteStatuses.set(screenId, status);
render();
return snapshot();
},
dispose() {
if (disposed) return;
close();
disposed = true;
optedIn.clear();
remoteStatuses.clear();
surfaces = [];
selectedId = null;
root.remove();
+31 -9
View File
@@ -60,11 +60,22 @@ export interface PresenterStart {
stream: MediaStream;
video: HTMLVideoElement;
}
export interface ViewerStart {
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
decision: MediaAuthorizationDecision;
video: HTMLVideoElement;
}
export type ViewerStart =
| {
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
decision: MediaAuthorizationDecision;
viewerOptIn?: never;
video: HTMLVideoElement;
}
| {
/**
* Literal user action for signaling servers that perform authorization
* during join and intentionally expose no source locator to the client.
*/
decision?: never;
viewerOptIn: true;
video: HTMLVideoElement;
};
export interface RemoteOfficeMedia {
startPresenter(input: PresenterStart): Promise<void>;
startViewer(input: ViewerStart): Promise<void>;
@@ -310,6 +321,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
cursor = advanced.value;
if (message.type === "screen-share-stopped") {
closeTransport();
// The server has already destroyed the signaling session. Forget the
// capability now so caller cleanup cannot issue a redundant leave that
// is guaranteed to fail authorization.
grant = null;
setStatus("stopped");
return;
}
@@ -412,7 +427,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
if (!message.continuous) resetPeerMesh();
await reconcile(message.participants);
scheduleRenewal();
setStatus("connecting");
// A presenter with no viewers is still live: the hosted signaling session
// is accepting authorized viewers. A viewer remains connecting until its
// receive-only peer actually reaches the connected state.
setStatus(options.role === "presenter" && peers.size === 0 ? "live" : "connecting");
return true;
}
@@ -492,6 +510,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
await reconcile(response.participants);
scheduleRenewal();
if (role === "presenter" && peers.size === 0) setStatus("live");
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); });
}
@@ -525,9 +544,12 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
async function startViewer(input: ViewerStart): Promise<void> {
if (options.role !== "viewer") throw new Error("remote media: adapter is not a viewer");
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
if (!input.decision.authorized || !input.decision.optedIn || !input.decision.canView ||
input.decision.surface.screenId !== binding.screenId || input.decision.surface.officeId !== binding.officeId ||
input.decision.surface.source?.kind !== "live-stream") {
const ready = input.decision === undefined
? input.viewerOptIn === true
: input.decision.authorized && input.decision.optedIn && input.decision.canView &&
input.decision.surface.screenId === binding.screenId && input.decision.surface.officeId === binding.officeId &&
input.decision.surface.source?.kind === "live-stream";
if (!ready) {
throw new Error("remote media: a ready live-stream authorization decision is required");
}
input.video.autoplay = false;
+124
View File
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
fetchEphemeralIceConfiguration,
IceConfigurationUnavailableError,
} from "../media/iceClient.ts";
describe("ephemeral ICE configuration client", () => {
it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => {
let input: RequestInfo | URL | undefined;
let init: RequestInit | undefined;
const result = await fetchEphemeralIceConfiguration({
authenticatedFetch: async (nextInput, nextInit) => {
input = nextInput;
init = nextInit;
return Response.json({
type: "ice-config-grant",
protocolVersion: 1,
requestId: "ice-test-1",
issuedAtMs: 100,
expiresAtMs: 600_000,
iceServers: [
{ urls: ["stun:relay.example.test:3478"] },
{
urls: ["turns:relay.example.test:5349"],
username: "temporary-user-01",
credential: "temporary-password-01",
credentialType: "password",
},
],
});
},
now: () => 200,
requestId: () => "ice-test-1",
});
assert.equal(input, "/api/v1/media/ice");
assert.equal(init?.method, "POST");
assert.equal(init?.credentials, "same-origin");
assert.equal(init?.cache, "no-store");
assert.deepEqual(JSON.parse(String(init?.body)), {
type: "ice-config-request",
protocolVersion: 1,
requestId: "ice-test-1",
});
assert.deepEqual(result.configuration.iceServers, [
{ urls: ["stun:relay.example.test:3478"] },
{
urls: ["turns:relay.example.test:5349"],
username: "temporary-user-01",
credential: "temporary-password-01",
credentialType: "password",
},
]);
assert.equal(result.expiresAtMs, 600_000);
});
it("rejects unavailable, mismatched, expired, and malformed responses", async () => {
const run = (body: unknown) => fetchEphemeralIceConfiguration({
authenticatedFetch: async () => Response.json(body),
now: () => 200,
requestId: () => "ice-test-1",
});
await assert.rejects(run({
type: "ice-config-unavailable",
protocolVersion: 1,
requestId: "ice-test-1",
retryAfterMs: 10_000,
}), /temporarily unavailable/);
await assert.rejects(run({
type: "ice-config-unavailable",
protocolVersion: 1,
requestId: "ice-test-2",
retryAfterMs: 10_000,
}), /did not match/);
await assert.rejects(run({
type: "ice-config-grant",
protocolVersion: 1,
requestId: "ice-test-1",
issuedAtMs: 10,
expiresAtMs: 100,
iceServers: [{
urls: ["turn:relay.example.test:3478"],
username: "temporary-user-01",
credential: "temporary-password-01",
credentialType: "password",
}],
}), /not active/);
await assert.rejects(run({ profile: "must-not-parse" }), /invalid response/);
});
it("exposes validated 429 and 503 retry timing without exposing an untrusted body", async () => {
for (const status of [429, 503]) {
await assert.rejects(fetchEphemeralIceConfiguration({
authenticatedFetch: async () => Response.json({
type: "ice-config-unavailable",
protocolVersion: 1,
requestId: "ice-backoff-1",
retryAfterMs: 12_345,
}, { status }),
requestId: () => "ice-backoff-1",
}), (error: unknown) => {
assert.ok(error instanceof IceConfigurationUnavailableError);
assert.equal(error.retryAfterMs, 12_345);
assert.equal(error.status, status);
return true;
});
}
await assert.rejects(fetchEphemeralIceConfiguration({
authenticatedFetch: async () => Response.json({
type: "ice-config-unavailable",
protocolVersion: 1,
requestId: "somebody-elses-request",
retryAfterMs: 12_345,
}, { status: 503 }),
requestId: () => "ice-backoff-1",
}), /did not match/);
await assert.rejects(fetchEphemeralIceConfiguration({
authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }),
requestId: () => "ice-backoff-1",
}), /failed \(503\)/);
});
});
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
isIceConfigGrantActive,
isSafeIceUrl,
parseIceConfigRequest,
parseIceConfigResponse,
type IceConfigGrant,
} from "../media/index.ts";
const grant = (): IceConfigGrant => ({
type: "ice-config-grant",
protocolVersion: 1,
requestId: "ice-request-1",
issuedAtMs: 1_000,
expiresAtMs: 601_000,
iceServers: [
{ urls: ["stun:relay.example.test:3478"] },
{
urls: ["turn:relay.example.test:3478", "turns:relay.example.test:5349"],
username: "1700000600:opaque_nonce_value",
credential: "dGVzdF9jcmVkZW50aWFsX3ZhbHVl",
credentialType: "password",
},
],
});
describe("ICE configuration wire contract", () => {
it("accepts exact requests and bounded ephemeral grants", () => {
assert.equal(parseIceConfigRequest({
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1",
}).ok, true);
assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true);
assert.equal(isIceConfigGrantActive(grant(), 300_000), true);
assert.equal(isIceConfigGrantActive(grant(), 601_000), false);
});
it("allows only credential-free ICE URL forms", () => {
for (const url of [
"stun:relay.example.test:3478", "stuns:relay.example.test:5349",
"turn:192.0.2.4:3478", "turn:relay.example.test:3478?transport=udp",
"turn:relay.example.test:3478?transport=tcp", "turns:[2001:db8::1]:5349?transport=tcp",
]) {
assert.equal(isSafeIceUrl(url), true, url);
}
for (const url of [
"https://relay.example.test", "turn:user:pass@relay.example.test:3478",
"turn:relay.example.test:3478?credential=secret", "turn://relay.example.test:3478",
"stun:relay.example.test:3478?transport=udp", "turn:relay.example.test:3478?transport=sctp",
"turn:relay.example.test:3478?transport=tcp&credential=secret",
"turn:relay.example.test:99999", "turn:relay example.test:3478",
]) assert.equal(isSafeIceUrl(url), false, url);
});
it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => {
assert.equal(parseIceConfigRequest({
type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user",
}).ok, false);
assert.equal(parseIceConfigResponse({ ...grant(), identity: { profile: "karti" } }).ok, false);
assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false);
assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false);
const turn = grant().iceServers[1] as unknown as Record<string, unknown>;
assert.equal(parseIceConfigResponse({
...grant(), iceServers: [{ ...turn, urls: ["turn:user@relay.example.test:3478"] }],
}).ok, false);
});
});
+47 -1
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createOfficeScreenPanel, type MediaSurfaceDescriptor } from "../media/index.ts";
import { createOfficeScreenPanel } from "../media/officeScreenPanel.ts";
import type { MediaSurfaceDescriptor } from "../media/presentation.ts";
type Listener = (event: FakeEvent) => void;
class FakeEvent {
@@ -121,6 +122,18 @@ describe("office screen manager panel", () => {
assert.equal("mediaDevices" in panel, false);
});
it("keeps presenting separate from viewer opt-in", () => {
const { panel, shares, opts } = setup();
panel.open();
const root = panel.root as unknown as FakeElement;
const share = root.find("data-action", "share");
assert.equal(share.disabled, false);
share.dispatch("click");
assert.deepEqual(shares, ["lobby-monitor"]);
assert.deepEqual(opts, []);
assert.deepEqual(panel.state().optedInScreenIds, []);
});
it("updates defensively, removes stale consent, and represents an empty office", () => {
const { panel } = setup();
const root = panel.root as unknown as FakeElement;
@@ -136,6 +149,39 @@ describe("office screen manager panel", () => {
assert.match(root.text(), /No authored office screens/);
});
it("shows concise remote lifecycle states and forgets them with their screen", () => {
const { panel, stops, opts } = setup();
const root = panel.root as unknown as FakeElement;
root.find("data-action", "opt-in").dispatch("click");
panel.setRemoteStatus("lobby-monitor", "connecting");
assert.equal(panel.state().remoteStatusByScreen["lobby-monitor"], "connecting");
assert.match(root.text(), /Remote connecting/);
assert.equal(root.find("data-action", "stop").disabled, false);
root.find("data-action", "stop").dispatch("click");
assert.deepEqual(stops, ["lobby-monitor"]);
panel.setRemoteStatus("lobby-monitor", "unavailable");
assert.match(root.text(), /No remote share/);
assert.equal(root.find("data-action", "opt-in").textContent, "Retry remote");
root.find("data-action", "opt-in").dispatch("click");
assert.deepEqual(opts.at(-1), ["lobby-monitor", true]);
assert.deepEqual(panel.state().optedInScreenIds, ["lobby-monitor"]);
panel.update([SURFACES[1]!]);
assert.deepEqual(panel.state().remoteStatusByScreen, {});
assert.throws(() => panel.setRemoteStatus("commons-display", "invalid" as "live"), /invalid remote status/);
});
it("refreshes a remote surface from bound back to cleared", () => {
const { panel } = setup();
const root = panel.root as unknown as FakeElement;
panel.setRemoteStatus("lobby-monitor", "live");
panel.update([{ ...SURFACES[0]!, bound: true }, SURFACES[1]!]);
assert.match(root.text(), /Media active · Remote live/);
panel.update(SURFACES);
panel.setRemoteStatus("lobby-monitor", "off");
assert.match(root.text(), /Media off · Remote off/);
assert.equal(root.find("data-action", "stop").disabled, true);
});
it("traps tab focus, closes on Escape, restores focus, and disposes idempotently", () => {
const { document, container, panel } = setup();
const trigger = document.createElement("button");
+37 -1
View File
@@ -182,6 +182,7 @@ describe("remote office media presenter", () => {
/explicit opt-in/,
);
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement });
assert.equal(media.state().status, "live", "an empty hosted room is ready for its first viewer");
await settle();
assert.equal(peers.length, 1);
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
@@ -324,7 +325,7 @@ describe("remote office media viewer", () => {
});
const video = new FakeVideo();
await assert.rejects(media.startViewer({ decision: decision(false), video: video as unknown as HTMLVideoElement }), /ready live-stream/);
await media.startViewer({ decision: decision(), video: video as unknown as HTMLVideoElement });
await media.startViewer({ viewerOptIn: true, video: video as unknown as HTMLVideoElement });
await settle();
assert.equal(peers[0]?.transceivers[0], "video");
assert.deepEqual(peers[0]?.remote[0], { type: "offer", sdp: "presenter-offer" });
@@ -340,4 +341,39 @@ describe("remote office media viewer", () => {
assert.equal(video.srcObject, null);
assert.equal(media.state().status, "revoked");
});
it("forgets its grant when the server has already stopped the session", async () => {
const presenterPeer = { participantId: "presenter-opaque", role: "presenter" as const };
let emitEvent: (value: Uint8Array) => void = () => { throw new Error("event stream is not open"); };
let leaveCalls = 0;
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
}
if (String(input).endsWith("/events")) {
const stream = new ReadableStream<Uint8Array>({ start(controller) { emitEvent = (value) => controller.enqueue(value); } });
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
emitEvent(new TextEncoder().encode(`data: ${JSON.stringify(resume)}\n\n`));
return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" } });
}
if (String(input).endsWith("/leave")) leaveCalls += 1;
return new Response(null, { status: 204 });
};
const media = createRemoteOfficeMedia({
role: "viewer", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
peerConnectionFactory: () => new FakePeer() as unknown as RTCPeerConnection,
});
await media.startViewer({ viewerOptIn: true, video: new FakeVideo() as unknown as HTMLVideoElement });
await settle();
emitEvent(new TextEncoder().encode(`data: ${JSON.stringify({
type: "screen-share-stopped", protocolVersion: 1, sequence: 2, timestampMs: NOW + 1,
sessionId: "share-session", binding: BINDING, reason: "presenter-stopped",
})}\n\n`));
await settle();
assert.equal(media.state().status, "stopped");
assert.equal(media.state().sessionId, null);
await media.dispose();
assert.equal(leaveCalls, 0, "cleanup does not reuse a server-invalidated capability");
});
});