/* * Candidate-side proctoring pipeline. * MediaPipe runs locally for ML face/gaze/object detection. * Enforces Tab/Focus Loss, Clipboard Activity, AI-Tool/Hotkey Detection, * Continuous Speech Analysis, Screen Share Enforcement & System Desktop Screenshots. * All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint. */ import { initMeteredIceServers } from './metered.js'; // MediaPipe Published Browser Bundle CDN URLs const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs'; const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs'; const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm'; const FACE_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'; const OBJECT_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float32/1/efficientdet_lite0.tflite'; const SAMPLE_INTERVAL_MS = 200; const NO_FACE_GRACE_MS = 1000; const LOOKING_AWAY_GRACE_MS = 700; const PROLONGED_LOOKING_AWAY_MS = 10000; const PROLONGED_LOOKING_AWAY_RECHECK_MS = 120000; const PHONE_EVENT_COOLDOWN_MS = 3000; const SNAPSHOT_QUALITY = 0.72; const HEAD_YAW_THRESHOLD = 0.34; const HEAD_PITCH_UP_THRESHOLD = 0.2; const HEAD_PITCH_DOWN_THRESHOLD = 0.74; const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28; const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72; const GAZE_DOWN_THRESHOLD = 0.62; const CORNER_FIXATION_MS = 18000; const CORNER_RECHECK_MS = 20000; const CORNER_JITTER_GRACE_MS = 3000; // --- State Variables for Violation & Focus Detection --- let isTabSwitchScreenshotEnabled = true; let lastTabSwitchTime = 0; let pendingNativePrompts = 0; let suppressFocusViolationsUntil = 0; let screenShareStream = null; let screenShareVideoElem = null; let overlayCheckInterval = null; let candidateSpeechHistory = []; // --- Native Prompt Suppression Window --- export function beginNativePrompt() { pendingNativePrompts++; } export function endNativePrompt() { pendingNativePrompts = Math.max(0, pendingNativePrompts - 1); } export function armTrailingBuffer(ms) { suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms); } export function isFocusSuppressed() { return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil; } export function isCandidateAssessmentPage() { if (typeof window === 'undefined' || typeof document === 'undefined') return false; const isCandidateUrl = window.location.pathname.includes('/candidate/'); const hasMonaco = Boolean(document.getElementById('monaco-editor-container')); const isInterviewerPage = Boolean(document.getElementById('interviewer-cand-video') || document.getElementById('interviewer-self-video') || document.getElementById('panelist-mesh-grid')); return (isCandidateUrl || hasMonaco) && !isInterviewerPage; } export function isCandidateCallActive() { if (!isCandidateAssessmentPage()) return false; if (typeof window !== 'undefined' && typeof window.isCallConnected === 'function') { return window.isCallConnected(); } const callStatusBadge = document.getElementById('call-status-badge'); if (callStatusBadge) { return callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED'); } return false; } // --- Structured Logging & Backend Reporting --- class StructuredEventLogger { constructor(sessionId) { this.sessionId = sessionId; this.events = []; } record(type, confidence, details, snapshot = null) { const event = { schema: 'candidate-proctoring/v1', session_id: this.sessionId, event_id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type, timestamp: new Date().toISOString(), confidence: Number(Math.max(0, Math.min(1, confidence || 0)).toFixed(4)), details: details || {}, snapshot: snapshot || null, }; this.events.push(event); console.log('[proctoring:event]', event); this.sendToServer(event); return event; } sendToServer(event) { if (!isCandidateAssessmentPage()) return; const interviewId = this.sessionId || document.body.dataset.interviewId; if (!interviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/candidate/log-violation/${interviewId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ type: event.type, details: typeof event.details === 'object' ? JSON.stringify(event.details) : event.details, confidence: event.confidence, snapshot: event.snapshot, session_id: event.session_id, event_id: event.event_id, timestamp: event.timestamp }) }) .then(r => r.json()) .then(() => { if (typeof BroadcastChannel !== 'undefined') { const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); callSigChannel.postMessage({ type: 'violation_occurred', violation_type: event.type, details: event.details }); liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: event.type, details: event.details }); } if (typeof window.pollReviewData === 'function') { window.pollReviewData(); } }) .catch(err => console.log('[proctoring:sync-error]', err)); } report(startedAt, endedAt, durations) { const report = { schema: 'candidate-proctoring/report-v1', session_id: this.sessionId, started_at: startedAt, ended_at: endedAt, generated_at: new Date().toISOString(), review_required: true, summary: { looking_away_ms: Math.round(durations.lookingAwayMs), no_face_ms: Math.round(durations.noFaceMs), multiple_faces_ms: Math.round(durations.multipleFacesMs), prolonged_looking_away_event_count: this.events.filter((event) => event.type === 'prolonged_looking_away').length, phone_event_count: this.events.filter((event) => event.type === 'phone_detected').length, total_events: this.events.length, }, events: this.events, }; console.log('[proctoring:report]', report); return report; } } export function logViolation(type, details, confidence = 1.0, snapshot = null) { if (!isCandidateAssessmentPage()) return; if (window.candidateProctoring && window.candidateProctoring.logger) { window.candidateProctoring.logger.record(type, confidence, details, snapshot); } else { const interviewId = document.body.dataset.interviewId; if (!interviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/candidate/log-violation/${interviewId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ type: type, details: typeof details === 'object' ? JSON.stringify(details) : details, confidence: confidence, snapshot: snapshot }) }) .then(r => r.json()) .then(() => { if (typeof BroadcastChannel !== 'undefined') { const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details }); liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details }); } if (typeof window.pollReviewData === 'function') { window.pollReviewData(); } }) .catch(err => console.log('Log violation fetch error:', err)); } } // --- System Desktop Screenshot Capture Engine --- function getScreenShareVideoElement() { if (!screenShareVideoElem) { screenShareVideoElem = document.createElement('video'); screenShareVideoElem.autoplay = true; screenShareVideoElem.muted = true; screenShareVideoElem.playsInline = true; screenShareVideoElem.style.position = 'fixed'; screenShareVideoElem.style.top = '0px'; screenShareVideoElem.style.left = '0px'; screenShareVideoElem.style.width = '320px'; screenShareVideoElem.style.height = '180px'; screenShareVideoElem.style.opacity = '0.001'; screenShareVideoElem.style.pointerEvents = 'none'; screenShareVideoElem.style.zIndex = '-9999'; document.body.appendChild(screenShareVideoElem); } return screenShareVideoElem; } export function captureAndUploadTabScreenshot(reason = 'tab_switch') { if (!isTabSwitchScreenshotEnabled || !isCandidateAssessmentPage()) return; if (reason !== 'tab_switch' && reason !== 'call_start' && reason !== 'external_ai_detected' && reason !== 'copy_event') return; try { const tempCanvas = document.createElement('canvas'); let drewScreenStream = false; const isCallStart = (reason === 'call_start'); const bannerLabel = isCallStart ? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString() : '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString(); if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) { const screenTrack = screenShareStream.getVideoTracks()[0]; if (screenTrack && screenTrack.readyState === 'live') { try { const vidElem = getScreenShareVideoElement(); if (vidElem.srcObject !== screenShareStream) { vidElem.srcObject = screenShareStream; vidElem.play().catch(() => {}); } let rawW = vidElem.videoWidth || 1280; let rawH = vidElem.videoHeight || 720; if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) { const maxW = 1280; let targetW = rawW; let targetH = rawH; if (targetW > maxW) { targetH = Math.round(targetH * (maxW / targetW)); targetW = maxW; } tempCanvas.width = targetW; tempCanvas.height = targetH; const tempCtx = tempCanvas.getContext('2d'); tempCtx.drawImage(vidElem, 0, 0, targetW, targetH); tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; tempCtx.font = 'bold 14px sans-serif'; tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15); uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason); drewScreenStream = true; } } catch(e) { console.log('Screen stream draw exception:', e); } } } if (drewScreenStream) return; if (typeof window.html2canvas !== 'undefined' && !document.hidden) { window.html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => { const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgba(15, 23, 42, 0.90)'; ctx.fillRect(0, canvas.height - 42, canvas.width, 42); ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; ctx.font = 'bold 14px sans-serif'; ctx.fillText(bannerLabel, 16, canvas.height - 15); uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason); }).catch(() => { fallbackCompositeSnapshot(reason); }); } else { fallbackCompositeSnapshot(reason); } } catch(e) { console.log('Candidate system screenshot capture exception:', e); fallbackCompositeSnapshot(reason); } } export function fallbackCompositeSnapshot(reason = 'tab_switch') { const interviewId = document.body.dataset.interviewId || ''; const tempCanvas = document.createElement('canvas'); const w = 1280; const h = 720; tempCanvas.width = w; tempCanvas.height = h; const tempCtx = tempCanvas.getContext('2d'); tempCtx.fillStyle = '#0b0f19'; tempCtx.fillRect(0, 0, w, h); tempCtx.fillStyle = '#1e293b'; tempCtx.fillRect(0, 0, w, 50); tempCtx.fillStyle = '#38bdf8'; tempCtx.font = 'bold 16px sans-serif'; tempCtx.fillText('💻 Candidate System Workspace • ID: ' + interviewId, 20, 32); const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor') || document.getElementById('monaco-editor-container'); tempCtx.fillStyle = '#020617'; tempCtx.fillRect(20, 70, w - 40, h - 130); tempCtx.fillStyle = '#94a3b8'; tempCtx.font = '13px monospace'; const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : ''; const lines = codeText.split('\n').slice(0, 30); lines.forEach((line, i) => { tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18)); }); const isCallStart = (reason === 'call_start'); tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; tempCtx.font = 'bold 14px sans-serif'; const text = isCallStart ? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString() : '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString(); tempCtx.fillText(text, 16, tempCanvas.height - 15); uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason); } export function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') { const interviewId = document.body.dataset.interviewId; if (!interviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/candidate/upload-tab-screenshot/${interviewId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ image: imageData, reason: reason }) }) .then(r => r.json()) .then(data => { console.log('Candidate system screenshot saved:', data); }) .catch(err => console.log('System screenshot fetch error:', err)); } export function isEntireScreenShare(track) { if (!track || !track.getSettings) return true; const settings = track.getSettings(); const surface = settings.displaySurface; if (surface) { const allowedSurfaces = ['monitor', 'screen']; if (!allowedSurfaces.includes(surface)) { return false; } } if (window.screen && window.screen.width && settings.width) { if (settings.width < (window.screen.width * 0.65) && settings.height < (window.screen.height * 0.65)) { return false; } } return true; } export function isScreenSharingActive() { return Boolean(screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().some(t => t.readyState === 'live')); } export function enforceBrowserFullscreen() { if (!isScreenSharingActive() || document.fullscreenElement) return; beginNativePrompt(); if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen() .then(() => { endNativePrompt(); armTrailingBuffer(1000); }) .catch(() => { endNativePrompt(); showFullscreenRequiredModal(); }); } else { endNativePrompt(); showFullscreenRequiredModal(); } } export function showFullscreenRequiredModal() { if (!isScreenSharingActive() || document.fullscreenElement) return; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'warning', title: 'Fullscreen Mode Required', text: 'Your assessment must be conducted in Fullscreen mode while screen sharing. Please click below to enter Fullscreen.', confirmButtonText: 'Enter Fullscreen Mode', confirmButtonColor: '#6366f1', allowOutsideClick: false, allowEscapeKey: false }).then(() => { if (isScreenSharingActive()) { enforceBrowserFullscreen(); } }); } } let candidateScreenPeerInstance = null; const activeScreenCallTargets = new Set(); export function broadcastCandidateScreenStream(stream) { if (!stream || !stream.active) return; const videoTracks = stream.getVideoTracks(); if (!videoTracks || videoTracks.length === 0 || videoTracks[0].readyState !== 'live') return; const interviewId = document.body?.dataset?.interviewId; const submissionId = document.body?.dataset?.submissionId || interviewId; if (!interviewId || typeof window.Peer === 'undefined') return; const subIdPart = (submissionId && submissionId !== String(interviewId)) ? ('_' + submissionId) : ''; const legacyScreenPeerId = 'interviewer_screen_' + interviewId + subIdPart; const targetPeerIds = new Set(); // Collect dedicated screen receiver targets and main interviewer peers if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) { window.latestActivePeers.forEach(p => { if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_'); targetPeerIds.add(screenTargetId); targetPeerIds.add(p.peer_id); } }); } targetPeerIds.add(legacyScreenPeerId); const makeCalls = (peer) => { targetPeerIds.forEach(targetId => { if (activeScreenCallTargets.has(targetId)) return; try { console.log('[WebRTC Screen] Broadcasting screen to interviewer target:', targetId); const outCall = peer.call(targetId, stream); if (outCall) { activeScreenCallTargets.add(targetId); outCall.on('close', () => { console.log('[WebRTC Screen] Screen call closed for target:', targetId); activeScreenCallTargets.delete(targetId); }); outCall.on('error', (err) => { console.warn('[WebRTC Screen] Screen call error for target:', targetId, err); activeScreenCallTargets.delete(targetId); }); } } catch(e) { activeScreenCallTargets.delete(targetId); } }); }; if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed && candidateScreenPeerInstance.open) { makeCalls(candidateScreenPeerInstance); } else { if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { try { candidateScreenPeerInstance.destroy(); } catch(e) {} } activeScreenCallTargets.clear(); const screenPeerId = 'cand_screen_' + interviewId + subIdPart + '_' + Date.now(); initMeteredIceServers().then(iceServers => { candidateScreenPeerInstance = new window.Peer(screenPeerId, { config: { iceServers: iceServers } }); candidateScreenPeerInstance.on('open', () => { makeCalls(candidateScreenPeerInstance); }); candidateScreenPeerInstance.on('error', err => { console.warn('[WebRTC Screen] Candidate screen peer error:', err); }); }).catch(() => { candidateScreenPeerInstance = new window.Peer(screenPeerId); candidateScreenPeerInstance.on('open', () => { makeCalls(candidateScreenPeerInstance); }); }); } } // --- Screen Share Enforcement & Live Overlay Detector (Category 6) --- export function startScreenShare() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { alert('Screen sharing is not supported on your browser.'); return; } beginNativePrompt(); navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'monitor' }, audio: false }) .then(stream => { endNativePrompt(); const track = stream.getVideoTracks()[0]; // Enforce ENTIRE SCREEN share across all browsers if (!isEntireScreenShare(track)) { track.stop(); screenShareStream = null; logViolation('tab_switch', 'Candidate attempted non-entire screen share (tab or window)'); if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'error', title: 'Entire Screen Share Required', html: 'You selected a single tab or window.

