import { Logger } from '../shared/logger'; import { base64ToBlob } from '../shared/binary'; import type { Message, TransferredVideoChunk, VideoChunk, SessionMetadata, VideoBufferResponse } from '../shared/types'; import JSZip from 'jszip'; // Default MIME applied when a wire chunk somehow lacks a type // field (defense-in-depth: in normal operation the offscreen recorder // always populates it from chunk.data.type). Matches D-20 strict codec. const VIDEO_MIME_FALLBACK = 'video/webm;codecs=vp9'; const logger = new Logger('Main'); // Состояние // Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально: // при экспорте он спрашивает буфер у offscreen через long-lived port (D-17). let isRecording = false; let offscreenCreated = false; let lastScreenshotTime = 0; let cachedScreenshot: Blob | null = null; // Port from offscreen (D-17). Re-assigned on every (re)connect. let videoPort: chrome.runtime.Port | null = null; // Offscreen readiness Promise — set up at module load, resolved on first // OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before // sending START_RECORDING, so we never lose the popup's transient activation // to a race with the offscreen bootstrap. let offscreenReadyResolve: (() => void) | null = null; let offscreenReady: Promise = new Promise((res) => { offscreenReadyResolve = res; }); // userEvents хранится только в content script // Для архивации получаем его оттуда // Ring-buffer helpers (header-pin + age-trim) and the buffer duration // constant were removed in Plan 01-03 — the buffer now lives in // src/offscreen/recorder.ts per D-16. Plan 05 completes the SW shrink: // see deletions below. // Создание offscreen документа async function ensureOffscreen() { if (offscreenCreated) { logger.log('Offscreen already created'); return; } try { const url = chrome.runtime.getURL('src/offscreen/index.html'); logger.log('Creating offscreen document at:', url); await chrome.offscreen.createDocument({ url: url, reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA], justification: 'Continuous screen recording for operator session diagnostics' }); offscreenCreated = true; logger.log('Offscreen document created successfully'); } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes('already exists')) { offscreenCreated = true; logger.log('Offscreen document already exists'); } else { logger.error('Failed to create offscreen document:', error); throw error; } } } // SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this // port on bootstrap and reconnects on disconnect. We use it for: (a) // keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every // port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE. chrome.runtime.onConnect.addListener((port) => { // T-1-04 mitigation: only accept ports from this extension if (port.name !== 'video-keepalive') { return; } if (port.sender?.id !== chrome.runtime.id) { logger.warn('Rejecting port with mismatched sender:', port.sender?.id); port.disconnect(); return; } logger.log('Offscreen port connected'); videoPort = port; port.onDisconnect.addListener(() => { logger.log('Offscreen port disconnected; offscreen will reconnect'); videoPort = null; }); // Inbound traffic is mostly PING (ignored) and BUFFER (handled by the // per-request listener installed in getVideoBufferFromOffscreen). }); // 2 s budget covers the worst-case round-trip: offscreen base64-encodes // up to ~15 chunks of ~100 KB each (~1.5 MB raw → ~2 MB base64) in // well under 100 ms, post-message + JSON parse adds < 50 ms, leaving // plenty of headroom. Bumping later is cheap if real-world recordings // produce significantly larger buffers; today this is sufficient. const BUFFER_FETCH_TIMEOUT_MS = 2_000; async function getVideoBufferFromOffscreen(): Promise { if (videoPort === null) { logger.warn('No offscreen port available; returning empty buffer'); return { chunks: [] }; } const port = videoPort; return new Promise((resolve) => { const timer = setTimeout(() => { port.onMessage.removeListener(handler); logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`); resolve({ chunks: [] }); }, BUFFER_FETCH_TIMEOUT_MS); const handler = (msg: unknown) => { if ( typeof msg === 'object' && msg !== null && (msg as { type?: unknown }).type === 'BUFFER' ) { clearTimeout(timer); port.onMessage.removeListener(handler); // D-12 fix: chunks arrive as TransferredVideoChunk[] (base64 // string + MIME). Decode each back into a VideoChunk so // mergeVideoChunks keeps operating on real Blobs. See // src/shared/binary.ts and the GREEN block of // tests/offscreen/port-serialization.test.ts. const wireChunks = (msg as { chunks?: TransferredVideoChunk[] }).chunks ?? []; const chunks: VideoChunk[] = []; for (const wire of wireChunks) { try { chunks.push({ data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK), timestamp: wire.timestamp, isFirst: wire.isFirst, }); } catch (err) { logger.warn( 'base64ToBlob failed; skipping chunk', 'timestamp:', wire.timestamp, 'isFirst:', wire.isFirst, 'error:', err, ); } } resolve({ chunks }); } }; port.onMessage.addListener(handler); port.postMessage({ type: 'REQUEST_BUFFER' }); }); } // Начало записи видео async function startVideoCapture() { if (isRecording) { logger.log('Video recording already active'); return; } try { // Получаем активную вкладку const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.id || !tab.url) { throw new Error('No active tab found'); } logger.log(`Starting video capture for tab ${tab.id}: ${tab.url}`); // Создаём offscreen документ (с reason из D-02) await ensureOffscreen(); // Ждём, пока offscreen зарегистрирует свой onMessage listener // (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше, // чем offscreen готов его принять, и Chrome бросает "Receiving end // does not exist". await offscreenReady; // Просим offscreen запустить запись — getDisplayMedia вызывается там // (D-01: больше нет SW-side stream-id юзаства). logger.log('Sending START_RECORDING to offscreen...'); try { await chrome.runtime.sendMessage({ type: 'START_RECORDING' }); logger.log('START_RECORDING sent successfully'); } catch (msgError) { logger.error('Failed to send START_RECORDING:', msgError); throw msgError; } isRecording = true; logger.log('Video recording started successfully'); } catch (error) { logger.error('Failed to start video capture:', error); isRecording = false; throw error; } } // Keepalive теперь обеспечивается long-lived портом offscreen→SW (D-17/D-18). // Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle // timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение. // Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен // выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана. // Получение скриншота активной вкладки async function captureScreenshot(): Promise { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.id) { throw new Error('No active tab'); } // Проверяем кэш и лимит частоты (максимум 1 скриншот в 2 секунды) const now = Date.now(); if (cachedScreenshot && (now - lastScreenshotTime) < 2000) { logger.log('Using cached screenshot'); return cachedScreenshot; } // Делаем новый скриншот const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); // Конвертируем data URL в Blob const response = await fetch(dataUrl); cachedScreenshot = await response.blob(); lastScreenshotTime = now; return cachedScreenshot; } // Склейка видео чанков function mergeVideoChunks(chunks: VideoChunk[]): Blob { logger.log(`Merging ${chunks.length} chunks`); // Сортируем по времени, чтобы сохранить правильный порядок const sortedChunks = [...chunks].sort((a, b) => a.timestamp - b.timestamp); logger.log(`Chunks sorted, first timestamp: ${sortedChunks[0]?.timestamp}, last: ${sortedChunks[sortedChunks.length - 1]?.timestamp}`); // Конвертируем в массив Blob const blobs: Blob[] = sortedChunks.map((chunk, index) => { logger.log(`Adding chunk ${index}, size: ${chunk.data.size} bytes, isFirst: ${chunk.isFirst}`); return chunk.data; }); const finalBlob = new Blob(blobs, { type: 'video/webm' }); logger.log(`Final video blob size: ${finalBlob.size} bytes, total chunks merged: ${blobs.length}`); return finalBlob; } // Создание архива async function createArchive( videoBufferResponse: VideoBufferResponse, rrwebEvents: unknown[], userEvents: unknown[], screenshot: Blob ): Promise { logger.log('=== Creating archive ==='); logger.log(`Events to include: ${rrwebEvents.length} rrweb, ${userEvents.length} user`); const zip = new JSZip(); // Добавляем видео if (videoBufferResponse.chunks.length > 0) { const videoBlob = mergeVideoChunks(videoBufferResponse.chunks); zip.file('video/last_30sec.webm', videoBlob); logger.log(`✓ Added video: ${videoBlob.size} bytes`); } else { logger.warn('✗ No video chunks to add'); } // Добавляем rrweb события const rrwebJson = JSON.stringify(rrwebEvents, null, 2); zip.file('rrweb/session.json', rrwebJson); if (rrwebEvents.length > 0) { logger.log(`✓ Added rrweb events: ${rrwebEvents.length} events, ${rrwebJson.length} bytes`); } else { logger.warn('✗ rrweb session.json created but EMPTY (content script may not be running)'); } // Добавляем пользовательские события const eventsJson = JSON.stringify(userEvents, null, 2); zip.file('logs/events.json', eventsJson); if (userEvents.length > 0) { logger.log(`✓ Added user events: ${userEvents.length} events, ${eventsJson.length} bytes`); } else { logger.warn('✗ logs/events.json created but EMPTY (no user interactions detected)'); } // Добавляем скриншот zip.file('screenshot.png', screenshot); logger.log('✓ Added screenshot'); // Добавляем метаданные const metadata: SessionMetadata = { timestamp: new Date().toISOString(), url: new URL(chrome.runtime.getURL('')).origin, userAgent: navigator.userAgent, extensionVersion: '1.0.0', videoBufferSeconds: 30, logDurationMinutes: 10, totalEvents: rrwebEvents.length + userEvents.length }; zip.file('meta.json', JSON.stringify(metadata, null, 2)); logger.log('✓ Added metadata'); // Генерируем архив const archiveBlob = await zip.generateAsync({ type: 'blob' }); logger.log(`✓ Archive created: ${archiveBlob.size} bytes`); return archiveBlob; } // Скачивание архива async function downloadArchive(archiveBlob: Blob) { const now = new Date(); const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0]; const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-'); const filename = `session_report_${dateStr}_${timeStr}.zip`; logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`); // Конвертируем Blob в Data URL (работает в Service Worker) const arrayBuffer = await archiveBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); let binary = ''; for (let i = 0; i < uint8Array.length; i++) { binary += String.fromCharCode(uint8Array[i]); } const base64 = btoa(binary); const url = `data:application/zip;base64,${base64}`; await chrome.downloads.download({ url: url, filename: filename, saveAs: false }); logger.log('Archive download started'); } // Сохранение архива (полный процесс) async function saveArchive() { try { logger.log('Starting archive save process'); // Получаем текущую активную вкладку const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.id) { throw new Error('No active tab found'); } logger.log(`Using tab ${tab.id} for archive, url: ${tab.url}`); // Получаем скриншот logger.log('Capturing screenshot...'); const screenshot = await captureScreenshot(); // Получаем видео буфер из offscreen через long-lived port (D-17) const videoBufferResp = await getVideoBufferFromOffscreen(); logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`); // Получаем rrweb события от content script logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`); let rrwebEvents: unknown[] = []; let userEvents: unknown[] = []; try { logger.log(`Sending GET_RRWEB_EVENTS message to tab ${tab.id}...`); const response: { events?: unknown[]; userEvents?: unknown[] } | undefined = await chrome.tabs.sendMessage(tab.id, { type: 'GET_RRWEB_EVENTS' }); logger.log(`Got response from tab ${tab.id}:`, response); rrwebEvents = response?.events ?? []; userEvents = response?.userEvents ?? []; logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`); // Логируем первые несколько событий для отладки if (rrwebEvents.length > 0) { logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...'); } if (userEvents.length > 0) { logger.log('First user event:', userEvents[0]); } } catch (messageError) { logger.warn('✗ Failed to get events from content script:', messageError); logger.warn('This may happen on special pages (chrome://, about:blank) or if content script is not injected'); logger.warn('Check if content script is running on the active tab (open DevTools Console on the page)'); // Продолжаем без событий, если контент-скрипт недоступен } // Создаем архив const archiveBlob = await createArchive( videoBufferResp, rrwebEvents, userEvents, screenshot ); // Скачиваем await downloadArchive(archiveBlob); logger.log('Archive save completed'); return { success: true }; } catch (error) { logger.error('Failed to save archive:', error); return { success: false, error }; } } // checkPermissions / requestPermissions удалены: старая permission // больше не нужна (D-A6 — заменена на desktopCapture в manifest), а // getDisplayMedia не требует runtime-разрешения — нужен только user gesture. // REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true. // Обработка сообщений chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => { // T-1-NEW-05-01 mitigation: only accept onMessage traffic from this // extension (popup, content script, offscreen). External callers (other // extensions, web pages) are silently dropped. if (sender.id !== chrome.runtime.id) { logger.warn('Rejecting message with mismatched sender:', sender.id); return false; } logger.log('Received message:', message.type, message); switch (message.type) { case 'REQUEST_PERMISSIONS': // Под getDisplayMedia (D-01) runtime-permission проверять нечего — // браузер сам покажет picker по user gesture из popup. Просто // запускаем запись и подтверждаем popup-у. (async () => { try { if (!isRecording) { await startVideoCapture(); } sendResponse({ granted: true }); } catch (error) { logger.error('startVideoCapture failed:', error); sendResponse({ granted: false }); } })(); return true; case 'GET_VIDEO_BUFFER': getVideoBufferFromOffscreen().then((resp) => sendResponse(resp)); return true; case 'SAVE_ARCHIVE': saveArchive().then(result => { sendResponse(result); }); return true; case 'OFFSCREEN_READY': logger.log('OFFSCREEN_READY received'); offscreenReadyResolve?.(); offscreenReadyResolve = null; return false; // Legacy chunk-streaming and IndexedDB save/load handlers were removed // in Plan 01-03: // - the offscreen recorder now owns the buffer (D-16); // - chunks no longer travel via chrome.runtime.sendMessage (D-19); // - SW-side IDB plumbing was the audit P0 #2 broken path. // The IDB helpers were only reachable from those deleted cases. // Plan 05 finishes the SW shrink (see deletions above). default: logger.warn('Unknown message type:', message.type); return false; } }); // Инициализация async function initialize() { logger.log('Service Worker initializing'); // Audit P1 #8: after the SW respawns (e.g. after Chrome wakes it from idle), // the offscreen document may still exist while our offscreenCreated flag // resets to false. Ask Chrome the ground truth so we don't end up trying // to createDocument over an existing one. Cheap and idempotent. try { if (typeof chrome.offscreen?.hasDocument === 'function') { const exists = await chrome.offscreen.hasDocument(); if (exists) { offscreenCreated = true; logger.log('Existing offscreen document detected on SW init'); } } } catch (err) { logger.warn('chrome.offscreen.hasDocument check failed:', err); } logger.log('Service Worker initialized'); } // Запуск при установке chrome.runtime.onInstalled.addListener((details) => { logger.log('Extension installed/updated:', details.reason); // RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from // pre-Phase-01 builds. Idempotent: no-op if DB never existed. // T-1-NEW-05-02 mitigation. try { indexedDB.deleteDatabase('VideoRecorderDB'); logger.log('Cleaned up orphaned VideoRecorderDB (if present)'); } catch (e) { logger.warn('IDB cleanup failed:', e); } initialize(); }); // Запуск при старте Service Worker initialize();