diff --git a/src/background/index.ts b/src/background/index.ts index d277dcb..95c8023 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -16,6 +16,16 @@ 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 // Для архивации получаем его оттуда @@ -54,6 +64,61 @@ async function ensureOffscreen() { } } +// 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). +}); + +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); + const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? []; + resolve({ chunks }); + } + }; + port.onMessage.addListener(handler); + port.postMessage({ type: 'REQUEST_BUFFER' }); + }); +} + // Начало записи видео async function startVideoCapture() { if (isRecording) { @@ -73,6 +138,11 @@ async function startVideoCapture() { // Создаём 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 юзаства). @@ -101,11 +171,8 @@ async function startVideoCapture() { // Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle // timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение. -// Получение видеобуфера (временный синхронный стаб; Task 2 заменит его -// на асинхронный запрос к offscreen через long-lived port). -function getVideoBuffer(): VideoBufferResponse { - return { chunks: [] }; -} +// Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен +// выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана. // Получение скриншота активной вкладки async function captureScreenshot(): Promise { @@ -264,9 +331,9 @@ async function saveArchive() { logger.log('Capturing screenshot...'); const screenshot = await captureScreenshot(); - // Получаем видео буфер - const videoBuffer = getVideoBuffer(); - logger.log(`Video buffer: ${videoBuffer.chunks.length} chunks`); + // Получаем видео буфер из 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})...`); @@ -303,7 +370,7 @@ async function saveArchive() { // Создаем архив const archiveBlob = await createArchive( - videoBuffer, + videoBufferResp, rrwebEvents, userEvents, screenshot @@ -327,7 +394,14 @@ async function saveArchive() { // REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true. // Обработка сообщений -chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => { +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) { @@ -349,8 +423,8 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) = return true; case 'GET_VIDEO_BUFFER': - sendResponse(getVideoBuffer()); - return false; + getVideoBufferFromOffscreen().then((resp) => sendResponse(resp)); + return true; case 'SAVE_ARCHIVE': saveArchive().then(result => { @@ -358,6 +432,12 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) = }); 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); @@ -373,14 +453,38 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) = }); // Инициализация -function initialize() { +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(); });