/** * Framework-agnostic pre-flight system check engine. * * Consumers can drive it either way: * * Async-generator (pull-based): * const engine = new SystemCheck(options); * for await (const snapshot of engine.run()) { * // snapshot is a frozen copy of the current rows * } * * Callback-based subscribe (push-based, for Vue's `watch` etc.): * const engine = new SystemCheck(options); * const unsubscribe = engine.subscribe((rows) => { ... }); * const report = await engine.runOnce(); * unsubscribe(); * * `runSimulated(results)` is a separate method, NOT a branch inside * `run()` — keeps the production path narrow and stories cleanly * deterministic. * * Output: * - `rows`: live array of `CheckRow` mirroring the engine's progress. * Each row updates in place but the array is replaced when iterating * via the async generator so React/Vue see a new reference. * - `runOnce()` resolves to a JSON-serialisable `PreflightReport` with * a final snapshot. * - `subscribe(cb)` calls `cb(rows)` after every state transition. */ import { applyConnectionQualityToResult, applySpeedToResult, checkBrowser, checkConnection, checkDevice, checkExternalMonitor, checkScreenLayout, measureSpeed, type CheckKind, type CheckOverrides, type CheckRow, type CheckState, type FailCode, } from "./checks.js"; import { createHttpConnectionProbe, measureConnectionQuality, type ConnectionQualityOptions, type HttpConnectionProbeOptions, } from "./connection-quality.js"; import { runDeepCameraCheck, type DeepCameraCheckOptions } from "./deep-camera-check.js"; export type { CheckKind, CheckOverrides, CheckRow, CheckState, FailCode }; export type SystemCheckMode = "boot" | "normal" | "recheck"; /** Which media checks to run. Off by default — keep the production path narrow. */ export interface MediaChecksConfig { microphone?: boolean; speaker?: boolean; camera?: boolean; /** * Verify the candidate can share their entire screen. The check * sits in a `permission-required` state at mount (it can't run * automatically because `getDisplayMedia` needs a user-activation * gesture). The wizard renders an action card; clicking the * button triggers the probe, which validates the chosen surface * is `monitor` and immediately stops the stream. */ screenShare?: boolean; } /** * Which system-class checks to include. Each is on by default -- * skipping one drops the row from the wizard entirely (not just * skipped, no chip in the summary). * * Practical reasons to toggle off: * - `browser`: the consuming app already gates browser * compatibility upstream (paywall, login screen, etc) so * re-checking here is noise. * - `device`: similar. * - `layout`: candidate UI handles its own responsive layout, * no need to fail at preflight. * - `externalMonitor`: the policy explicitly allows multi-monitor * setups (see also `thresholds.allowExternalMonitor` for the * finer-grained pass/fail policy). * - `connection`: low-stakes practice quiz, internet quality is * the candidate's problem. */ export interface SystemChecksConfig { /** Default: true. */ browser?: boolean; /** Default: true. */ device?: boolean; /** Default: true. */ layout?: boolean; /** Default: true. */ externalMonitor?: boolean; /** Default: true. */ connection?: boolean; } /** Browser capabilities that must be available before media steps begin. */ export interface RequiredCapabilitiesConfig { /** * Require `navigator.mediaDevices.getDisplayMedia`. When unavailable, the * initial device row fails with `screen-share-not-supported`. Default: false. */ screenShare?: boolean; } /** * Per-policy thresholds the customer can tune. Every field is * optional; omitted fields fall through to the documented default. * These knobs control *pass/fail rules*, not whether a check runs * (see `SystemChecksConfig` for the on/off toggle). */ export interface ThresholdsConfig { /** * Download-bandwidth reference used for internal quality telemetry. Browser * Mbps estimates do not decide candidate eligibility. Default: 2. */ minBandwidthMbps?: number; /** * Upload-bandwidth reference used for internal quality telemetry. Recording * readiness is derived from the enabled media workload instead. Default: 2. */ minUploadBandwidthMbps?: number; /** * Continuous-speech duration (ms) required before the mic check * verifies. The VAD waits this long after detecting voice before * declaring the mic working. Lower = more permissive but more * prone to misfires (single-word noises, coughs). * Default: 1500. */ micMinSpeechMs?: number; /** * Maximum allowed face count in the deep-camera frame for the * candidate to pass. * 1 = "exactly one face" (typical proctoring). * Higher values allow a chaperone or assistive companion in frame. * Default: 1. */ maxFacesAllowed?: number; /** * Whether to let candidates with an external monitor connected * pass the monitor check. Default: false (block multi-display * setups -- the canonical proctoring rule). */ allowExternalMonitor?: boolean; /** * Whether to let candidates on mobile devices (Android phones, * iPads, etc) pass the device check. Default: false. Note: iOS * specifically is also gated by `enableSafari`; both must be * truthy for an iPhone candidate to pass. */ allowMobile?: boolean; } export interface ConnectionReadinessCheckResult { mode: "direct" | "segments" | "post"; payloadBytes: number; completionWindowMs: number; sampleDurationsMs: number[]; medianCompletionMs: number; } type ReadinessTelemetry = Partial< Pick< CheckRow, | "recordingUploadMode" | "readinessPayloadBytes" | "readinessMedianMs" | "readinessWindowMs" | "readinessSampleCount" > >; function readinessErrorDetails(error: unknown): ReadinessTelemetry { if (!error || typeof error !== "object" || !("details" in error)) return {}; const details = (error as { details?: unknown }).details; if (!details || typeof details !== "object") return {}; const values = details as Record; const mode = values["recordingUploadMode"]; const payloadBytes = finiteNumber(values["readinessPayloadBytes"]); const medianMs = finiteNumber(values["readinessMedianMs"]); const windowMs = finiteNumber(values["readinessWindowMs"]); const sampleCount = finiteNumber(values["readinessSampleCount"]); return { ...(mode === "direct" || mode === "segments" || mode === "post" ? { recordingUploadMode: mode } : {}), ...(payloadBytes !== undefined ? { readinessPayloadBytes: payloadBytes } : {}), ...(medianMs !== undefined ? { readinessMedianMs: medianMs } : {}), ...(windowMs !== undefined ? { readinessWindowMs: windowMs } : {}), ...(sampleCount !== undefined ? { readinessSampleCount: Math.max(0, Math.floor(sampleCount)) } : {}), }; } function finiteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } export interface SystemCheckOptions { /** * Mode controls the row list and the short-circuit behaviour: * - `boot`: first-load wizard. Runs the same checks as `normal` * (browser + media), but consumers render the granular layout * instead of collapsing browser-class rows under a "System" row. * - `normal`: browser-class + media rows. Browser-class failures * mark media rows as `skipped`. * - `recheck`: same as `normal` but the consuming UI shouldn't auto- * resume on pass — it's the candidate's responsibility to click. * Default: `normal`. */ mode?: SystemCheckMode; /** Media rows to include. Default: none. */ media?: MediaChecksConfig; /** System rows to include. Each defaults to true (all five run). */ system?: SystemChecksConfig; /** Capabilities required by runtime policy. Default: none. */ requiredCapabilities?: RequiredCapabilitiesConfig; /** Per-policy pass/fail thresholds. All fields optional with documented defaults. */ thresholds?: ThresholdsConfig; /** * Treat the host as a development environment. Force-passes the * external-monitor check (otherwise devs working on two-monitor rigs * can't run the wizard). Tests opt in via stories. */ developmentMode?: boolean; /** Allow Safari/iOS through the browser + device checks. Off by default. */ enableSafari?: boolean; /** * Optional override map applied to individual checks. Used by stories * and integration tests to pin specific failures. */ overrides?: CheckOverrides; /** * Deep camera check configuration. `detectFace` is mandatory whenever * the camera row is enabled: the camera check returns pass only when * the detector reports a face count inside the configured policy band. */ deepCamera?: Omit; /** * Step delay used for visual pacing between row transitions. The * wizard looks too abrupt without this. Default: 300ms. Set to 0 in * tests to skip pacing. */ stepDelayMs?: number; /** * Minimum time the connection row stays in the "Testing speed..." * state. A fast same-origin measurement (often <100ms) makes the * spinner flash imperceptibly. Default: 700ms. */ minSpeedTestMs?: number; /** * Optional production-path upload/download measurement. When omitted, the * legacy Cloudflare download-only probe remains available for standalone * SDK consumers. */ connectionTest?: HttpConnectionProbeOptions & ConnectionQualityOptions; /** * Optional workload-aware recording-path check. When supplied, this is the * candidate eligibility signal; numeric speed tests remain telemetry only. */ connectionReadiness?: () => Promise; } /** JSON-serialisable final report. No class instances, safe to log + persist. */ export interface PreflightReport { mode: SystemCheckMode; passed: boolean; rows: CheckRow[]; failures: Array<{ kind: CheckKind; code: FailCode; detail: string }>; finishedAt: number; } /** Result shape used by `runSimulated()`. */ export interface SimulatedCheckResult { kind: CheckKind; state: CheckState; photo?: string; mbps?: number; } /** * Listener invoked after every row state transition. Receives a frozen * copy of the rows array so the caller can rely on referential * inequality between snapshots. */ export type SystemCheckListener = (rows: ReadonlyArray) => void; const BROWSER_CLASS_KINDS: CheckKind[] = ["browser", "device", "layout", "monitor", "connection"]; const MEDIA_KINDS: CheckKind[] = ["microphone", "speaker", "camera", "screen-share"]; /** * The non-retryable front-runners. Browser and device failures are * deterministic for the page load (userAgent / hardware): no retry or * environment change can clear them. So we run them first, and if either * fails there's no point measuring the retryable rows behind them (layout / * monitor / connection) — the candidate can't proceed regardless, and a * speed test would just burn time. We skip the rest of the system class. */ const GATE_KINDS: CheckKind[] = ["browser", "device"]; /** Retryable system-class checks, run only once the gate passes. */ const GATED_SYSTEM_KINDS: CheckKind[] = ["layout", "monitor", "connection"]; /** * Threshold defaults. Exported alongside the type so consumers * (Vue wizard's MicStep, integration tests, etc) can read the * same numbers we apply internally without duplicating them. */ export const THRESHOLD_DEFAULTS: Required = { minBandwidthMbps: 2, minUploadBandwidthMbps: 2, micMinSpeechMs: 1500, maxFacesAllowed: 1, allowExternalMonitor: false, allowMobile: false, }; const SYSTEM_DEFAULTS: Required = { browser: true, device: true, layout: true, externalMonitor: true, connection: true, }; interface ResolvedOptions { mode: SystemCheckMode; developmentMode: boolean; stepDelayMs: number; minSpeedTestMs: number; enableSafari: boolean; media: MediaChecksConfig; system: Required; requiredCapabilities: Required; thresholds: Required; overrides: CheckOverrides | undefined; deepCamera: SystemCheckOptions["deepCamera"]; connectionTest: SystemCheckOptions["connectionTest"]; connectionReadiness: SystemCheckOptions["connectionReadiness"]; } export class SystemCheck { private readonly options: ResolvedOptions; private rows: CheckRow[]; private readonly listeners = new Set(); private running = false; constructor(options: SystemCheckOptions = {}) { this.options = { mode: options.mode ?? "normal", developmentMode: options.developmentMode ?? false, stepDelayMs: options.stepDelayMs ?? 300, minSpeedTestMs: options.minSpeedTestMs ?? 700, enableSafari: options.enableSafari ?? false, media: options.media ?? {}, // Spread defaults first so any explicit `false` from the // caller (eg. `system: { connection: false }`) wins. system: { ...SYSTEM_DEFAULTS, ...(options.system ?? {}) }, requiredCapabilities: { screenShare: options.requiredCapabilities?.screenShare ?? false, }, thresholds: { ...THRESHOLD_DEFAULTS, ...(options.thresholds ?? {}) }, overrides: options.overrides, deepCamera: options.deepCamera, connectionTest: options.connectionTest, connectionReadiness: options.connectionReadiness, }; this.rows = this.buildInitialRows(); } /** * Replace the row list. Use this after changing `mode` between * runs (eg. user retries after a media failure). */ reset(): void { this.rows = this.buildInitialRows(); this.notify(); } /** * Current rows. Deep-cloned and frozen at every level: consumers can * hold a snapshot through later state transitions and observe it as * it was at capture time. Mutations on `this.rows[i].state` inside * `execute()` must not leak into prior snapshots — that's the bug we * had to fix for the `run()` generator's all-pending initial yield. */ getRows(): ReadonlyArray { return Object.freeze( this.rows.map((row) => Object.freeze({ ...row, state: Object.freeze({ ...row.state }) })), ); } /** * Subscribe to row transitions. Listener fires once with the initial * rows, then once per state transition during a run. Returns an * unsubscribe function. */ subscribe(listener: SystemCheckListener): () => void { this.listeners.add(listener); listener(this.getRows()); return (): void => { this.listeners.delete(listener); }; } /** * Pull-based runner. Yields a snapshot after every state transition. * The final yielded value is the same array as the report's `rows`. * * Throws if a run is already in progress on this instance. */ async *run(): AsyncGenerator, PreflightReport, void> { if (this.running) { throw new Error("SystemCheck.run(): a run is already in progress"); } this.running = true; const queue: ReadonlyArray[] = []; let resolveWaiter: ((v: ReadonlyArray | null) => void) | null = null; let done = false; const listener: SystemCheckListener = (rows) => { if (resolveWaiter) { const r = resolveWaiter; resolveWaiter = null; r(rows); } else { queue.push(rows); } }; this.listeners.add(listener); // Snapshot the initial (all-pending) state BEFORE kicking off // execute(). `execute()` is async but its synchronous prologue can // immediately flip the first row into "checking" and emit() — if we // called execute() first and yielded getRows() afterwards, our // "initial" snapshot would already be mid-flight. const initial = this.getRows(); const finishPromise = this.execute().finally(() => { done = true; if (resolveWaiter) { const r = resolveWaiter; resolveWaiter = null; r(null); } }); try { yield initial; while (true) { if (queue.length > 0) { yield queue.shift()!; continue; } if (done) break; const next = await new Promise | null>((resolve) => { resolveWaiter = resolve; }); if (next === null) break; yield next; } } finally { this.listeners.delete(listener); this.running = false; } const report = await finishPromise; return report; } /** Run-and-resolve. Equivalent to draining `run()` but more ergonomic. */ async runOnce(): Promise { if (this.running) { throw new Error("SystemCheck.runOnce(): a run is already in progress"); } this.running = true; try { return await this.execute(); } finally { this.running = false; } } /** * Apply a set of simulated results to the row list without running * any real checks. Used by stories (and tests, when desired). Keeps * the same visual pacing as `run()` so the stories feel real. */ async runSimulated(results: SimulatedCheckResult[]): Promise { if (this.running) { throw new Error("SystemCheck.runSimulated(): a run is already in progress"); } this.running = true; try { let browserClassFailed = false; for (const row of this.rows) { // System-class failure still short-circuits media (mic / // speaker / camera) -- there's no point asking for camera // permission on an unsupported browser. Media rows // themselves run to completion regardless of each other: // the candidate gets the full diagnostic picture in a // single pass. const isMedia = MEDIA_KINDS.includes(row.kind); if (browserClassFailed && isMedia) { row.state = { kind: "skipped" }; this.notify(); continue; } this.setState(row.kind, { kind: "checking", message: "Checking...", }); await this.delay(this.options.stepDelayMs); const match = results.find((r) => r.kind === row.kind); if (match) { row.state = match.state; if (match.photo !== undefined) row.photo = match.photo; if (match.mbps !== undefined) row.mbps = match.mbps; } else { row.state = { kind: "pass", detail: "Ready" }; } this.notify(); if (row.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(row.kind)) { browserClassFailed = true; } } return this.buildReport(); } finally { this.running = false; } } // ========================================================================== // Internal execution // ========================================================================== private async execute(): Promise { // Mode no longer branches inside execute() -- boot, normal, and // recheck all run the same set of checks. UI-side concerns // (countdown, System collapse) are the only mode-driven // differences, handled by consumers. const overrides: CheckOverrides = { ...(this.options.overrides ?? {}), ...(this.options.enableSafari ? { enableSafari: true } : {}), }; // System-class checks (browser → device → layout → monitor → // connection), gated on the non-retryable front-runners: a browser or // device failure skips the retryable rows behind it. await this.runSystemClassSequence(overrides); // Short-circuit media when any system-class check failed. const browserClassFailed = this.rows.some( (r) => r.state.kind === "fail" && BROWSER_CLASS_KINDS.includes(r.kind), ); if (browserClassFailed) { this.skipPending(MEDIA_KINDS); return this.buildReport(); } // Media (mic/speaker/camera). All three modes that opt in via // options.media run them -- including boot. Boot historically // skipped media to keep the first-load gate fast, but the wizard // is the candidate's first impression and reviewers want the // hardware verified before the candidate ever reaches the exam // surface. await this.runMedia(); return this.buildReport(); } private async runRow(kind: CheckKind, compute: () => CheckState): Promise { if (!this.hasRow(kind)) return; this.setState(kind, { kind: "checking", message: "Checking..." }); await this.delay(this.options.stepDelayMs); this.setState(kind, compute()); } private async runConnection(overrides: CheckOverrides): Promise { if (!this.hasRow("connection")) return; this.setState("connection", { kind: "checking", message: "Checking..." }); await this.delay(this.options.stepDelayMs); const sync = checkConnection(overrides); if (sync) { this.setState("connection", sync.state); if (sync.mbps !== undefined) { this.setRowField("connection", "mbps", sync.mbps); } return; } // Need to actually measure. Production wrappers provide application-path // upload + download endpoints; standalone consumers retain the legacy // Cloudflare download-only fallback. this.setState("connection", { kind: "checking", message: "Testing connection...", }); const start = performance.now(); let result: CheckRow; if (this.options.connectionReadiness) { try { const readiness = await this.options.connectionReadiness(); result = { kind: "connection", state: { kind: "pass", detail: "Connection available" }, recordingUploadMode: readiness.mode, readinessPayloadBytes: readiness.payloadBytes, readinessMedianMs: readiness.medianCompletionMs, readinessWindowMs: readiness.completionWindowMs, readinessSampleCount: readiness.sampleDurationsMs.length, }; } catch (error) { const diagnostic = error && typeof error === "object" && "code" in error ? String((error as { code: unknown }).code) : "unavailable"; const details = readinessErrorDetails(error); result = { kind: "connection", state: { kind: "fail", code: "speed-test-failed", detail: `Connection readiness failed: ${diagnostic}`, }, ...(details.recordingUploadMode !== undefined ? { recordingUploadMode: details.recordingUploadMode } : {}), ...(details.readinessPayloadBytes !== undefined ? { readinessPayloadBytes: details.readinessPayloadBytes } : {}), ...(details.readinessMedianMs !== undefined ? { readinessMedianMs: details.readinessMedianMs } : {}), ...(details.readinessWindowMs !== undefined ? { readinessWindowMs: details.readinessWindowMs } : {}), ...(details.readinessSampleCount !== undefined ? { readinessSampleCount: details.readinessSampleCount } : {}), }; } } else { result = this.options.connectionTest ? applyConnectionQualityToResult( await measureConnectionQuality( createHttpConnectionProbe(this.options.connectionTest), this.options.connectionTest, ), this.options.thresholds.minBandwidthMbps, this.options.thresholds.minUploadBandwidthMbps, ) : applySpeedToResult(await measureSpeed(), this.options.thresholds.minBandwidthMbps); } const elapsed = performance.now() - start; if (elapsed < this.options.minSpeedTestMs) { await this.delay(this.options.minSpeedTestMs - elapsed); } this.setState("connection", result.state); if (result.mbps !== undefined) { this.setRowField("connection", "mbps", result.mbps); } if (result.uploadMbps !== undefined) { this.setRowField("connection", "uploadMbps", result.uploadMbps); } if (result.latencyMs !== undefined) { this.setRowField("connection", "latencyMs", result.latencyMs); } if (result.jitterMs !== undefined) { this.setRowField("connection", "jitterMs", result.jitterMs); } if (result.band !== undefined) { this.setRowField("connection", "band", result.band); } if (result.recordingUploadMode !== undefined) { this.setRowField("connection", "recordingUploadMode", result.recordingUploadMode); } if (result.readinessPayloadBytes !== undefined) { this.setRowField("connection", "readinessPayloadBytes", result.readinessPayloadBytes); } if (result.readinessMedianMs !== undefined) { this.setRowField("connection", "readinessMedianMs", result.readinessMedianMs); } if (result.readinessWindowMs !== undefined) { this.setRowField("connection", "readinessWindowMs", result.readinessWindowMs); } if (result.readinessSampleCount !== undefined) { this.setRowField("connection", "readinessSampleCount", result.readinessSampleCount); } } /** * Run just the system-class checks (browser → device → layout → * monitor → connection). The wizard drives this as step 1; the * legacy `runChecks()` flow composes it as the first phase of a * full run. Idempotent: safe to call on retry. */ async runSystemClass(): Promise { const overrides: CheckOverrides = { ...(this.options.overrides ?? {}), ...(this.options.enableSafari ? { enableSafari: true } : {}), }; await this.runSystemClassSequence(overrides); } /** * Run the five system-class rows in order, gating on the non-retryable * front-runners (browser, device). If either fails, the retryable rows * behind them (layout / monitor / connection) can't unblock the * candidate, so mark any still-pending ones skipped and stop. Returns * true if the gate tripped. See {@link GATE_KINDS}. */ private async runSystemClassSequence(overrides: CheckOverrides): Promise { await this.runRow("browser", () => checkBrowser(overrides).state); await this.runRow( "device", () => checkDevice( overrides, this.options.thresholds.allowMobile, this.options.requiredCapabilities.screenShare, ).state, ); if (this.gateTripped()) { this.skipPending(GATED_SYSTEM_KINDS); return true; } await this.runRow("layout", () => checkScreenLayout(overrides).state); await this.runRow( "monitor", () => checkExternalMonitor( overrides, this.options.developmentMode, this.options.thresholds.allowExternalMonitor, ).state, ); await this.runConnection(overrides); return false; } /** True once a non-retryable gate check (browser/device) has failed. */ private gateTripped(): boolean { return this.rows.some((r) => GATE_KINDS.includes(r.kind) && r.state.kind === "fail"); } /** Mark every still-pending row of the given kinds as skipped. */ private skipPending(kinds: CheckKind[]): void { for (const row of this.rows) { if (kinds.includes(row.kind) && row.state.kind === "pending") { row.state = { kind: "skipped" }; this.notify(); } } } /** * Probe microphone permission + device availability with an * optional explicit `deviceId`. Used by the wizard's mic step so * the candidate can pick a specific input from a dropdown after * granting permission. Without `deviceId` we use the browser * default. */ async probeMic(deviceId?: string): Promise { if (!this.hasRow("microphone")) return; this.setState("microphone", { kind: "checking", message: "Checking permission...", }); const perms = await queryPermissions({ mic: true, camera: false }); this.setState("microphone", await resolveAudioInputState(perms.mic, deviceId)); } /** * Probe speaker — really an "are audio output devices listable * yet?" check, since browsers don't expose a permission for * output. Mic permission gates the device list as a side effect; * this method should be called AFTER probeMic has resolved. */ async probeSpeaker(): Promise { if (!this.hasRow("speaker")) return; this.setState("speaker", { kind: "checking", message: "Checking device...", }); this.setState("speaker", await resolveSpeakerState()); } /** * Probe camera — permission + device + deep face/photo check. * Optional `deviceId` picks a specific camera; without it the * browser default is used. */ async probeCamera( deviceId?: string, existingStream?: { videoElement: HTMLVideoElement }, ): Promise<{ /** * Per-attempt frames the deep-check captured, in order. Empty * when permission was denied or no deep-check was run. The * Vue wizard's camera step uploads each attempt as a separate * WebcamPhoto row so the dashboard can review the full * sequence. */ attempts: import("./deep-camera-check.js").DeepCameraAttempt[]; }> { if (!this.hasRow("camera")) return { attempts: [] }; this.setState("camera", { kind: "checking", message: "Checking permission...", }); const perms = await queryPermissions({ mic: false, camera: true }); const camState = await this.resolveCameraState(perms.camera, deviceId, existingStream); this.setState("camera", camState.state); if (camState.photo) this.setRowField("camera", "photo", camState.photo); return { attempts: camState.attempts ?? [] }; } /** * Probe screen-share. The preflight check is *attestation-only*: * we confirm the browser supports `getDisplayMedia` and that the * candidate has acknowledged they understand the test will record * their screen. The real screen-share gesture happens at session * start under a fresh user activation. * * Why no real `getDisplayMedia` here: * - `getDisplayMedia` permission is not persistent in any * browser. A grant given here doesn't survive into the * session, and the OS picker reappears the moment the * session-recorder calls it. Validating + immediately * releasing was a permission gesture the candidate paid for * nothing functional. * - The "Entire Screen vs Window" surface check has to run * again at session start anyway; doing it twice is wasted * UX. We surface the same expectation in the preflight copy. * * `attested` is the candidate's tick of the "I understand my * screen will be recorded" checkbox in the wizard. Passing false * (default) leaves the row in `checking` so Continue stays * disabled; passing true sets pass. * * Idempotent -- safe to call again on retry. No user-activation * requirement because no real media API is invoked. */ async probeScreenShare(attested: boolean = true): Promise { if (!this.hasRow("screen-share")) return; this.setState("screen-share", { kind: "checking", message: "Waiting for confirmation...", }); // Browser-capability gate. Cheap to check and worth keeping -- // a browser without `getDisplayMedia` will fail at session // start anyway, but the candidate deserves to know now while // they can still switch to a supported browser. if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) { this.setState("screen-share", { kind: "fail", code: "screen-share-not-supported", detail: "Screen sharing is not supported in this browser", }); return; } if (!attested) { // Candidate hasn't ticked the box yet. Hold in checking so // the wizard's Continue stays gated -- the host will call // again with `attested = true` once the checkbox flips on. return; } this.setState("screen-share", { kind: "pass", detail: "Attested", }); } private async runMedia(): Promise { const wantMic = this.hasRow("microphone"); const wantSpeaker = this.hasRow("speaker"); const wantCamera = this.hasRow("camera"); // Screen-share intentionally NOT probed here. See probeScreenShare() // -- it requires a user-activation gesture, so it stays pending // until the orchestrator wires a click handler. // Permissions query is best-effort. We surface granted/denied/prompt // distinct from device-presence so the failure code is precise. const perms = await queryPermissions({ mic: wantMic, camera: wantCamera, }); // All three media checks run to completion even if an earlier // one failed. The candidate needs the full diagnostic picture // ("mic denied AND camera covered") in a single pass so they can // resolve every issue before retry, instead of fixing them one // at a time across multiple runs. if (wantMic) { this.setState("microphone", { kind: "checking", message: "Checking permission...", }); await this.delay(this.options.stepDelayMs); this.setState("microphone", await resolveAudioInputState(perms.mic)); } if (wantSpeaker) { this.setState("speaker", { kind: "checking", message: "Checking device...", }); await this.delay(this.options.stepDelayMs); this.setState("speaker", await resolveSpeakerState()); } if (wantCamera) { this.setState("camera", { kind: "checking", message: "Checking permission...", }); await this.delay(this.options.stepDelayMs); const camState = await this.resolveCameraState(perms.camera); // resolveCameraState attaches the photo to the row directly via // setRowField — read back the final state below. this.setState("camera", camState.state); if (camState.photo) this.setRowField("camera", "photo", camState.photo); } } /** * Camera state resolution. Permission → live stream/device → * deep verification. Camera preflight is evidence-bearing: * a detector must be configured and a frame must be captured. */ private async resolveCameraState( perm: PermissionResult, deviceId?: string, existingStream?: { videoElement: HTMLVideoElement }, ): Promise<{ state: CheckState; photo?: string; attempts?: import("./deep-camera-check.js").DeepCameraAttempt[]; }> { const hasExistingStream = hasLiveVideoStream(existingStream?.videoElement); if (perm === "denied") { return { state: { kind: "fail", code: "permission-denied", detail: "Permission denied", }, }; } if (!hasExistingStream && (perm === "prompt" || perm === "unknown")) { return { state: { kind: "fail", code: "permission-required", detail: "Permission required", }, }; } const deepOptions = this.options.deepCamera; if (!deepOptions?.detectFace) { return { state: { kind: "fail", code: "deep-check-failed", detail: "Face detector not configured", }, attempts: [], }; } // Access is granted or we have a live stream. Run the deep check, // optionally pinned to a specific deviceId (used by the wizard's // camera step when the candidate picks a non-default camera from // the dropdown). try { // Thread policy thresholds into the deep-camera options. // Order matters: policy thresholds set the floor, the // customer's explicit `deepCamera.*` overrides win, then // the per-call deviceId pin. This lets a customer override // face-count behaviour on a per-camera-config basis without // editing the global policy. const result = await runDeepCameraCheck({ maxFacesAllowed: this.options.thresholds.maxFacesAllowed, ...deepOptions, ...(existingStream ? { existingStream } : {}), ...(!existingStream && deviceId ? { cameraOptions: { deviceId } } : {}), }); const out: { state: CheckState; photo?: string; attempts: import("./deep-camera-check.js").DeepCameraAttempt[]; } = { state: result.state, attempts: result.attempts }; if (result.photo !== undefined) out.photo = result.photo; return out; } catch (err) { // Most commonly: no camera plugged in despite the permission grant // ("device-disconnected"). const message = err instanceof Error ? err.message : String(err); const code: FailCode = /denied|notallowed/i.test(message) ? "permission-denied" : "device-disconnected"; return { state: { kind: "fail", code, detail: code === "permission-denied" ? "Permission denied" : "Device disconnected", }, }; } } // ========================================================================== // Row plumbing // ========================================================================== private buildInitialRows(): CheckRow[] { const rows: CheckRow[] = []; // System rows are gated by `options.system.*`. Each defaults to true, so // the legacy "all five run" behaviour is unchanged for callers passing no // system config. A required runtime capability may retain its owning gate. const sys = this.options.system; if (sys.browser) rows.push({ kind: "browser", state: { kind: "pending" } }); // A runtime-required capability is a hard prerequisite, not an optional // informational check. Keep the device gate even when a caller hides the // ordinary device policy row. if (sys.device || this.options.requiredCapabilities.screenShare) { rows.push({ kind: "device", state: { kind: "pending" } }); } if (sys.layout) rows.push({ kind: "layout", state: { kind: "pending" } }); if (sys.externalMonitor) { rows.push({ kind: "monitor", state: { kind: "pending" } }); } if (sys.connection) { rows.push({ kind: "connection", state: { kind: "pending" } }); } // Media rows are driven by options.media regardless of mode -- // boot/normal/recheck all run the same media subset the caller // opts into. `mode` only affects post-check UX (countdown, // collapse rendering), not which rows exist. const media = this.options.media; if (media.microphone) { rows.push({ kind: "microphone", state: { kind: "pending" } }); } if (media.speaker) { rows.push({ kind: "speaker", state: { kind: "pending" } }); } if (media.camera) { rows.push({ kind: "camera", state: { kind: "pending" } }); } if (media.screenShare) { rows.push({ kind: "screen-share", state: { kind: "pending" } }); } return rows; } private hasRow(kind: CheckKind): boolean { return this.rows.some((r) => r.kind === kind); } private setState(kind: CheckKind, state: CheckState): void { const row = this.rows.find((r) => r.kind === kind); if (!row) return; row.state = state; this.notify(); } private setRowField( kind: CheckKind, field: K, value: CheckRow[K], ): void { const row = this.rows.find((r) => r.kind === kind); if (!row) return; row[field] = value; this.notify(); } private notify(): void { const snapshot = this.getRows(); for (const listener of this.listeners) { try { listener(snapshot); } catch { // A listener crash shouldn't break the engine. } } } private buildReport(): PreflightReport { const rows = this.rows.map((r) => ({ ...r })); const failures = rows .filter( (r): r is CheckRow & { state: Extract } => r.state.kind === "fail", ) .map((r) => ({ kind: r.kind, code: r.state.code, detail: r.state.detail, })); return { mode: this.options.mode, passed: failures.length === 0 && rows.every((r) => r.state.kind === "pass"), rows, failures, finishedAt: Date.now(), }; } private delay(ms: number): Promise { if (ms <= 0) return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, ms)); } } /** * Convenience wrapper for the common case ("just run it once and give * me the report"). Equivalent to `new SystemCheck(options).runOnce()`. */ export const runSystemCheck = async (options: SystemCheckOptions = {}): Promise => new SystemCheck(options).runOnce(); // ============================================================================ // Permissions + media-device probing // ============================================================================ type PermissionResult = "granted" | "denied" | "prompt" | "unknown"; interface PermissionQuery { mic: boolean; camera: boolean; } interface PermissionResults { mic: PermissionResult; camera: PermissionResult; } const queryPermissions = async (q: PermissionQuery): Promise => { const probe = async (name: PermissionName): Promise => { if (typeof navigator === "undefined" || !navigator.permissions) { return "unknown"; } try { const status = await navigator.permissions.query({ name }); return status.state as PermissionResult; } catch { return "unknown"; } }; const [mic, camera] = await Promise.all([ q.mic ? probe("microphone" as PermissionName) : Promise.resolve("granted" as PermissionResult), q.camera ? probe("camera" as PermissionName) : Promise.resolve("granted" as PermissionResult), ]); return { mic, camera }; }; const enumerateAudioInputs = async (): Promise => { if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) { return []; } try { const all = await navigator.mediaDevices.enumerateDevices(); return all.filter((d) => d.kind === "audioinput"); } catch { return []; } }; const enumerateAudioOutputs = async (): Promise => { if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) { return []; } try { const all = await navigator.mediaDevices.enumerateDevices(); return all.filter((d) => d.kind === "audiooutput"); } catch { return []; } }; const resolveAudioInputState = async ( perm: PermissionResult, deviceId?: string, ): Promise => { if (perm === "denied") { return { kind: "fail", code: "permission-denied", detail: "Permission denied" }; } if (perm === "prompt" || perm === "unknown") { // "unknown" here covers Firefox/Safari where Permissions API lacks // microphone — we can still pass on the basis of devices existing, // since the OS will prompt at first capture anyway. But if no // device is present, fail with no-device-found. const devices = await enumerateAudioInputs(); if (devices.length === 0) { return { kind: "fail", code: "no-device-found", detail: "No microphone detected" }; } if (perm === "prompt") { return { kind: "fail", code: "permission-required", detail: "Permission required", }; } const firstLabel = pickDeviceLabel(devices, deviceId) || "Ready"; return { kind: "pass", detail: firstLabel }; } const devices = await enumerateAudioInputs(); if (devices.length === 0) { return { kind: "fail", code: "device-disconnected", detail: "Device disconnected", }; } const label = pickDeviceLabel(devices, deviceId) || "Ready"; return { kind: "pass", detail: label }; }; const hasLiveVideoStream = (video: HTMLVideoElement | undefined): boolean => { const stream = video?.srcObject instanceof MediaStream ? video.srcObject : null; return stream?.getVideoTracks().some((track) => track.readyState === "live") ?? false; }; /** * Pick the most informative label for a chosen device. When the * candidate's specific deviceId resolves, use its label; otherwise * fall back to the first labelled device. */ const pickDeviceLabel = (devices: ReadonlyArray, deviceId?: string): string => { if (deviceId) { const exact = devices.find((d) => d.deviceId === deviceId); if (exact?.label) return exact.label; } return devices.find((d) => d.label)?.label ?? ""; }; const resolveSpeakerState = async (): Promise => { // There's no browser permission for audio output -- any tab can // play sound. BUT `enumerateDevices()` censors device labels and // IDs until mic OR camera permission has been granted at least // once. Without that unlock, we get either an empty list or // placeholder entries with empty `deviceId`/`label`. In either // case the candidate can't actually pick a speaker, so the check // has no useful verdict to give -- fail with permission-required // to nudge them to grant mic first (which unlocks the audio // device list as a side effect). const devices = await enumerateAudioOutputs(); const hasRealDevice = devices.some((d) => d.label && d.deviceId); if (!hasRealDevice) { return { kind: "fail", code: "permission-required", detail: "Grant microphone permission to list audio outputs", }; } const label = devices.find((d) => d.label)?.label ?? "Default output"; return { kind: "pass", detail: label }; };