import type { EventKind } from "@a4anthony/proctorkit-types"; import { systemCaptureClock, type CaptureClock, } from "../internal/capture-clock.js"; import type { RecordingChunkTiming } from "./media-capture-timing.js"; export interface ObserverEmitter { emit(kind: EventKind, payload?: Record, timestamp?: number): void; } export interface ScreenShareObserverConfig { /** * Pre-acquired display stream. Use this for staged flows that need * to open the browser picker before the runtime SDK starts, then * hand the granted stream to the SDK for recording. * * When omitted, the observer calls getDisplayMedia() itself. */ stream?: MediaStream; /** * Whether stop() should release a stream supplied via `stream`. * Defaults to false so customer-owned streams are not killed unless * the caller explicitly transfers lifecycle ownership to the SDK. */ stopExternalStreamOnStop?: boolean; /** * Reject anything other than `displaySurface === "monitor"`. When the * candidate picks a window or browser tab the SDK stops the stream * immediately, emits `screen-share.wrong-surface`, and the start() * promise rejects with {@link ScreenShareDeclinedError}. Default: true. * * Set to false to accept any shared surface (the customer can filter * on the `surface` field of `screen-share.started` themselves). */ enforceEntireScreen?: boolean; /** * MediaRecorder chunk duration in milliseconds. The recorder emits one * Blob per timeslice. Smaller = more rows in the DB but quicker * recovery if a chunk upload fails. Default: 10_000 (10s). */ timesliceMs?: number; /** * Target bitrate for the video track in bits per second. Default: * 500_000 (500 kbps) — small enough that 10s chunks are ~625KB. */ videoBitrate?: number; /** * Acquire and validate the browser screen-share stream, but do not * attach MediaRecorder until startRecording() is called. Useful for * flows that want the candidate to grant screen-share first, then * start actual recording only after the test enters fullscreen. * Default: false. */ deferRecording?: boolean; /** * Called with each chunk the recorder produces. In Phase 1 this is * where the SDK demo logs metadata; Phase 2 hooks the worker upload * channel into this. Receives the chunk number (1-indexed) and the * Blob itself. */ onChunkReady?: ( chunkNumber: number, blob: Blob, timing: RecordingChunkTiming, ) => void; } interface ResolvedConfig { externalStream: MediaStream | undefined; stopExternalStreamOnStop: boolean; enforceEntireScreen: boolean; timesliceMs: number; videoBitrate: number; deferRecording: boolean; onChunkReady: | ((chunkNumber: number, blob: Blob, timing: RecordingChunkTiming) => void) | undefined; } export type ScreenShareErrorKind = | "activation-required" | "permission-denied" | "unsupported" | "wrong-surface" | "track-ended"; export interface RequestScreenShareOptions { /** Reject a window or browser-tab selection. Default: true. */ enforceEntireScreen?: boolean; } /** * Thrown when screen sharing cannot start. The `kind` identifies activation, * permission, browser support, selected surface, or track-lifecycle failures. */ export class ScreenShareDeclinedError extends Error { readonly kind: ScreenShareErrorKind; constructor(kind: ScreenShareErrorKind, message?: string, options?: { cause?: unknown }) { super(message ?? screenShareErrorMessage(kind), options); this.name = "ScreenShareDeclinedError"; this.kind = kind; } } /** * Opens the browser screen picker immediately. Call this as the first action * inside the candidate's click handler, then pass the returned stream through * `observers.screenShare.stream`. */ export async function requestScreenShare( options: RequestScreenShareOptions = {}, ): Promise { if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) { throw new ScreenShareDeclinedError( "unsupported", "Screen sharing is not supported by this browser.", ); } let stream: MediaStream; try { stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions()); } catch (error) { throw classifyScreenShareRequestError(error); } try { validateScreenShareStream(stream, options.enforceEntireScreen ?? true); } catch (error) { releaseStream(stream, true); throw error; } return stream; } /** * Accepts a pre-acquired screen stream or requests one through * `getDisplayMedia`, enforces the chosen surface, and records it into chunks * via `MediaRecorder`. Prefer the exported `requestScreenShare()` helper so * acquisition happens directly from the candidate's action. * * The observer DOES NOT paint any UI — the browser's native picker is * the only user-facing element. Customers own the surrounding UX: * explainer copy before the click, retry/decline handling after. */ export class ScreenShareObserver { private readonly config: ResolvedConfig; private stream: MediaStream | null = null; private recorder: MediaRecorder | null = null; private recordingChunkStartedAt: number | null = null; private chunkCount = 0; private stopped = false; private stopPromise: Promise | null = null; private trackEndedListener: (() => void) | null = null; constructor( private readonly emitter: ObserverEmitter, config: ScreenShareObserverConfig | boolean | undefined, private readonly clock: CaptureClock = systemCaptureClock, ) { this.config = resolveConfig(config); } /** * Prompts the browser picker and validates the selected surface. * Unless `deferRecording` is enabled, this also starts recording. * Resolves once the stream is up (and recording has started in the * default path). Rejects with * {@link ScreenShareDeclinedError} on decline or wrong surface. * * When no stream is supplied, this must run during transient user * activation. `ProctoringClient` preserves that activation by opening its * compatibility picker synchronously before worker startup. */ async start(): Promise { let stream: MediaStream; if (this.config.externalStream) { stream = this.config.externalStream; const track = stream.getVideoTracks()[0]; if (!track) { this.emitter.emit("screen-share.declined", { reason: "no-video-track" }); throw new ScreenShareDeclinedError("track-ended", "Stream had no video track"); } if (track.readyState === "ended") { this.emitter.emit("screen-share.declined", { reason: "track-ended" }); throw new ScreenShareDeclinedError("track-ended", "Stream track has already ended"); } } else { try { // Surface enforcement stays below so the observer can emit the // established wrong-surface event with the chosen surface value. stream = await requestScreenShare({ enforceEntireScreen: false }); } catch (err) { this.emitter.emit("screen-share.declined", { reason: err instanceof ScreenShareDeclinedError ? err.kind : String(err), }); throw err; } } const track = stream.getVideoTracks()[0]; if (!track) { releaseStream(stream, !this.config.externalStream || this.config.stopExternalStreamOnStop); this.emitter.emit("screen-share.declined", { reason: "no-video-track" }); throw new ScreenShareDeclinedError("track-ended", "Stream had no video track"); } const settings = track.getSettings() as MediaTrackSettings & { displaySurface?: string; }; const surface = settings.displaySurface ?? "unknown"; if (this.config.enforceEntireScreen && surface !== "monitor") { releaseStream(stream, !this.config.externalStream || this.config.stopExternalStreamOnStop); this.emitter.emit("screen-share.wrong-surface", { surface }); throw new ScreenShareDeclinedError( "wrong-surface", `Expected entire screen, got "${surface}"`, ); } this.stream = stream; this.emitter.emit("screen-share.started", { surface }); // Track ended: candidate stops sharing via the browser's "Stop // sharing" toolbar. Different from us calling stop() — surface it // as an event so the customer can react. this.trackEndedListener = () => { this.emitter.emit("screen-share.stopped", { reason: "track-ended" }); void this.stop("track-ended"); }; track.addEventListener("ended", this.trackEndedListener); if (!this.config.deferRecording) { this.startRecording(); } } /** * Attach MediaRecorder to the already-active screen-share stream. * Returns false when the stream is unavailable or this browser cannot * record it. Idempotent once recording is active. */ startRecording(): boolean { if (this.stopped || !this.stream) return false; if (this.recorder && this.recorder.state !== "inactive") return true; return this.startRecorder(this.stream); } /** * Ask MediaRecorder to emit its current partial segment while continuing * to record. Used on lifecycle transitions where the page may disappear. */ flushRecordingData(): void { if (!this.recorder || this.recorder.state !== "recording") return; try { this.recorder.requestData(); } catch { // Best effort: some browsers reject during a recorder state transition. } } /** Capture the current shared-screen frame without interrupting recording. */ async captureEvidenceFrame() { if (!this.stream) return null; const { captureMediaStreamFrame } = await import( "../event-evidence/capture-media-stream-frame.js" ); return captureMediaStreamFrame(this.stream, () => this.clock.now()); } /** * Stops the recorder and the underlying tracks. Idempotent. `reason` * is purely informational — recording always terminates the same way. * * Awaits the recorder's final `dataavailable` + `stop` events before * resolving. MediaRecorder's spec guarantees a final dataavailable * fires after `stop()` is called, carrying whatever frames were * buffered since the last timeslice tick. Without this await, a * candidate hitting Stop session 4 seconds into a 10-second chunk * would lose those 4 seconds — the JS context would tear down before * the chunk reached the uploader. The cost is a one-tick wait on * end-of-session; the win is that mid-chunk stops don't drop the * tail. */ async stop(reason: "manual" | "track-ended" = "manual"): Promise { if (this.stopPromise) return this.stopPromise; if (this.stopped) return; this.stopPromise = this.stopInternal(reason); await this.stopPromise; } private async stopInternal(reason: "manual" | "track-ended"): Promise { this.stopped = true; let recorderWasActive = false; if (this.recorder && this.recorder.state !== "inactive") { recorderWasActive = true; await new Promise((resolve) => { const rec = this.recorder!; rec.addEventListener("stop", () => resolve(), { once: true }); try { rec.stop(); } catch { // Recorder can throw if the underlying track is already gone — // resolve so callers don't hang on a broken recorder. resolve(); } }); } this.recorder = null; if (recorderWasActive) { this.emitter.emit("screen-share.recording.stopped", { reason }); } if (this.stream) { const track = this.stream.getVideoTracks()[0]; if (track && this.trackEndedListener) { track.removeEventListener("ended", this.trackEndedListener); } this.trackEndedListener = null; releaseStream( this.stream, !this.config.externalStream || this.config.stopExternalStreamOnStop, ); this.stream = null; } if (reason === "manual") { this.emitter.emit("screen-share.stopped", { reason: "manual" }); } } private startRecorder(stream: MediaStream): boolean { if (typeof MediaRecorder === "undefined") { // No recorder available — events are still emitted but we can't // produce chunks. The customer can still see start/stop in the // timeline; recording will resume the next time the SDK runs in // a browser that supports it. this.emitter.emit("screen-share.recording.unavailable", { reason: "media-recorder-unavailable", }); return false; } const mimeType = pickMimeType(); const recorder = new MediaRecorder(stream, { ...(mimeType ? { mimeType } : {}), videoBitsPerSecond: this.config.videoBitrate, }); const recordingStartedAt = this.clock.now(); this.recordingChunkStartedAt = recordingStartedAt; recorder.addEventListener("dataavailable", (event) => { if (!event.data || event.data.size === 0) return; const capturedEndAt = this.clock.now(); const capturedStartAt = this.recordingChunkStartedAt ?? recordingStartedAt; this.recordingChunkStartedAt = capturedEndAt; this.chunkCount += 1; this.config.onChunkReady?.(this.chunkCount, event.data, { recordingStartedAt, capturedStartAt, capturedEndAt, }); }); recorder.start(this.config.timesliceMs); this.recorder = recorder; this.emitter.emit( "screen-share.recording.started", { timesliceMs: this.config.timesliceMs, videoBitrate: this.config.videoBitrate, }, recordingStartedAt, ); return true; } } function resolveConfig(raw: ScreenShareObserverConfig | boolean | undefined): ResolvedConfig { const base: ResolvedConfig = { externalStream: undefined, stopExternalStreamOnStop: false, enforceEntireScreen: true, timesliceMs: 10_000, videoBitrate: 500_000, deferRecording: false, onChunkReady: undefined, }; if (raw === undefined || raw === false || raw === true) return base; return { externalStream: raw.stream, stopExternalStreamOnStop: raw.stopExternalStreamOnStop ?? base.stopExternalStreamOnStop, enforceEntireScreen: raw.enforceEntireScreen ?? base.enforceEntireScreen, timesliceMs: raw.timesliceMs ?? base.timesliceMs, videoBitrate: raw.videoBitrate ?? base.videoBitrate, deferRecording: raw.deferRecording ?? base.deferRecording, onChunkReady: raw.onChunkReady, }; } function releaseStream(stream: MediaStream, shouldStop: boolean): void { if (!shouldStop) return; stream.getTracks().forEach((track) => track.stop()); } function validateScreenShareStream(stream: MediaStream, enforceEntireScreen: boolean): void { const track = stream.getVideoTracks()[0]; if (!track || track.readyState === "ended") { throw new ScreenShareDeclinedError("track-ended", "Screen-share track is unavailable."); } const settings = track.getSettings() as MediaTrackSettings & { displaySurface?: string }; const surface = settings.displaySurface ?? "unknown"; if (enforceEntireScreen && surface !== "monitor") { throw new ScreenShareDeclinedError("wrong-surface", `Expected entire screen, got "${surface}"`); } } function classifyScreenShareRequestError(error: unknown): ScreenShareDeclinedError { if (error instanceof ScreenShareDeclinedError) return error; if ( typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "InvalidStateError" ) { return new ScreenShareDeclinedError( "activation-required", "Screen sharing must be started from a candidate action.", { cause: error }, ); } if ( typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "NotSupportedError" ) { return new ScreenShareDeclinedError( "unsupported", "Screen sharing is not supported by this browser.", { cause: error }, ); } return new ScreenShareDeclinedError("permission-denied", "Screen sharing was not granted.", { cause: error, }); } function screenShareErrorMessage(kind: ScreenShareErrorKind): string { if (kind === "activation-required") return "Screen sharing requires a candidate action"; if (kind === "permission-denied") return "Screen sharing was not granted"; if (kind === "unsupported") return "Screen sharing is not supported"; if (kind === "wrong-surface") return "Wrong surface shared"; return "Screen-share track is unavailable"; } /** * Returns the best supported MIME type for screen recording, falling * back to undefined (which lets the browser pick) if nothing matches. * VP9 first — best size for our 500kbps target — then VP8 — then * any-WebM. */ function pickMimeType(): string | undefined { if (typeof MediaRecorder === "undefined") return undefined; const candidates = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]; for (const type of candidates) { if (MediaRecorder.isTypeSupported(type)) return type; } return undefined; } type ScreenCaptureOptions = DisplayMediaStreamOptions & { selfBrowserSurface?: "include" | "exclude"; monitorTypeSurfaces?: "include" | "exclude"; }; function displayMediaOptions(): ScreenCaptureOptions { return { video: { displaySurface: "monitor", }, audio: false, // Chromium/Edge hints. Browsers may ignore these; we still validate // track.getSettings().displaySurface after selection. selfBrowserSurface: "exclude", monitorTypeSurfaces: "include", }; }