/** * Runtime integrity checkpoint — browser-facing detectors. * * Every probe degrades gracefully: when the underlying API is missing or the * page can't answer (Firefox has no `permissions.query` for mic/camera, no * `screen.isExtended`; canvas/frame capture can be unavailable), it returns * the `unknown`/`ok`/`null` fallback rather than throwing or manufacturing a * false failure. The pure logic in `checkpoint.ts` treats those as "no drift". */ import type { DisplaySnapshot, FrameVerdict, PermissionSnapshot, } from "./types.js"; /** Map a live camera/microphone permission to a tri-state snapshot. */ export async function queryPermission( name: "microphone" | "camera", ): Promise { try { if (typeof navigator === "undefined" || !navigator.permissions?.query) { return "unknown"; } const status = await navigator.permissions.query({ name: name as PermissionName }); if (status.state === "granted") return "granted"; // A session that passed preflight with a grant no longer has access when // the browser returns "prompt": the next getUserMedia call would surface // native permission UI. Treat that as unavailable just like "denied" so // the runtime checkpoint raises the recovery gate instead of prompting. if (status.state === "denied" || status.state === "prompt") return "denied"; return "unknown"; } catch { return "unknown"; } } /** Current device ids grouped by kind, or null when enumeration is unavailable. */ export async function listDeviceIds(): Promise > | null> { try { if ( typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices ) { return null; } const all = await navigator.mediaDevices.enumerateDevices(); const out = { audioinput: new Set(), videoinput: new Set(), audiooutput: new Set(), }; for (const d of all) { if (d.deviceId && d.kind in out) { out[d.kind as keyof typeof out].add(d.deviceId); } } return out; } catch { return null; } } /** External-monitor detection via `screen.isExtended` (Chromium-only). */ export function probeDisplay(): DisplaySnapshot { try { if (typeof window === "undefined" || !window.screen) return "unknown"; const screen = window.screen as Screen & { isExtended?: boolean }; if (typeof screen.isExtended !== "boolean") return "unknown"; return screen.isExtended ? "extended" : "single"; } catch { return "unknown"; } } /** How much a frame's pixels must vary before it counts as non-black. */ const BLACK_LUMA_EPSILON = 6; // 0–255; a covered lens / black feed sits near 0 /** Total budget for starting playback and observing two advancing decoded frames. */ const FRAME_PROGRESS_TIMEOUT_MS = 1_500; interface PresentedFrame { mediaTime: number; presentedFrames: number; } /** * Sample a live video track for black / frozen. Waits for decoded-frame * callbacks instead of treating visual stillness as a stalled camera: an * all-dark first frame is "black"; a second frame whose browser-owned * `presentedFrames` / `mediaTime` advances is "ok", even when every pixel is * identical; no advancing second frame within the bounded budget is "frozen". * Returns "unknown" when the browser can't report frame presentation, playback * can't start, the first frame never arrives, or canvas capture is unavailable. * * Browser integration still needs a real camera; unit tests cover the decoded- * frame progression, timeout, and black-frame branches with injected elements. */ export async function sampleVideoFrame(track: MediaStreamTrack): Promise { let video: HTMLVideoElement | null = null; try { if (typeof document === "undefined" || track.readyState !== "live") return "unknown"; const activeVideo = document.createElement("video"); video = activeVideo; activeVideo.muted = true; activeVideo.playsInline = true; activeVideo.srcObject = new MediaStream([track]); if (typeof activeVideo.requestVideoFrameCallback !== "function") return "unknown"; const deadline = Date.now() + FRAME_PROGRESS_TIMEOUT_MS; if (!(await settlesBefore(activeVideo.play(), deadline))) return "unknown"; // Do not inspect the canvas until the browser confirms a decoded frame was // presented. Drawing immediately after play() can read a stale/blank frame. const firstFrame = await waitForPresentedFrame(activeVideo, deadline); if (!firstFrame) return "unknown"; const w = 64; const h = 48; const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return "unknown"; const grab = (): Uint8ClampedArray | null => { try { ctx.drawImage(activeVideo, 0, 0, w, h); return ctx.getImageData(0, 0, w, h).data; } catch { return null; // e.g. not enough data decoded yet } }; const first = grab(); if (!first) return "unknown"; // Black: mean luma near zero. let lumaSum = 0; for (let i = 0; i < first.length; i += 4) { lumaSum += 0.299 * first[i]! + 0.587 * first[i + 1]! + 0.114 * first[i + 2]!; } const meanLuma = lumaSum / (first.length / 4); if (meanLuma < BLACK_LUMA_EPSILON) { return "black"; } // A callback with advancing frame metadata proves delivery independently // of scene motion. A perfectly still candidate must remain healthy. const secondFrame = await waitForPresentedFrame(activeVideo, deadline, firstFrame); return secondFrame ? "ok" : "frozen"; } catch { return "unknown"; } finally { if (video) cleanupVideo(video); } } /** Resolve with the next browser-presented frame that advances from `previous`. */ function waitForPresentedFrame( video: HTMLVideoElement, deadline: number, previous?: PresentedFrame, ): Promise { return new Promise((resolve) => { let callbackId: number | null = null; let settled = false; const remaining = Math.max(0, deadline - Date.now()); const finish = (frame: PresentedFrame | null): void => { if (settled) return; settled = true; clearTimeout(timeout); if (callbackId !== null) video.cancelVideoFrameCallback?.(callbackId); resolve(frame); }; const requestNext = (): void => { if (Date.now() >= deadline) { finish(null); return; } callbackId = video.requestVideoFrameCallback((_now, metadata) => { callbackId = null; const frame = { mediaTime: metadata.mediaTime, presentedFrames: metadata.presentedFrames, }; if (!previous || frameAdvanced(previous, frame)) { finish(frame); } else { requestNext(); } }); }; const timeout = setTimeout(() => finish(null), remaining); requestNext(); }); } function frameAdvanced(previous: PresentedFrame, current: PresentedFrame): boolean { const hasPresentedFrameCount = Number.isFinite(previous.presentedFrames) && Number.isFinite(current.presentedFrames); const hasMediaTime = Number.isFinite(previous.mediaTime) && Number.isFinite(current.mediaTime); if (!hasPresentedFrameCount && !hasMediaTime) { // The callback itself is specified to run when a new frame is presented; // tolerate implementations that omit usable metadata. return true; } return ( (hasPresentedFrameCount && current.presentedFrames > previous.presentedFrames) || (hasMediaTime && current.mediaTime > previous.mediaTime) ); } /** Whether a promise fulfils inside the shared frame-progress deadline. */ function settlesBefore(promise: Promise, deadline: number): Promise { return new Promise((resolve) => { let settled = false; const finish = (value: boolean): void => { if (settled) return; settled = true; clearTimeout(timeout); resolve(value); }; const timeout = setTimeout(() => finish(false), Math.max(0, deadline - Date.now())); void promise.then( () => finish(true), () => finish(false), ); }); } function cleanupVideo(video: HTMLVideoElement): void { try { video.pause(); video.srcObject = null; } catch { /* no-op */ } }