import type { ObserverEmitter } from "./dom-observers.js"; /** * Fullscreen lock observer. * * Two responsibilities: * * 1. On `start()`, request fullscreen on the document root. * Must be called inside a user-gesture handler — the * Fullscreen API rejects otherwise. The customer page wires * ProctoringClient construction into a button click; we * piggyback on that same gesture. * * 2. Listen for `fullscreenchange` after entry. When the * candidate leaves fullscreen (Esc, Cmd-Q, OS interruption, * window switch), emit `fullscreen.exited` so the dashboard * can render an integrity flag AND the host UI can show a * "Return to fullscreen" modal that calls `requestEntry()` * from its own user-gesture handler. * * We can't force the browser back into fullscreen ourselves — * Fullscreen API requires a fresh user gesture every time. The * observer's job is to *detect* exits and *expose* a re-entry * method that the host UI binds to a button click. Anything else * is impossible by spec. */ export interface FullscreenObserverConfig { /** * Element to fullscreen. Defaults to document.documentElement so * the whole page is captured. Pass a specific element (eg. the * exam container) if the customer wants nav chrome to stay * visible — though most proctoring deployments want the whole * page. */ target?: HTMLElement; /** * Optional override: when true the observer also emits * `fullscreen.entered` events on successful (re-)entry so the * timeline has a complete trace. Default: true. */ emitEntries?: boolean; } export class FullscreenObserver { private readonly emitter: ObserverEmitter; private readonly target: HTMLElement; private readonly emitEntries: boolean; private handler: (() => void) | null = null; private wasFullscreen = false; private running = false; constructor(emitter: ObserverEmitter, config: FullscreenObserverConfig = {}) { this.emitter = emitter; this.target = config.target ?? (typeof document !== "undefined" ? document.documentElement : (null as unknown as HTMLElement)); this.emitEntries = config.emitEntries ?? true; } /** * Begin watching for fullscreen transitions AND request initial * fullscreen entry. Must be called inside a user-gesture * handler (the same one that gathered permission for * screen-share / webcam). * * Returns a promise that resolves to `true` if entry succeeded, * `false` otherwise. The observer keeps listening either way — * a "permission denied" fullscreen does NOT prevent the rest of * the session from running, it just means there's no integrity * lock and the host UI can surface a recovery panel. */ async start(): Promise { if (this.running) return this.wasFullscreen; this.running = true; if (typeof document === "undefined") return false; // Wire the change listener BEFORE the request so we don't // miss the initial entry event (some browsers fire it // synchronously within the requestFullscreen() promise). this.handler = () => this.onChange(); document.addEventListener("fullscreenchange", this.handler); const ok = await this.requestEntry({ reason: "session-start" }); return ok; } /** * Request fullscreen entry on the target. Public so the host UI * can call it from the "Return to fullscreen" button click * handler. Always runs requestFullscreen() in the current task * so the user-gesture context propagates. */ async requestEntry(opts: { reason?: string } = {}): Promise { if (typeof document === "undefined" || this.target === null) return false; if (document.fullscreenElement === this.target) { // Already in. Don't re-request — Safari throws on a no-op // request. return true; } try { await this.target.requestFullscreen({ navigationUI: "hide" }); // The fullscreenchange event will fire next tick and update // wasFullscreen + emit fullscreen.entered. We don't emit // here to avoid double-firing. return true; } catch (err) { this.emitter.emit("fullscreen.exited", { reason: opts.reason ?? "request-denied", error: err instanceof Error ? err.message : String(err), }); return false; } } /** * Stop watching. Does NOT exit fullscreen — the customer's * own client.stop() handler can do that with * document.exitFullscreen() if they want. */ stop(): void { if (!this.running) return; this.running = false; if (this.handler && typeof document !== "undefined") { document.removeEventListener("fullscreenchange", this.handler); } this.handler = null; } private onChange(): void { if (typeof document === "undefined") return; const isFs = document.fullscreenElement !== null; const prev = this.wasFullscreen; this.wasFullscreen = isFs; if (isFs && !prev) { if (this.emitEntries) { this.emitter.emit("fullscreen.entered", { at: new Date().toISOString(), }); } } else if (!isFs && prev) { // Exited. This is the integrity-flag case. this.emitter.emit("fullscreen.exited", { reason: "user-or-system", at: new Date().toISOString(), }); } } }