import type { EventKind } from "@a4anthony/proctorkit-types"; export interface ObserverEmitter { emit(kind: EventKind, payload?: Record): void; } export interface ScreenshotObserverConfig { /** * Render a blur overlay over the page whenever a screenshot is * suspected. The candidate sees a frosted overlay and (after 5s) a * "Resume Session" button. Useful as a deterrent and as a way to * obscure delayed-capture tools that snapshot the page after the * candidate switches back. Default: false. */ blurOnSuspicion?: boolean; /** * Milliseconds before the warning text + Resume button reveal on top * of the blur. Default: 5000. */ resumeRevealMs?: number; /** * Customise the deterrent overlay copy. */ resumeButtonText?: string; warningText?: string; } interface ResolvedScreenshotConfig { enabled: boolean; blurOnSuspicion: boolean; resumeRevealMs: number; resumeButtonText: string; warningText: string; } export type ScreenshotTrigger = "keyboard" | "visibility" | "blur"; /** * Watches for behaviours that indicate the candidate is trying to take * a screenshot. Emits a consolidated `screenshot.attempted` event with * the trigger ("keyboard" / "visibility" / "blur") in the payload, so * proctors see a single, semantic row in the timeline. * * Optionally renders a blur overlay over the page so that delayed * capture tools snapshot a blurred view. * * Screenshot-key handling (ported from teq-lib's proven approach): * * 1. Cmd+Shift keydown latches `screenshotKeyActive = true` and shows * the blur. macOS swallows the digit keydown for Cmd+Shift+3/4/5 * and the keyup for the modifiers, so we cannot rely on detecting * the digit — the latch is what carries state across the OS * clipping tool's lifecycle. * 2. window.blur fires while the OS clipping tool is up → re-shows * the blur (idempotent). * 3. window.focus fires when the clipping tool closes. We gate * hideBlur on `!screenshotKeyActive`. The flag is still true * because no keyup ever cleared it, so the overlay stays. * 4. The candidate either clicks Resume (clears flag + hides) or * releases one of the listed modifiers cleanly back into the page * (clears flag + hides). After 5s the Resume button reveals as a * fallback exit so the candidate isn't stuck. * * IMPORTANT: this is a deterrent, not real prevention. OS-level * screenshot keys (Cmd+Shift+3 on macOS, PrintScreen on Windows) are * intercepted by the OS before the page sees them, so the screenshot * usually completes before the observer's preventDefault runs. The * forensic value is the *attempt log*, not the block. */ export class ScreenshotObserver { private listening = false; private readonly config: ResolvedScreenshotConfig; private readonly listeners: Array<{ target: EventTarget; type: string; listener: EventListener; }> = []; private overlay: HTMLDivElement | null = null; private resumeButton: HTMLButtonElement | null = null; private warningEl: HTMLDivElement | null = null; private buttonTimer: ReturnType | null = null; private isDragging = false; // Latched when a screenshot key combo is pressed. macOS consumes the // modifier+digit keyups for Cmd+Shift+3/4/5 so handleKeyUp doesn't // run for them — the latch keeps the overlay up after focus returns // from the clipping tool until the candidate either clicks Resume or // a clean modifier keyup reaches the page. private screenshotKeyActive = false; constructor( private readonly emitter: ObserverEmitter, config: ScreenshotObserverConfig | boolean | undefined, ) { this.config = resolveConfig(config); } start(): void { if (this.listening || !this.config.enabled) return; if (typeof window === "undefined" || typeof document === "undefined") return; this.listening = true; this.bind(document, "dragstart", () => { this.isDragging = true; }); this.bind(document, "dragend", () => { this.isDragging = false; }); this.bind(document, "drop", () => { this.isDragging = false; }); this.bind(document, "keydown", (event) => { const ke = event as KeyboardEvent; const shortcut = matchScreenshotShortcut(ke); if (!shortcut) return; event.preventDefault(); this.emit("keyboard", { shortcut }); this.screenshotKeyActive = true; this.showBlur(); }); this.bind(document, "keyup", (event) => { const ke = event as KeyboardEvent; if ( ke.key === "Shift" || ke.key === "Meta" || ke.key === "Control" || ke.key === "PrintScreen" || ke.key === "p" ) { this.screenshotKeyActive = false; this.hideBlur(); } }); this.bind(document, "visibilitychange", () => { if (this.isDragging) return; if (document.visibilityState === "hidden") { this.emit("visibility"); this.showBlur(); } else if (!this.screenshotKeyActive) { this.hideBlur(); } }); this.bind(window, "blur", () => { if (this.isDragging) return; this.emit("blur"); this.showBlur(); }); this.bind(window, "focus", () => { if (!this.screenshotKeyActive) { this.hideBlur(); } }); } stop(): void { if (!this.listening) return; for (const { target, type, listener } of this.listeners) { target.removeEventListener(type, listener); } this.listeners.length = 0; this.listening = false; if (this.buttonTimer) { clearTimeout(this.buttonTimer); this.buttonTimer = null; } this.removeOverlay(); } private bind(target: EventTarget, type: string, listener: EventListener): void { target.addEventListener(type, listener); this.listeners.push({ target, type, listener }); } private emit(trigger: ScreenshotTrigger, extra: Record = {}): void { this.emitter.emit("screenshot.attempted", { trigger, ...extra }); } private showBlur(): void { if (!this.config.blurOnSuspicion) return; if (!this.overlay) this.mountOverlay(); if (!this.overlay) return; this.overlay.style.display = "block"; if (this.resumeButton) this.resumeButton.style.display = "none"; if (this.warningEl) this.warningEl.style.display = "none"; if (this.buttonTimer) clearTimeout(this.buttonTimer); this.buttonTimer = setTimeout(() => { if (!this.overlay || this.overlay.style.display !== "block") return; if (this.resumeButton) this.resumeButton.style.display = "inline-block"; if (this.warningEl) this.warningEl.style.display = "block"; }, this.config.resumeRevealMs); } private hideBlur(): void { if (!this.overlay) return; this.overlay.style.display = "none"; if (this.resumeButton) this.resumeButton.style.display = "none"; if (this.warningEl) this.warningEl.style.display = "none"; if (this.buttonTimer) { clearTimeout(this.buttonTimer); this.buttonTimer = null; } } private mountOverlay(): void { if (typeof document === "undefined") return; const overlay = document.createElement("div"); overlay.dataset["proctoringOverlay"] = "screenshot"; overlay.style.cssText = [ "position:fixed", "inset:0", "z-index:2147483647", "background-color:rgba(255,255,255,0.5)", "backdrop-filter:blur(30px)", "-webkit-backdrop-filter:blur(30px)", "display:none", "pointer-events:none", ].join(";"); const center = document.createElement("div"); center.style.cssText = [ "position:absolute", "top:50%", "left:50%", "transform:translate(-50%,-50%)", "display:flex", "flex-direction:column", "align-items:center", "gap:16px", "pointer-events:none", "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif", ].join(";"); const warning = document.createElement("div"); warning.style.cssText = "font-size:18px;font-weight:600;color:#1f2937;display:none;text-align:center"; warning.textContent = this.config.warningText; this.warningEl = warning; center.appendChild(warning); const button = document.createElement("button"); button.type = "button"; button.textContent = this.config.resumeButtonText; button.style.cssText = [ "display:none", "padding:12px 24px", "font-size:16px", "font-weight:600", "color:white", "background-color:#3b82f6", "border:none", "border-radius:8px", "cursor:pointer", "pointer-events:auto", ].join(";"); button.addEventListener("click", () => { this.screenshotKeyActive = false; this.hideBlur(); }); this.resumeButton = button; center.appendChild(button); overlay.appendChild(center); document.body.appendChild(overlay); this.overlay = overlay; } private removeOverlay(): void { if (this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); } this.overlay = null; this.resumeButton = null; this.warningEl = null; } } function resolveConfig( raw: ScreenshotObserverConfig | boolean | undefined, ): ResolvedScreenshotConfig { const base = { enabled: false, blurOnSuspicion: false, resumeRevealMs: 5_000, resumeButtonText: "Resume Session", warningText: "Click below to continue your session.", }; if (raw === undefined || raw === false) return base; if (raw === true) return { ...base, enabled: true }; return { enabled: true, blurOnSuspicion: raw.blurOnSuspicion ?? false, resumeRevealMs: raw.resumeRevealMs ?? 5_000, resumeButtonText: raw.resumeButtonText ?? base.resumeButtonText, warningText: raw.warningText ?? base.warningText, }; } function matchScreenshotShortcut(event: KeyboardEvent): string | null { // Cmd+Shift / Ctrl+Shift — covers macOS Cmd+Shift+3/4/5/6 (the digit // keydown is OS-swallowed, but the modifier keydown isn't) and // Windows Snip & Sketch (Win+Shift+S has Shift+Meta held when the // Win-key fires). if (event.shiftKey && event.metaKey) return "Cmd+Shift"; if (event.shiftKey && event.ctrlKey) return "Ctrl+Shift"; // PrintScreen on Windows / Linux. if (event.key === "PrintScreen" || event.keyCode === 44) return "PrintScreen"; // Print dialog — same vector (save-to-PDF as a workaround). if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p") { return event.metaKey ? "Cmd+P" : "Ctrl+P"; } return null; }