You MUST select "Entire Screen" (or whole monitor) to continue your interview.', confirmButtonText: 'Reshare Entire Screen Now', confirmButtonColor: '#ef4444', allowOutsideClick: false, allowEscapeKey: false }).then(() => { setTimeout(startScreenShare, 300); }); } else { alert('Entire Screen share is required. Please select Entire Screen.'); setTimeout(startScreenShare, 300); } return; } screenShareStream = stream; const vidElem = getScreenShareVideoElement(); vidElem.srcObject = stream; vidElem.play().catch(() => {}); updateScreenShareButton(true); enforceBrowserFullscreen(); broadcastCandidateScreenStream(stream); track.onended = () => { screenShareStream = null; activeScreenCallTargets.clear(); if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { try { candidateScreenPeerInstance.destroy(); } catch(e) {} candidateScreenPeerInstance = null; } updateScreenShareButton(false); if (document.fullscreenElement) { try { document.exitFullscreen().catch(() => {}); } catch(e) {} } logViolation('tab_switch', 'Candidate stopped screen sharing'); }; initLiveOverlayDetector(); }) .catch(err => { endNativePrompt(); console.log('Screen sharing cancelled/denied:', err); screenShareStream = null; activeScreenCallTargets.clear(); updateScreenShareButton(false); }); } export function stopScreenShare() { if (screenShareStream) { try { screenShareStream.getTracks().forEach(track => track.stop()); } catch(e) {} screenShareStream = null; } activeScreenCallTargets.clear(); if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { try { candidateScreenPeerInstance.destroy(); } catch(e) {} candidateScreenPeerInstance = null; } updateScreenShareButton(false); if (document.fullscreenElement) { try { document.exitFullscreen().catch(() => {}); } catch(e) {} } logViolation('tab_switch', 'Candidate stopped screen sharing'); } export function toggleScreenShare() { if (screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().length > 0 && screenShareStream.getVideoTracks()[0].readyState === 'live') { stopScreenShare(); } else { startScreenShare(); } } export function updateScreenShareButton(active) { const btn = document.getElementById('share-screen-btn'); if (!btn) return; if (active) { btn.innerHTML = ' Stop Screen Sharing'; btn.style.background = '#ef4444'; btn.style.borderColor = '#dc2626'; btn.onclick = stopScreenShare; } else { btn.innerHTML = ' Share Screen with Interviewer'; btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; btn.style.borderColor = '#6366f1'; btn.onclick = startScreenShare; } } export function initLiveOverlayDetector() { if (!isCandidateAssessmentPage()) return; if (overlayCheckInterval) clearInterval(overlayCheckInterval); overlayCheckInterval = setInterval(() => { if (!screenShareStream || screenShareStream.getVideoTracks().length === 0) return; const track = screenShareStream.getVideoTracks()[0]; if (track.readyState !== 'live') return; const vidElem = getScreenShareVideoElement(); if (!vidElem || vidElem.videoWidth === 0) return; try { if (!document.hasFocus()) { const now = Date.now(); if (now - lastTabSwitchTime > 3000) { lastTabSwitchTime = now; logViolation('tab_switch', 'Candidate window lost focus during active screen share'); captureAndUploadTabScreenshot('tab_switch'); } } } catch(e) {} }, 2000); } // --- Continuous Speech Recognition (Category 5) --- export function initQuestionRepeatDetection() { if (!isCandidateAssessmentPage()) return; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) return; try { const speechRec = new SpeechRecognition(); speechRec.continuous = true; speechRec.interimResults = false; speechRec.lang = 'en-US'; speechRec.onresult = function(event) { for (let i = event.resultIndex; i < event.results.length; ++i) { if (event.results[i].isFinal) { const transcript = event.results[i][0].transcript.trim().toLowerCase(); if (transcript.length < 4) continue; const pageText = (document.body ? document.body.innerText : '').toLowerCase(); const words = transcript.split(/\s+/).filter(w => w.length > 3); let matchedWordCount = 0; words.forEach(w => { if (pageText.includes(w)) matchedWordCount++; }); const isReadingQuestionOnScreen = words.length >= 3 && (matchedWordCount / words.length) >= 0.5; const isRepeatedSpeech = candidateSpeechHistory.some(prev => { return prev === transcript || (transcript.length > 8 && prev.includes(transcript)) || (prev.length > 8 && transcript.includes(prev)); }); candidateSpeechHistory.push(transcript); if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift(); const phoneKeywords = ['hello', 'can you hear me', 'on phone', 'calling', 'call you back', 'hold on', 'on the line', 'speaker', 'phone', 'mobile', 'call me', 'speak later', 'hey call', 'on a call']; const isPhoneTalk = phoneKeywords.some(kw => transcript.includes(kw)); const isCallConnected = window.isCallConnected ? window.isCallConnected() : false; if (isPhoneTalk) { logViolation('talking_on_phone', `Candidate detected talking on phone / phone call during assessment: "${transcript}"`); } else if (isReadingQuestionOnScreen || isRepeatedSpeech) { if (window.candidateProctoring?.lastStates?.lookingAway) { logViolation('reading_external_device', `Candidate detected reading from external device or mobile screen: "${transcript}"`); } else { logViolation('question_repetition', `Candidate detected reading / repeating question out loud: "${transcript}" (suspected AI prompt ingestion)`); } } else { if (!isCallConnected && transcript.length > 10) { logViolation('talking_secondary_person', `Candidate speaking out loud to secondary person in room: "${transcript}"`); } } } } }; speechRec.onerror = function() { setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000); }; speechRec.onend = function() { setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500); }; speechRec.start(); } catch(e) {} } // --- Candidate Proctoring Event Listeners (Tab focus, Clipboard, Hotkeys, DOM Observer, PiP, Fullscreen) --- export function initCandidateProctoringListeners() { if (!isCandidateAssessmentPage()) return; // 1. Visibility Change (Tab Switched / Window Minimized) document.addEventListener('visibilitychange', function () { if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; if (document.hidden) { const now = Date.now(); if (now - lastTabSwitchTime > 1000) { lastTabSwitchTime = now; logViolation('tab_switch', 'Candidate switched browser tab or minimized window'); captureAndUploadTabScreenshot(); } } }); // 2. Window Blur (Assessment Window Lost Focus) window.addEventListener('blur', function () { if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; const now = Date.now(); if (now - lastTabSwitchTime > 1000) { lastTabSwitchTime = now; logViolation('tab_switch', 'Candidate assessment window lost focus'); captureAndUploadTabScreenshot('tab_switch'); } }); // 3. Mouse Viewport Departure document.addEventListener('mouseleave', function () { if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; const now = Date.now(); if (now - lastTabSwitchTime > 2500) { lastTabSwitchTime = now; logViolation('tab_switch', 'Candidate cursor departed assessment window viewport'); captureAndUploadTabScreenshot('tab_switch'); } }); // 4. Clipboard Events (Paste into Editor / Copy from Assessment Window) document.addEventListener('paste', function () { if (!isCandidateAssessmentPage()) return; logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); }, true); document.addEventListener('copy', function () { if (!isCandidateAssessmentPage()) return; logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)'); captureAndUploadTabScreenshot(); }); // 5. Global Keydown AI Hotkey Interception (Capture Phase) window.addEventListener('keydown', function (e) { if (!isCandidateAssessmentPage()) return; if (e.code === 'Tab' || e.key === 'Tab') return; const key = (e.key || '').toLowerCase(); const code = (e.code || '').toLowerCase(); const isAlt = e.altKey; const isCmd = e.metaKey; const isCtrl = e.ctrlKey; const isShift = e.shiftKey; const isAiHotkey = (isAlt && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'c' || key === 'v' || key === 'q' || key === 'w' || key === 'e' || key === 'x' || key === 'z')) || (isCmd && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'x')) || (isCtrl && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'i' || key === 'c' || key === 'x')) || (isCmd && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'x' || key === '4' || key === '3')) || (isCtrl && isAlt) || (key === 'f12' || code === 'f12' || key === 'f11' || code === 'f11'); if (isAiHotkey) { logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI assistant shortcut hotkey (${e.code || e.key})`); captureAndUploadTabScreenshot('external_ai_detected'); } }, true); // 6. Injected Extension DOM Observer try { const aiMutationObserver = new MutationObserver(mutations => { if (!isCandidateAssessmentPage()) return; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { const nodeStr = ((node.id || '') + ' ' + (node.className || '') + ' ' + (node.getAttribute('src') || '')).toLowerCase(); if (nodeStr.includes('parakeet') || nodeStr.includes('finalround') || nodeStr.includes('copilot') || nodeStr.includes('ai-assistant') || nodeStr.includes('otter')) { logViolation('external_ai_detected', 'candidate might use external ai: Injected AI extension overlay element detected in DOM (' + node.tagName + ')'); captureAndUploadTabScreenshot('external_ai_detected'); } } } } }); aiMutationObserver.observe(document.documentElement, { childList: true, subtree: true }); } catch(e) {} // 7. Picture-in-Picture Floating AI Window Detector setInterval(function() { if (!isCandidateAssessmentPage()) return; if (document.pictureInPictureElement) { logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active'); } }, 6000); // 8. Fullscreen Exit Listener (Only enforced when screen sharing is active) document.addEventListener('fullscreenchange', function() { if (!isCandidateAssessmentPage() || isFocusSuppressed() || !isScreenSharingActive()) return; if (!document.fullscreenElement) { logViolation('tab_switch', 'Candidate exited full-screen proctoring mode while screen sharing'); captureAndUploadTabScreenshot(); showFullscreenRequiredModal(); } }); } // --- MediaPipe Proctoring Classes --- class WarningPresenter { constructor() { this.element = null; } show(message) { // Candidate-side violation notifications removed per directive. // All violations are silently logged and sent to the server. } } class SnapshotService { constructor(video, canvas) { this.video = video; this.canvas = canvas; } capture() { if (!this.video || this.video.readyState < 2 || !this.video.videoWidth) return null; const width = Math.min(640, this.video.videoWidth); const height = Math.round(width * this.video.videoHeight / this.video.videoWidth); this.canvas.width = width; this.canvas.height = height; this.canvas.getContext('2d').drawImage(this.video, 0, 0, width, height); return this.canvas.toDataURL('image/jpeg', SNAPSHOT_QUALITY); } } class FacePoseDetector { constructor(landmarker) { this.landmarker = landmarker; } detect(video, timestamp) { const result = this.landmarker.detectForVideo(video, timestamp); const faces = result.faceLandmarks || []; return { count: faces.length, pose: faces[0] ? this.estimatePose(faces[0]) : null, gaze: faces[0] ? this.estimateGaze(faces[0]) : null, }; } estimatePose(landmarks) { const point = (index) => landmarks[index]; const leftEye = point(33); const rightEye = point(263); const nose = point(1); const mouth = point(13); const eyeMidX = (leftEye.x + rightEye.x) / 2; const eyeMidY = (leftEye.y + rightEye.y) / 2; const eyeDistance = Math.max(0.001, Math.abs(rightEye.x - leftEye.x)); const yaw = (nose.x - eyeMidX) / eyeDistance; const pitch = (nose.y - eyeMidY) / Math.max(0.001, Math.abs(mouth.y - eyeMidY)); return { yaw, pitch, confidence: Math.min(1, 0.5 + Math.abs(yaw) * 0.4 + Math.abs(pitch - 0.5) * 0.2) }; } estimateGaze(landmarks) { const leftEye = this.estimateEyeGaze(landmarks, { corners: [33, 133], top: [159, 158], bottom: [145, 153], iris: [468, 469, 470, 471, 472], }); const rightEye = this.estimateEyeGaze(landmarks, { corners: [362, 263], top: [386, 385], bottom: [374, 380], iris: [473, 474, 475, 476, 477], }); const eyes = [leftEye, rightEye].filter(Boolean); if (!eyes.length) return null; const horizontal = eyes.reduce((total, eye) => total + eye.horizontal, 0) / eyes.length; const vertical = eyes.reduce((total, eye) => total + eye.vertical, 0) / eyes.length; const confidence = Math.min(1, 0.45 + Math.max(Math.abs(horizontal - 0.5), Math.abs(vertical - 0.5)) * 1.4); return { horizontal, vertical, confidence }; } estimateEyeGaze(landmarks, config) { const points = [...config.corners, ...config.top, ...config.bottom, ...config.iris].map((index) => landmarks[index]); if (points.some((point) => !point)) return null; const cornerA = landmarks[config.corners[0]]; const cornerB = landmarks[config.corners[1]]; const topY = this.averageCoordinate(landmarks, config.top, 'y'); const bottomY = this.averageCoordinate(landmarks, config.bottom, 'y'); const irisX = this.averageCoordinate(landmarks, config.iris, 'x'); const irisY = this.averageCoordinate(landmarks, config.iris, 'y'); const leftX = Math.min(cornerA.x, cornerB.x); const rightX = Math.max(cornerA.x, cornerB.x); return { horizontal: this.clamp01((irisX - leftX) / Math.max(0.001, rightX - leftX)), vertical: this.clamp01((irisY - topY) / Math.max(0.001, bottomY - topY)), }; } averageCoordinate(landmarks, indexes, coordinate) { return indexes.reduce((total, index) => total + landmarks[index][coordinate], 0) / indexes.length; } clamp01(value) { return Math.max(0, Math.min(1, value)); } } class LookingAwayClassifier { classify(face) { const reasons = []; const pose = face.pose; const gaze = face.gaze; if (pose && Math.abs(pose.yaw) > HEAD_YAW_THRESHOLD) reasons.push('head_yaw'); if (pose && pose.pitch < HEAD_PITCH_UP_THRESHOLD) reasons.push('head_up'); if (pose && pose.pitch > HEAD_PITCH_DOWN_THRESHOLD) reasons.push('head_down'); if (gaze && gaze.horizontal < GAZE_HORIZONTAL_LEFT_THRESHOLD) reasons.push('eye_gaze_left'); if (gaze && gaze.horizontal > GAZE_HORIZONTAL_RIGHT_THRESHOLD) reasons.push('eye_gaze_right'); if (gaze && gaze.vertical > GAZE_DOWN_THRESHOLD) reasons.push('eye_gaze_down'); return { active: reasons.length > 0, confidence: this.confidence(reasons, pose, gaze), details: { reasons, yaw: pose?.yaw ?? null, pitch: pose?.pitch ?? null, gaze_horizontal: gaze?.horizontal ?? null, gaze_vertical: gaze?.vertical ?? null, }, }; } confidence(reasons, pose, gaze) { if (!reasons.length) return 0; return Math.max( reasons.some((reason) => reason.startsWith('head_')) ? pose?.confidence || 0 : 0, reasons.some((reason) => reason.startsWith('eye_')) ? gaze?.confidence || 0 : 0, ); } } class PhoneDetector { constructor(detector) { this.detector = detector; } detect(video, timestamp) { if (!this.detector || !video || video.readyState < 2 || !video.videoWidth) return null; try { const ts = Math.round(timestamp); const result = this.detector.detectForVideo(video, ts); const detections = result ? (result.detections || []) : []; for (const detection of detections) { const categories = detection.categories || []; for (const category of categories) { const name = (category.categoryName || category.displayName || '').toLowerCase(); const score = category.score || 0; if (/cell phone|mobile phone|phone|remote|handheld|device|calculator|tablet/i.test(name) && score >= 0.15) { console.log('[proctoring:phone_detected_raw]', { name, score }); return { confidence: score, label: category.categoryName || category.displayName || 'cell phone' }; } } } } catch (e) { console.warn('[proctoring:phone_detect_error]', e.message); } return null; } } class DurationTracker { constructor() { this.activeSince = { lookingAway: null, noFace: null, multipleFaces: null }; this.total = { lookingAwayMs: 0, noFaceMs: 0, multipleFacesMs: 0 }; } update(name, active, now) { const startedAt = this.activeSince[name]; if (active && startedAt === null) this.activeSince[name] = now; if (!active && startedAt !== null) { this.total[`${name}Ms`] += now - startedAt; this.activeSince[name] = null; } } finish(now) { Object.keys(this.activeSince).forEach((name) => this.update(name, false, now)); return { ...this.total }; } } class ProlongedLookingAwayTracker { constructor({ thresholdMs, recheckMs }) { this.thresholdMs = thresholdMs; this.recheckMs = recheckMs; this.startedAt = null; this.nextCheckAt = null; } update(active, now) { if (!active) { this.reset(); return null; } if (this.startedAt === null) this.startedAt = now; const durationMs = now - this.startedAt; if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) return null; const recheck = this.nextCheckAt !== null; this.nextCheckAt = now + this.recheckMs; return { duration_ms: Math.round(durationMs), threshold_ms: this.thresholdMs, recheck, recheck_interval_ms: this.recheckMs, next_check_at: new Date(Date.now() + this.recheckMs).toISOString(), }; } reset() { this.startedAt = null; this.nextCheckAt = null; } } class CornerGazeClassifier { classify(face) { const pose = face.pose; const gaze = face.gaze; if (!pose || !gaze) return null; const horiz = gaze.horizontal; const vert = gaze.vertical; const yaw = pose.yaw; const pitch = pose.pitch; const isLeft = (horiz <= 0.42 || yaw <= -0.16); const isRight = (horiz >= 0.58 || yaw >= 0.16); const isTop = (vert <= 0.44 || pitch <= 0.38); const isBottom = (vert >= 0.56 || pitch >= 0.62); let corner = null; let cornerLabel = null; if (isLeft && isTop) { corner = 'top_left'; cornerLabel = 'Top-Left Screen Corner'; } else if (isRight && isTop) { corner = 'top_right'; cornerLabel = 'Top-Right Screen Corner'; } else if (isLeft && isBottom) { corner = 'bottom_left'; cornerLabel = 'Bottom-Left Screen Corner'; } else if (isRight && isBottom) { corner = 'bottom_right'; cornerLabel = 'Bottom-Right Screen Corner'; } else if (isBottom) { corner = 'bottom_center'; cornerLabel = 'Screen Bottom / Downside'; } else if (isLeft) { corner = 'screen_left'; cornerLabel = 'Screen Left Side'; } else if (isRight) { corner = 'screen_right'; cornerLabel = 'Screen Right Side'; } if (!corner) return null; const confidence = Math.min(1, 0.5 + Math.max(Math.abs(horiz - 0.5), Math.abs(vert - 0.5)) * 1.2); return { corner, corner_label: cornerLabel, confidence, details: { corner, corner_label: cornerLabel, yaw, pitch, gaze_horizontal: horiz, gaze_vertical: vert, } }; } } class CornerGazeTracker { constructor({ thresholdMs, recheckMs, graceMs }) { this.thresholdMs = thresholdMs; this.recheckMs = recheckMs; this.graceMs = graceMs; this.currentCorner = null; this.startedAt = null; this.lastActiveAt = null; this.nextCheckAt = null; } update(cornerResult, now) { if (!cornerResult) { if (this.lastActiveAt && (now - this.lastActiveAt) > this.graceMs) { this.reset(); } return null; } const activeCorner = cornerResult.corner; const isRelatedZone = (this.currentCorner && ( (this.currentCorner.startsWith('bottom_') && activeCorner.startsWith('bottom_')) || (this.currentCorner.startsWith('top_') && activeCorner.startsWith('top_')) || (this.currentCorner === activeCorner) )); if (!this.currentCorner || !isRelatedZone) { this.currentCorner = activeCorner; this.startedAt = now; this.nextCheckAt = null; } else { this.currentCorner = activeCorner; } this.lastActiveAt = now; const durationMs = now - this.startedAt; if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) { return null; } this.nextCheckAt = now + this.recheckMs; return { corner: activeCorner, corner_label: cornerResult.corner_label, duration_ms: Math.round(durationMs), duration_s: Math.round(durationMs / 1000), confidence: cornerResult.confidence, details: { ...cornerResult.details, duration_ms: Math.round(durationMs), duration_s: Math.round(durationMs / 1000), } }; } reset() { this.currentCorner = null; this.startedAt = null; this.lastActiveAt = null; this.nextCheckAt = null; } } class CandidateProctoring { constructor({ sessionId, video, canvas }) { this.sessionId = sessionId; this.video = video; this.canvas = canvas; this.logger = new StructuredEventLogger(sessionId); this.warning = new WarningPresenter(); this.snapshot = new SnapshotService(video, canvas); this.durations = new DurationTracker(); this.lookingAwayClassifier = new LookingAwayClassifier(); this.prolongedLookingAway = new ProlongedLookingAwayTracker({ thresholdMs: PROLONGED_LOOKING_AWAY_MS, recheckMs: PROLONGED_LOOKING_AWAY_RECHECK_MS, }); this.cornerGazeClassifier = new CornerGazeClassifier(); this.cornerGazeTracker = new CornerGazeTracker({ thresholdMs: CORNER_FIXATION_MS, recheckMs: CORNER_RECHECK_MS, graceMs: CORNER_JITTER_GRACE_MS, }); this.startedAt = new Date().toISOString(); this.lastSampleAt = 0; this.lastPhoneEventAt = 0; this.lastStates = { lookingAway: false, noFace: false, multipleFaces: false }; this.running = false; this.modelsReady = false; this.reported = false; } async start() { if (this.running) return; try { const vision = await this.importVisionModule(); const fileset = await vision.FilesetResolver.forVisionTasks(WASM_ROOT); const faceLandmarker = await this.withAmdLoaderDisabled(() => this.createModel(vision.FaceLandmarker, fileset, { modelAssetPath: FACE_MODEL_URL, runningMode: 'VIDEO', numFaces: 3, minFaceDetectionConfidence: 0.5, minFacePresenceConfidence: 0.5, minTrackingConfidence: 0.5, }) ); this.faceDetector = new FacePoseDetector(faceLandmarker); try { const objectDetector = await this.withAmdLoaderDisabled(() => this.createModel(vision.ObjectDetector, fileset, { modelAssetPath: OBJECT_MODEL_URL, runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.35, }) ); this.phoneDetector = new PhoneDetector(objectDetector); console.log('[proctoring:object_detector_ready]'); } catch (objErr) { console.error('[proctoring:object_detector_failed]', objErr); } this.modelsReady = true; this.running = true; this.scheduleNextSample(); console.log('[proctoring:ready]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId }); } catch (error) { console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'model_initialization_failed', message: error.message }); } } async importVisionModule() { try { return await import(/* @vite-ignore */ VISION_MODULE_URL); } catch (primaryError) { console.warn('[proctoring:warning]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'vision_cdn_fallback', message: primaryError.message, }); return import(/* @vite-ignore */ VISION_MODULE_FALLBACK_URL); } } async withAmdLoaderDisabled(callback) { const amdGlobals = ['define', 'require', 'requirejs']; const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] })); amdGlobals.forEach((name) => { try { delete globalThis[name]; } catch (error) { globalThis[name] = undefined; } }); try { return await callback(); } finally { saved.forEach(({ name, value }) => { if (value === undefined) delete globalThis[name]; else globalThis[name] = value; }); } } async createModel(Model, fileset, options) { try { return await Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'GPU' } }); } catch (gpuError) { console.warn('[proctoring:warning]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'gpu_delegate_unavailable', message: gpuError.message }); return Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'CPU' } }); } } scheduleNextSample() { if (!this.running) return; setTimeout(() => this.sample(), SAMPLE_INTERVAL_MS); } sample() { if (!this.running) return; if (this.video.readyState < 2 || !this.video.videoWidth) return this.scheduleNextSample(); const timestamp = performance.now(); try { const face = this.faceDetector.detect(this.video, timestamp); const phone = this.phoneDetector ? this.phoneDetector.detect(this.video, timestamp) : null; this.processFace(face, timestamp); this.processPhone(phone, timestamp); } catch (error) { console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'frame_processing_failed', message: error.message }); } this.lastSampleAt = timestamp; this.scheduleNextSample(); } processFace(result, now) { const noFace = result.count === 0; const multipleFaces = result.count > 1; const lookingAwayResult = this.lookingAwayClassifier.classify(result); const cornerGazeResult = this.cornerGazeClassifier.classify(result); this.durations.update('noFace', noFace, now); this.durations.update('multipleFaces', multipleFaces, now); this.durations.update('lookingAway', lookingAwayResult.active, now); this.transition('noFace', noFace, noFace ? 0.95 : 0, { face_count: result.count }, NO_FACE_GRACE_MS, 'Candidate face is not visible'); this.transition('multipleFaces', multipleFaces, multipleFaces ? 0.95 : 0, { face_count: result.count }, 0, 'Multiple faces detected'); this.transition('lookingAway', lookingAwayResult.active, lookingAwayResult.confidence, lookingAwayResult.details, LOOKING_AWAY_GRACE_MS, 'Please look toward the camera'); this.trackProlongedLookingAway(lookingAwayResult, now); this.trackCornerGaze(cornerGazeResult, now); } transition(name, active, confidence, details, graceMs, message) { if (active === this.lastStates[name]) return; if (active && graceMs > 0 && this.durations.activeSince[name] && performance.now() - this.durations.activeSince[name] < graceMs) return; this.lastStates[name] = active; if (active) { this.warning.show(message); const eventName = name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces'; this.logger.record(eventName, confidence, details, this.snapshot.capture()); } } trackProlongedLookingAway(lookingAwayResult, now) { const prolongedEvent = this.prolongedLookingAway.update(lookingAwayResult.active, now); if (!prolongedEvent) return; this.warning.show(`Candidate has been looking away for over ${Math.round(PROLONGED_LOOKING_AWAY_MS / 1000)} seconds`); this.logger.record('prolonged_looking_away', lookingAwayResult.confidence, { ...prolongedEvent, ...lookingAwayResult.details, }, this.snapshot.capture()); } trackCornerGaze(cornerResult, now) { const cornerEvent = this.cornerGazeTracker.update(cornerResult, now); if (!cornerEvent) return; this.logger.record('prolonged_corner_gaze', cornerEvent.confidence, { ...cornerEvent.details, }, this.snapshot.capture()); } processPhone(phone, now) { if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return; this.lastPhoneEventAt = now; this.warning.show('Possible phone detected in camera frame'); this.logger.record('phone_detected', phone.confidence, { label: phone.label }, this.snapshot.capture()); } stop() { if (this.reported) return null; this.running = false; this.reported = true; return this.logger.report(this.startedAt, new Date().toISOString(), this.durations.finish(performance.now())); } } // --- Candidate Proctoring Auto-Boot & Listener Registration --- function bootCandidateProctoring() { if (!isCandidateAssessmentPage()) return; const sessionId = document.body.dataset.interviewId; if (!sessionId) return; const video = document.getElementById('webcam'); const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas'); // Boot MediaPipe proctoring if webcam element exists if (video && navigator.mediaDevices) { const proctor = new CandidateProctoring({ sessionId, video, canvas }); window.candidateProctoring = proctor; const startWhenReady = () => proctor.start(); if (video.readyState >= 2) startWhenReady(); video.addEventListener('loadeddata', startWhenReady, { once: true }); const callStatus = document.getElementById('call-status-badge'); if (callStatus) { const statusObserver = new MutationObserver(() => { if (/ended/i.test(callStatus.textContent || '')) proctor.stop(); }); statusObserver.observe(callStatus, { childList: true, characterData: true, subtree: true }); } window.addEventListener('pagehide', () => proctor.stop(), { once: true }); } // Initialize all candidate cheat detection event listeners & speech analysis initCandidateProctoringListeners(); initQuestionRepeatDetection(); } // --- Window Bindings for Proctoring --- if (typeof window !== 'undefined') { window.logViolation = logViolation; window.captureAndUploadTabScreenshot = captureAndUploadTabScreenshot; window.fallbackCompositeSnapshot = fallbackCompositeSnapshot; window.uploadTabScreenshotBlob = uploadTabScreenshotBlob; window.beginNativePrompt = beginNativePrompt; window.endNativePrompt = endNativePrompt; window.armTrailingBuffer = armTrailingBuffer; window.isFocusSuppressed = isFocusSuppressed; window.isScreenSharingActive = isScreenSharingActive; window.startScreenShare = startScreenShare; window.stopScreenShare = stopScreenShare; window.toggleScreenShare = toggleScreenShare; window.updateScreenShareButton = updateScreenShareButton; window.initLiveOverlayDetector = initLiveOverlayDetector; window.initQuestionRepeatDetection = initQuestionRepeatDetection; window.initCandidateProctoringListeners = initCandidateProctoringListeners; window.isCandidateAssessmentPage = isCandidateAssessmentPage; window.isCandidateCallActive = isCandidateCallActive; window.broadcastCandidateScreenStream = broadcastCandidateScreenStream; window.getScreenShareStream = () => screenShareStream; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true }); } else { bootCandidateProctoring(); } }