diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index dd5d439..2937cc8 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -306,6 +306,18 @@ public function updateOnboardingToggle(Request $request) return redirect()->route('admin.dashboard')->with('success', $msg); } + /** + * Toggle candidate system screenshot capture globally. + */ + public function updateScreenshotToggle(Request $request) + { + $enabled = $request->boolean('enable_candidate_screenshots') ? '1' : '0'; + \App\Models\Setting::set('enable_candidate_screenshots', $enabled); + + $msg = $enabled === '1' ? 'Candidate System Screenshot Capture ENABLED globally.' : 'Candidate System Screenshot Capture DISABLED globally by Admin.'; + return redirect()->route('admin.dashboard')->with('success', $msg); + } + /** * Toggle the admin privilege of a user. */ diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index db5415d..a5cd4ef 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -148,96 +148,96 @@ public function executeCode(Request $request) $request->validate([ 'language' => 'required|string', 'code' => 'required|string', + 'interview_id' => 'nullable|string', ]); - $langMap = [ - 'python' => ['language' => 'python', 'version' => '*'], - 'c' => ['language' => 'c', 'version' => '*'], - 'cpp' => ['language' => 'c++', 'version' => '*'], - 'java' => ['language' => 'java', 'version' => '*'], - 'php' => ['language' => 'php', 'version' => '*'], - 'laravel' => ['language' => 'php', 'version' => '*'], - 'javascript' => ['language' => 'javascript', 'version' => '*'], - ]; - - $langKey = strtolower($request->language); - $targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*']; + $langKey = strtolower(trim($request->language)); + $code = $request->code; + $output = ''; + // 1. Instant Local CLI Process Execution (0.02s response for Node.js, Python, PHP) try { - $response = Http::withHeaders([ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept' => 'application/json', - ])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [ - 'language' => $targetLang['language'], - 'version' => $targetLang['version'], - 'files' => [ - [ - 'name' => 'solution', - 'content' => $request->code, - ] - ], - ]); - - if ($response->successful()) { - $data = $response->json(); - $output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.'); - return response()->json(['success' => true, 'output' => $output]); - } - - // Retry with explicit fallback version if wildcard returned non-200 - $fallbackVersions = [ - 'python' => '3.10.0', - 'c' => '10.2.0', - 'c++' => '10.2.0', - 'java' => '15.0.2', - 'php' => '8.2.3', - 'javascript' => '18.15.0', - ]; - - $retryResponse = Http::withHeaders([ - 'User-Agent' => 'SingleLogin-ProctoredIDE/1.0', - 'Accept' => 'application/json', - ])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [ - 'language' => $targetLang['language'], - 'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0', - 'files' => [ - [ - 'name' => 'solution', - 'content' => $request->code, - ] - ], - ]); - - if ($retryResponse->successful()) { - $data = $retryResponse->json(); - $output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.'); - return response()->json(['success' => true, 'output' => $output]); - } - - // Fallback for local PHP execution if PHP/Laravel language requested - if (in_array($langKey, ['php', 'laravel'])) { + if (in_array($langKey, ['javascript', 'js'])) { + $process = new \Symfony\Component\Process\Process(['node', '-e', $code]); + $process->setTimeout(4); + $process->run(); + $out = $process->getOutput() ?: $process->getErrorOutput(); + $output = trim($out) ?: 'JavaScript executed successfully (no stdout).'; + } elseif ($langKey === 'python') { + $process = new \Symfony\Component\Process\Process(['python', '-c', $code]); + $process->setTimeout(4); + $process->run(); + $out = $process->getOutput() ?: $process->getErrorOutput(); + $output = trim($out) ?: 'Python executed successfully (no stdout).'; + } elseif (in_array($langKey, ['php', 'laravel'])) { ob_start(); try { - $cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $request->code); + $cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $code); eval($cleanCode); - $localOutput = ob_get_clean(); - return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']); + $out = ob_get_clean(); + $output = trim($out) ?: 'PHP code executed successfully.'; } catch (\Throwable $e) { ob_end_clean(); - return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]); + $output = 'PHP Evaluation Error: ' . $e->getMessage(); } } - - return response()->json([ - 'success' => true, - 'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record." - ]); } catch (\Exception $e) { - return response()->json([ - 'success' => true, - 'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record." - ]); + // Local execution exception; fall through to Judge0 API } + + // 2. Remote Compiler API (Judge0 CE) for C, C++, Java or non-local runtimes + if (empty($output)) { + $judge0Map = [ + 'python' => 71, + 'c' => 50, + 'cpp' => 54, + 'c++' => 54, + 'java' => 62, + 'php' => 68, + 'laravel' => 68, + 'javascript' => 63, + 'js' => 63, + ]; + + if (isset($judge0Map[$langKey])) { + try { + $response = Http::timeout(5)->post('https://ce.judge0.com/submissions?wait=true', [ + 'source_code' => $code, + 'language_id' => $judge0Map[$langKey], + ]); + + if ($response->successful()) { + $data = $response->json(); + $stdout = $data['stdout'] ?? ''; + $stderr = $data['stderr'] ?? ''; + $compile = $data['compile_output'] ?? ''; + $msg = $data['message'] ?? ''; + + $outputParts = array_filter([$stdout, $stderr, $compile, $msg]); + $output = trim(implode("\n", $outputParts)); + } + } catch (\Exception $e) {} + } + } + + if (empty($output)) { + $output = "=== Code Execution Status ===\nSolution executed.\nLanguage: " . strtoupper($langKey); + } + + // Auto-persist candidate code + execution output directly to interview model + $interviewId = $request->input('interview_id'); + if ($interviewId) { + $interview = Interview::where('id', $interviewId)->orWhere('submission_unique_id', $interviewId)->first(); + if ($interview) { + $interview->update([ + 'submitted_code' => $code, + 'submitted_language' => $langKey, + 'code_output' => $output, + ]); + } + } + + return response()->json(['success' => true, 'output' => $output]); } /** @@ -340,10 +340,14 @@ public function candidateSendMessage(Request $request, $id) public function syncCandidateCode(Request $request, $id) { $interview = Interview::findOrFail($id); - $interview->update([ + $updateData = [ 'submitted_code' => $request->input('code'), 'submitted_language' => $request->input('language', $interview->language), - ]); + ]; + if ($request->has('output')) { + $updateData['code_output'] = $request->input('output'); + } + $interview->update($updateData); return response()->json(['success' => true]); } @@ -570,13 +574,18 @@ public function getPollData($id) $starterName = $interview->callStarter ? $interview->callStarter->name : null; + $globalScreenshotsEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1'; + $interviewScreenshotsEnabled = (bool) ($interview->enable_tab_switch_screenshot ?? true); + $effectiveScreenshotEnabled = $globalScreenshotsEnabled && $interviewScreenshotsEnabled; + return response()->json([ 'status' => $interview->status, 'call_status' => $interview->call_status ?? 'idle', 'call_started_by' => $interview->call_started_by, 'starter_name' => $starterName, 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, - 'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true), + 'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled, + 'global_screenshot_enabled' => $globalScreenshotsEnabled, 'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [], 'submitted_code' => $interview->submitted_code, 'submitted_language' => $interview->submitted_language, @@ -633,7 +642,7 @@ public function updateCallStatus(Request $request, $id) } /** - * Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/. + * Upload & Save Candidate System Screen Screenshot under public/uploads/candidate_screenshots/{unique_id}/. */ public function uploadTabScreenshot(Request $request, $id) { @@ -641,15 +650,18 @@ public function uploadTabScreenshot(Request $request, $id) ->orWhere('submission_unique_id', $id) ->firstOrFail(); - if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) { + $globalEnabled = \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1'; + $interviewEnabled = !isset($interview->enable_tab_switch_screenshot) || $interview->enable_tab_switch_screenshot; + + if (!$globalEnabled || !$interviewEnabled) { return response()->json([ 'success' => false, - 'message' => 'Tab-switch screenshot capture is disabled by admin setting.' + 'message' => 'Candidate system screenshot capture is disabled by admin setting.' ]); } - $url = null; - $filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg'; + $reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch' + $filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg'; $subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id; $destinationPath = public_path($subDir); @@ -657,6 +669,7 @@ public function uploadTabScreenshot(Request $request, $id) mkdir($destinationPath, 0777, true); } + $url = null; if ($request->hasFile('screenshot')) { $file = $request->file('screenshot'); $file->move($destinationPath, $filename); @@ -685,7 +698,7 @@ public function uploadTabScreenshot(Request $request, $id) 'filename' => $filename, 'ai_verdict' => $aiVerdict, 'timestamp' => now()->toIso8601String(), - 'reason' => 'tab_switch', + 'reason' => $reason, ]; $screenshots = $interview->tab_switch_screenshots ?? []; @@ -693,10 +706,13 @@ public function uploadTabScreenshot(Request $request, $id) $aiDetails = $aiVerdict['summary'] . ' (' . round(($aiVerdict['confidence'] ?? 0.85) * 100) . '% Confidence - ' . ($aiVerdict['engine'] ?? 'AI Engine') . ')'; + $logType = ($reason === 'call_start') ? 'call_start_screenshot' : (($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch'); + $logMsg = ($reason === 'call_start') ? '📸 CALL STARTED SYSTEM SNAPSHOT: System screen snapshot captured automatically when call started.' : ('🤖 AI INSPECTOR VERDICT: ' . $aiDetails); + $logs = $interview->proctor_logs ?? []; $logs[] = [ - 'type' => ($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch', - 'details' => '🤖 AI INSPECTOR VERDICT: ' . $aiDetails, + 'type' => $logType, + 'details' => $logMsg, 'ai_verdict' => $aiVerdict, 'screenshot_url' => $relativePath, 'timestamp' => now()->toIso8601String(), diff --git a/public/uploads/candidate_screenshots/anwesha_9163855564_1785411698/screenshot_tab_switch_1785412177_446.jpg b/public/uploads/candidate_screenshots/anwesha_9163855564_1785411698/screenshot_tab_switch_1785412177_446.jpg new file mode 100644 index 0000000..7706219 Binary files /dev/null and b/public/uploads/candidate_screenshots/anwesha_9163855564_1785411698/screenshot_tab_switch_1785412177_446.jpg differ diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index c457ecf..22b76af 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -910,6 +910,77 @@ @endif + + + + + + ⚙️ Global Admin Proctoring & System Settings + + Master control panel for candidate assessment rules, system screen capturing, and module toggles + + + 🛡️ ADMIN CONTROL PANEL + + + + + + + + + @csrf + + + 📸 Candidate System Screenshot Capture + + + Captures candidate system desktop screen automatically on call start & tab switches. Admin can stop taking screenshots anytime across all candidates. + + + + + {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Active)' : '🔴 STOPPED / DISABLED' }} + + + + + {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'Stop Screenshot Capture' : 'Enable Screenshot Capture' }} + + + + + + + + + + @csrf + + + 🚀 Candidate Provisioning & Onboarding Module + + + Master toggle to enable/disable automated candidate provisioning, onboarding checklists, and 1-click revocation. + + + + + {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? '🟢 ENABLED' : '🔴 DISABLED' }} + + + + + {{ \App\Models\Setting::get('onboarding_enabled', '1') === '1' ? 'Disable Onboarding' : 'Enable Onboarding' }} + + + + + + + + + @@ -1174,7 +1245,7 @@ - + @csrf @@ -1190,6 +1261,23 @@ + + + @csrf + + + 📸 Candidate System Screenshot Capture + Master Admin control to stop/start candidate system screen capturing (call start & tab switch) + + + + + {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? 'ENABLED' : 'STOPPED / DISABLED' }} + + + + + diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 7c8e067..d173812 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -487,7 +487,7 @@ } }); - // Execute Code live via Piston API + // Execute Code live via Piston/Judge0 Engine function runCode() { document.getElementById('run-status').innerText = 'Running...'; document.getElementById('terminal-output').innerText = 'Executing solution on code engine...'; @@ -501,16 +501,23 @@ function runCode() { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, - body: JSON.stringify({ language: lang, code: code }) + body: JSON.stringify({ language: lang, code: code, interview_id: '{{ $interview->id }}' }) }) .then(r => r.json()) .then(data => { document.getElementById('run-status').innerText = 'Ready'; - if (data.success) { - document.getElementById('terminal-output').innerText = data.output; - } else { - document.getElementById('terminal-output').innerText = 'Error: ' + data.output; - } + const outStr = data.success ? data.output : ('Error: ' + data.output); + document.getElementById('terminal-output').innerText = outStr; + + // Sync live code & execution output to backend immediately + fetch(`{{ route("interview.candidate.sync-code", $interview->id) }}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + }, + body: JSON.stringify({ code: code, language: lang, output: outStr }) + }); }) .catch(err => { document.getElementById('run-status').innerText = 'Error'; @@ -561,14 +568,19 @@ function getScreenShareVideoElement() { } // REAL CANDIDATE SYSTEM / COMPUTER SCREENSHOT CAPTURE ENGINE - function captureAndUploadTabScreenshot() { + function captureAndUploadTabScreenshot(reason = 'tab_switch') { if (!isTabSwitchScreenshotEnabled) return; try { const tempCanvas = document.createElement('canvas'); let drewScreenStream = false; - // PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Captures switched tab, external desktop apps like Parakeet AI, etc.) + 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(); + + // PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream) if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) { const screenTrack = screenShareStream.getVideoTracks()[0]; if (screenTrack.readyState === 'live') { @@ -579,24 +591,31 @@ function captureAndUploadTabScreenshot() { vidElem.play().catch(e => {}); } - const settings = screenTrack.getSettings ? screenTrack.getSettings() : {}; - const w = settings.width || vidElem.videoWidth || 1280; - const h = settings.height || vidElem.videoHeight || 720; + let rawW = vidElem.videoWidth || 1280; + let rawH = vidElem.videoHeight || 720; - if (w > 0 && h > 0) { - tempCanvas.width = w; - tempCanvas.height = h; + if (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, w, h); + tempCtx.drawImage(vidElem, 0, 0, targetW, targetH); - // Add red violation banner + // Add system screenshot banner tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); - tempCtx.fillStyle = '#ef4444'; + tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; tempCtx.font = 'bold 14px sans-serif'; - tempCtx.fillText('🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT (TAB / APP SWITCH DETECTED) • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15); + tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15); - uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85)); + uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason); drewScreenStream = true; } } catch(e) { @@ -607,32 +626,31 @@ function captureAndUploadTabScreenshot() { if (drewScreenStream) return; - // PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas (Captures exact active tab DOM, code editor, layout) + // PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas if (typeof html2canvas !== 'undefined') { - html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true }).then(canvas => { + 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 = '#ef4444'; + ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; ctx.font = 'bold 14px sans-serif'; - ctx.fillText('🚨 CANDIDATE WORKSPACE TAB SNAPSHOT • ' + new Date().toLocaleString(), 16, canvas.height - 15); - uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.85)); + ctx.fillText(bannerLabel, 16, canvas.height - 15); + uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason); }).catch(err => { - fallbackCompositeSnapshot(); + fallbackCompositeSnapshot(reason); }); } else { - fallbackCompositeSnapshot(); + fallbackCompositeSnapshot(reason); } } catch(e) { - console.log('Tab switch screenshot capture exception:', e); + console.log('Candidate system screenshot capture exception:', e); } } - function fallbackCompositeSnapshot() { + function fallbackCompositeSnapshot(reason = 'tab_switch') { const tempCanvas = document.createElement('canvas'); - const video = document.getElementById('webcam'); - const w = (video && video.videoWidth > 0) ? video.videoWidth : 1280; - const h = (video && video.videoHeight > 0) ? video.videoHeight : 720; + const w = 1280; + const h = 720; tempCanvas.width = w; tempCanvas.height = h; const tempCtx = tempCanvas.getContext('2d'); @@ -643,51 +661,47 @@ function fallbackCompositeSnapshot() { tempCtx.fillRect(0, 0, w, 50); tempCtx.fillStyle = '#38bdf8'; tempCtx.font = 'bold 16px sans-serif'; - tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32); + tempCtx.fillText('💻 Candidate System Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32); const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor'); tempCtx.fillStyle = '#020617'; - tempCtx.fillRect(20, 70, w - 240, h - 130); + tempCtx.fillRect(20, 70, w - 40, h - 130); tempCtx.fillStyle = '#94a3b8'; tempCtx.font = '13px monospace'; - const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : ''; + const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : (typeof editor !== 'undefined' && editor ? editor.getValue() : ''); const lines = codeText.split('\n').slice(0, 30); lines.forEach((line, i) => { - tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18)); + tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18)); }); - if (video && video.readyState >= 2) { - try { - tempCtx.drawImage(video, w - 210, 70, 190, 140); - tempCtx.strokeStyle = '#6366f1'; - tempCtx.lineWidth = 2; - tempCtx.strokeRect(w - 210, 70, 190, 140); - } catch(e) {} - } - + // Candidate webcam feed is explicitly omitted to ensure screenshot is candidate system screen ONLY + const isCallStart = (reason === 'call_start'); tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); - tempCtx.fillStyle = '#ef4444'; + tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; tempCtx.font = 'bold 14px sans-serif'; - tempCtx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • SYSTEM SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15); + 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)); + uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason); } - function uploadTabScreenshotBlob(imageData) { + function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') { fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, - body: JSON.stringify({ image: imageData }) + body: JSON.stringify({ image: imageData, reason: reason }) }) .then(r => r.json()) .then(data => { - console.log('Computer screen tab-switch screenshot saved:', data); + console.log('Candidate system screenshot saved:', data); }) - .catch(err => console.log('Tab screenshot fetch error:', err)); + .catch(err => console.log('System screenshot fetch error:', err)); } // 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot) @@ -1046,6 +1060,7 @@ class CandRingtoneEngine { function triggerCandidateRing(offer = null, callStartedAt = null) { if (isCandidateCallConnected) return; + if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return; if (candidateDeclinedOrLeftCall) { showCandidateJoinOption(); return; @@ -1187,6 +1202,14 @@ function showCandidateJoinOption() { await handleInterviewerOffer(pendingOffer); pendingOffer = null; } + + // Automatically capture candidate system screenshot on call start (if screenshot feature enabled) + if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) { + hasCapturedCallStartScreenshot = true; + setTimeout(() => { + captureAndUploadTabScreenshot('call_start'); + }, 1200); + } } // 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS) @@ -1197,6 +1220,8 @@ function showCandidateJoinOption() { let isMicEnabled = true; let isCamEnabled = true; let activeCallInstance = null; + let hasCapturedCallStartScreenshot = false; + let lastRungCallStartedAt = null; const globalCallChannel = new BroadcastChannel('global_call_alerts'); globalCallChannel.onmessage = (event) => { @@ -1472,6 +1497,20 @@ function candidateLogoutAction() { function candidateLeaveCall(isEndedByHost = false) { isCandidateCallConnected = false; + candidateDeclinedOrLeftCall = true; + + candRingtone.stop(); + if (candRingTimer) { + clearTimeout(candRingTimer); + candRingTimer = null; + } + + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + if (activeCallInstance) { try { activeCallInstance.close(); } catch(e) {} activeCallInstance = null; @@ -1802,7 +1841,7 @@ function saveNotepadContent() { }, body: JSON.stringify({ notes: notes }) }); - }, 800); + }, 300); } // HTML5 Canvas Drawing Logic @@ -1850,6 +1889,7 @@ function draw(e) { ctx.strokeStyle = document.getElementById('draw-color').value; ctx.lineTo(e.offsetX, e.offsetY); ctx.stroke(); + saveCanvasDrawing(); } function stopDraw() { @@ -1880,7 +1920,7 @@ function saveCanvasDrawing() { }, body: JSON.stringify({ drawing: drawingData }) }); - }, 1000); + }, 300); } setInterval(() => { @@ -1897,16 +1937,25 @@ function saveCanvasDrawing() { if (data.call_status === 'active') { latestCallStatus = 'active'; - if (!isCandidateCallConnected) { + if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) { + hasCapturedCallStartScreenshot = true; + setTimeout(() => { + captureAndUploadTabScreenshot('call_start'); + }, 1200); + } + if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) { const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999; - if (elapsed <= 10000) { + if (elapsed <= 12000 && lastRungCallStartedAt !== data.call_started_at) { + lastRungCallStartedAt = data.call_started_at; triggerCandidateRing(null, data.call_started_at); - } else { + } else if (elapsed > 12000) { showCandidateJoinOption(); } } } else if (data.call_status === 'ended' || data.call_status === 'idle') { latestCallStatus = data.call_status; + hasCapturedCallStartScreenshot = false; + lastRungCallStartedAt = null; candRingtone.stop(); if (candRingTimer) clearTimeout(candRingTimer); isCandidateCallConnected = false; diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 8c3a698..361e7e0 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -215,6 +215,32 @@ {{ session('success') }} @endif + + + + 📸 + + Global Candidate System Screenshot Control + + Captures candidate desktop screen on call start & tab switches. + Admin can stop/start screen capturing globally anytime. + + + + + + {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🟢 ENABLED (Screen Capturing Active)' : '🔴 STOPPED / DISABLED' }} + + @if(Auth::user() && Auth::user()->isAdmin()) + + @csrf + + {{ \App\Models\Setting::get('enable_candidate_screenshots', '1') === '1' ? '🛑 Stop Screenshot Capture' : '🟢 Start Screenshot Capture' }} + + + @endif + + @@ -1160,7 +1186,7 @@ function openReviewModal(id, name, uid, autoJoinCall = false) { pollReviewData(); clearInterval(sosPollInterval); - sosPollInterval = setInterval(pollReviewData, 2000); + sosPollInterval = setInterval(pollReviewData, 1200); if (autoJoinCall) { setTimeout(() => { @@ -1781,38 +1807,112 @@ function startInterviewerCall(isRejoin = false) { }); } - function leaveInterviewerCall() { + function leaveInterviewerCall(isExplicitEnd = true) { + if (currentInterviewId && isExplicitEnd) { + const endingId = currentInterviewId; + endedCallIds.add(endingId); + dismissedCallIds.add(endingId); + + if (typeof globalCallChannel !== 'undefined' && globalCallChannel) { + try { + globalCallChannel.postMessage({ + type: 'call_ended', + interview_id: endingId + }); + } catch(e) {} + } + + if (typeof callSigChannel !== 'undefined' && callSigChannel) { + try { + callSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); + } catch(e) {} + } + + if (typeof interviewerSigChannel !== 'undefined' && interviewerSigChannel) { + try { + interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); + } catch(e) {} + } + + fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + }, + body: JSON.stringify({ call_status: 'ended' }) + }).catch(e => {}); + } + if (interviewerLocalStream) { - interviewerLocalStream.getTracks().forEach(t => t.stop()); + try { + interviewerLocalStream.getTracks().forEach(t => t.stop()); + } catch(e) {} interviewerLocalStream = null; } if (activeCall) { - activeCall.close(); + try { activeCall.close(); } catch(e) {} activeCall = null; } - document.getElementById('cand-video-placeholder').style.display = 'flex'; - document.getElementById('self-video-placeholder').style.display = 'flex'; + const candPlace = document.getElementById('cand-video-placeholder'); + const screenPlace = document.getElementById('screen-video-placeholder'); + const selfPlace = document.getElementById('self-video-placeholder'); + if (candPlace) candPlace.style.display = 'flex'; + if (screenPlace) screenPlace.style.display = 'flex'; + if (selfPlace) selfPlace.style.display = 'flex'; - document.getElementById('btn-start-call').innerText = '📞 Join Call'; - document.getElementById('btn-start-call').style.display = 'inline-block'; - document.getElementById('btn-start-call').style.background = '#6366f1'; - document.getElementById('btn-leave-call').style.display = 'none'; - document.getElementById('btn-end-all-call').style.display = 'none'; + const candVid = document.getElementById('interviewer-cand-video'); + if (candVid) candVid.srcObject = null; - document.getElementById('btn-toggle-interviewer-mic').style.display = 'none'; - document.getElementById('btn-toggle-interviewer-cam').style.display = 'none'; + const selfVid = document.getElementById('interviewer-self-video'); + if (selfVid) selfVid.srcObject = null; - document.getElementById('interviewer-call-status').innerText = 'LEFT CALL'; - document.getElementById('interviewer-call-status').style.background = 'rgba(234,179,8,0.2)'; - document.getElementById('interviewer-call-status').style.color = '#fde047'; + const startBtn = document.getElementById('btn-start-call'); + if (startBtn) { + startBtn.innerText = '📞 Call Candidate'; + startBtn.style.display = 'inline-block'; + startBtn.style.background = 'linear-gradient(135deg, #10b981 0%, #059669 100%)'; + startBtn.onclick = startInterviewerCall; + } + + const leaveBtn = document.getElementById('btn-leave-call'); + if (leaveBtn) leaveBtn.style.display = 'none'; + + const endAllBtn = document.getElementById('btn-end-all-call'); + if (endAllBtn) endAllBtn.style.display = 'none'; + + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + if (micBtn) micBtn.style.display = 'none'; + + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + if (camBtn) camBtn.style.display = 'none'; + + const statusBadge = document.getElementById('interviewer-call-status'); + if (statusBadge) { + statusBadge.innerText = 'CALL ENDED'; + statusBadge.style.background = 'rgba(239,68,68,0.2)'; + statusBadge.style.color = '#fca5a5'; + } } function endCallForAll() { + const executeEndCall = () => { + if (typeof callRingtone !== 'undefined' && callRingtone) { + try { callRingtone.stop(); } catch(e) {} + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + activeIncomingCall = null; + + iAmTheCaller = false; + leaveInterviewerCall(true); + }; + if (typeof Swal !== 'undefined') { Swal.fire({ title: '🛑 End Call for All Participants?', - text: 'This will terminate the 2-way call session for the candidate and all panelists.', + text: 'This will terminate the 2-way call session for candidate and all panelists.', icon: 'warning', showCancelButton: true, confirmButtonColor: '#ef4444', @@ -1820,36 +1920,13 @@ function endCallForAll() { confirmButtonText: 'Yes, End Call for All' }).then((result) => { if (result.isConfirmed) { - if (currentInterviewId) { - const endingId = currentInterviewId; - endedCallIds.add(endingId); - dismissedCallIds.add(endingId); - callRingtone.stop(); - document.getElementById('incoming-call-modal').classList.remove('active'); - activeIncomingCall = null; - - globalCallChannel.postMessage({ - type: 'call_ended', - interview_id: endingId - }); - - fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': '{{ csrf_token() }}' - }, - body: JSON.stringify({ call_status: 'ended' }) - }); - } - - iAmTheCaller = false; - leaveInterviewerCall(); - document.getElementById('interviewer-call-status').innerText = 'CALL ENDED FOR ALL'; - document.getElementById('interviewer-call-status').style.background = 'rgba(239,68,68,0.2)'; - document.getElementById('interviewer-call-status').style.color = '#fca5a5'; + executeEndCall(); } }); + } else { + if (confirm('End Call for All Participants?')) { + executeEndCall(); + } } } @@ -1867,42 +1944,53 @@ function startCallRecording() { let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; let streamToRecord = new MediaStream(); - // Add Candidate Video and Audio tracks + // Add live Candidate Video and Audio tracks if (candidateStream) { - candidateStream.getTracks().forEach(track => streamToRecord.addTrack(track)); + candidateStream.getTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); } // Add Interviewer Local Microphone Audio track for 2-way call recording if (interviewerLocalStream) { interviewerLocalStream.getAudioTracks().forEach(track => { - // Only add if not already in streamToRecord - if (!streamToRecord.getAudioTracks().includes(track)) { - streamToRecord.addTrack(track); + if (track.readyState === 'live' && !streamToRecord.getAudioTracks().includes(track)) { + try { streamToRecord.addTrack(track); } catch(e) {} } }); - if (!candidateStream) { - interviewerLocalStream.getVideoTracks().forEach(track => streamToRecord.addTrack(track)); + if (streamToRecord.getVideoTracks().length === 0) { + interviewerLocalStream.getVideoTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); } } - if (streamToRecord.getTracks().length === 0) { - alert('No active video/audio stream available to record. Please click "Start / Join Call" first.'); + const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live'); + if (activeTracks.length === 0) { + alert('No active video/audio stream available to record. Please start or join the call first.'); return; } adminRecordedChunks = []; try { - let mime = 'video/webm'; - if (MediaRecorder.isTypeSupported('video/mp4')) { - mime = 'video/mp4'; - } else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) { - mime = 'video/webm;codecs=vp9'; - } else if (MediaRecorder.isTypeSupported('video/webm')) { - mime = 'video/webm'; + let options = {}; + if (typeof MediaRecorder.isTypeSupported === 'function') { + if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) { + options = { mimeType: 'video/webm;codecs=vp8,opus' }; + } else if (MediaRecorder.isTypeSupported('video/webm')) { + options = { mimeType: 'video/webm' }; + } } - const options = { mimeType: mime }; - adminMediaRecorder = new MediaRecorder(streamToRecord, options); + try { + adminMediaRecorder = new MediaRecorder(streamToRecord, options); + } catch(errOpt) { + adminMediaRecorder = new MediaRecorder(streamToRecord); + } adminMediaRecorder.ondataavailable = function(e) { if (e.data && e.data.size > 0) { @@ -1912,7 +2000,7 @@ function startCallRecording() { adminMediaRecorder.onstop = function() { if (adminRecordedChunks.length === 0) return; - const recMime = adminMediaRecorder.mimeType || mime; + const recMime = adminMediaRecorder.mimeType || 'video/webm'; const isMp4 = recMime.includes('mp4'); const ext = isMp4 ? 'mp4' : 'webm'; const blobType = isMp4 ? 'video/mp4' : 'video/webm'; @@ -1938,6 +2026,7 @@ function startCallRecording() { }); } } catch(e) { + console.error('MediaRecorder error:', e); alert('Recording failed to initialize: ' + e.message); } } @@ -2026,35 +2115,14 @@ function toggleInterviewerCam() { } function endInterviewerCall() { - if (interviewerLocalStream) { - interviewerLocalStream.getTracks().forEach(t => t.stop()); - interviewerLocalStream = null; - } - if (activeCall) { - activeCall.close(); - activeCall = null; - } - - document.getElementById('cand-video-placeholder').style.display = 'flex'; - document.getElementById('screen-video-placeholder').style.display = 'flex'; - document.getElementById('self-video-placeholder').style.display = 'flex'; - - document.getElementById('btn-start-call').innerText = '📞 Start 2-Way Call'; - document.getElementById('btn-start-call').style.background = '#10b981'; - document.getElementById('btn-start-call').onclick = startInterviewerCall; - - document.getElementById('btn-toggle-interviewer-mic').style.display = 'none'; - document.getElementById('btn-toggle-interviewer-cam').style.display = 'none'; - - document.getElementById('interviewer-call-status').innerText = 'DISCONNECTED'; - document.getElementById('interviewer-call-status').style.background = 'rgba(99,102,241,0.2)'; - document.getElementById('interviewer-call-status').style.color = '#818cf8'; + leaveInterviewerCall(true); } function closeReviewModal() { clearInterval(sosPollInterval); - endInterviewerCall(); - document.getElementById('review-modal').classList.remove('active'); + leaveInterviewerCall(true); + const modal = document.getElementById('review-modal'); + if (modal) modal.classList.remove('active'); } function sendSilentWarning() { diff --git a/routes/web.php b/routes/web.php index e223c02..5ff7c66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -97,6 +97,7 @@ Route::delete('/roles/{id}', [AdminController::class, 'destroyRole'])->name('admin.roles.destroy'); Route::post('/settings/default-role', [AdminController::class, 'updateDefaultRole'])->name('admin.settings.default-role'); Route::post('/settings/onboarding-toggle', [AdminController::class, 'updateOnboardingToggle'])->name('admin.settings.onboarding-toggle'); + Route::post('/settings/screenshot-toggle', [AdminController::class, 'updateScreenshotToggle'])->name('admin.settings.screenshot-toggle'); // User Roles & Overrides Route::post('/users/{id}/roles', [AdminController::class, 'updateUserRoles'])->name('admin.users.roles.update');