import { Logger } from '../shared/logger'; import { base64ToBlob, blobToBase64 } from '../shared/binary'; import type { Message, TransferredVideoSegment, VideoSegment, SessionMetadata, VideoBufferResponse } from '../shared/types'; import { remuxSegments } from './webm-remux'; import JSZip from 'jszip'; // ─── Plan 01-13: NO SW-side test hook gate (Approach B) ────────────── // Plan 01-11 originally planned a gated `await import('../test-hooks/sw-hooks')` // here to instrument chrome.* handler registrations from the SW side. // EMPIRICAL FALSIFICATION (01-11 spike, see 01-11-SUMMARY.md): MV3 // service workers BLOCK dynamic import (Chromium es_modules.md: // "Dynamic import is currently blocked in Service Workers, but it // will change in the future."; w3c/webextensions#212 still open as of // May 2026). The `await import(...)` never resolved → the SW silently // died at top-of-module init → production chrome.* listeners never // registered → the entire service worker was non-functional. // // Plan 01-13 (Approach B) DROPS the SW-side hook entirely: // - The OFFSCREEN-side test hook (src/test-hooks/offscreen-hooks.ts) // still works because offscreen IS a DOM document where dynamic // import is supported. See src/offscreen/recorder.ts top-of-module // for the surviving `if (__MOKOSH_UAT__) { await import(...) }`. // - SW-side state (badge text, popup, isRecording proxy) is queried // by the extension-internal harness page via chrome.action.* // directly (the page has full chrome.* privilege, unlike the // restricted CDP `sw.evaluate` surface that 01-11 tried first). // - Architectural rationale + full falsification table: // .planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md // // SECURITY INVARIANT (T-1-11-01, retained): production bundle MUST // contain NO test-hook surface strings. The Tier-1 grep gate // `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces // post-build. In Approach B this invariant is trivially satisfied for // the SW chunk (no hook imports here at all); the gate's enduring // value is catching regressions in the offscreen chunk's // `__MOKOSH_UAT__` gate. // 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'); // Option C: a typed error so saveArchive can distinguish the empty-video // failure (operator-facing — shows a clear RECORDING_ERROR popup) from // generic createArchive failures (zip/manifest/etc.). The 'empty-video- // buffer' code joins the CaptureErrorCode union surfaced by the // offscreen recorder (see src/offscreen/recorder.ts: classifyCaptureError) // — same operator-facing vocabulary, different production point. export class EmptyVideoBufferError extends Error { readonly code = 'empty-video-buffer' as const; constructor(detail: string) { super(`empty-video-buffer: ${detail}`); this.name = 'EmptyVideoBufferError'; } } // Состояние // Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально: // при экспорте он спрашивает буфер у offscreen через long-lived port (D-17). let isRecording = false; let offscreenCreated = false; let lastScreenshotTime = 0; let cachedScreenshot: Blob | null = null; // ─── Plan 01-09 badge palette + notification constants ─────────────── // Project naming standard: SCREAMING_SNAKE for true constants. These // drive the operator-facing badge state machine + notification flow. // // Plan 01-12 Wave 4: BADGE_REC_COLOR updated from #00C853 (material // green) to #b2543d (= --mks-madder-600 per D-04 loom palette per // RESEARCH §10 Open Question A7). OFF + ERROR colors retained as // engineering choices (no loom-palette token for material-red / // material-amber-700 equivalents); document choice inline. // // Plan 01-12 Wave 4: BADGE_*_TITLE constants kept as FALLBACKS for the // chrome.i18n.getMessage reads at the setBadgeState call sites. Unit // tests that don't stub chrome.i18n degrade to these literals rather // than empty strings. const BADGE_REC_COLOR = '#b2543d'; // --mks-madder-600 per D-04 loom palette (Plan 01-12) const BADGE_OFF_COLOR = '#D32F2F'; // material red (no loom-palette token for OFF) const BADGE_ERROR_COLOR = '#F9A825'; // material amber-700 (no loom-palette token for ERR) const BADGE_REC_TEXT = 'REC'; const BADGE_OFF_TEXT = ''; const BADGE_ERROR_TEXT = 'ERR'; const BADGE_REC_TITLE_FALLBACK = 'Recording — last 30 s buffered. Click to save.'; const BADGE_OFF_TITLE_FALLBACK = 'Not recording. Click to start.'; const BADGE_ERROR_TITLE_FALLBACK = 'Recording error. Click to try again.'; const NOTIFICATION_ICON_PATH = 'icons/icon128.png'; const NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-'; const NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-'; const POPUP_HTML_PATH = 'src/popup/index.html'; // ─── Plan 01-10 onboarding constants (D-17-onboarding) ─────────────── // Project naming standard: SCREAMING_SNAKE for true constants. These // drive the first-install welcome-tab flow (chrome.runtime.onInstalled // → openWelcomeIfFirstInstall; flag persisted via chrome.storage.local). // The 'onboarding-completed' key is the storage-schema identifier // preserved across SW respawns (and surveyed by the unit test in // tests/background/onboarding.test.ts Test A). const ONBOARDING_FLAG = 'onboarding-completed'; const ONBOARDING_INSTALLED_AT = 'installed-at'; const WELCOME_PATH = 'src/welcome/welcome.html'; // Plan 01-12 Wave 4: operator-facing copy fallbacks for the notification // title (extName) + the two notification messages. Same `|| fallback` // pattern as the popup — unit-test contexts without chrome.i18n stub // degrade to these literals. const NOTIF_EXTNAME_FALLBACK = 'Mokosh'; // Plan 01-09 amendment 2026-05-20: `notifStartup` key was split into // `notifStartupCta` (used here by the onStartup handler — CTA-with-gesture // pre-recording invite) and `notifRecordingStarted` (reserved for future // post-manual-start confirmation flows). The original text falsely implied // recording had auto-started; the new text invites the operator to start // a recording. See .planning/debug/resolved/01-09-startup-notification-misleading-text.md. const NOTIF_STARTUP_CTA_FALLBACK = 'Mokosh ready. Click to start a recording.'; const NOTIF_RECOVERY_FALLBACK = 'Recording stopped. Click here to start a new session.'; /** * Safe wrapper around chrome.i18n.getMessage with explicit fallback. * Returns the fallback when chrome.i18n is undefined (unit-test contexts * without a stub) OR when the key is missing in the resolved locale. * * chrome.i18n.getMessage is SYNCHRONOUS per Chrome docs — no Promise. * Returns '' for missing keys, hence the explicit length check. */ function i18nMessage(key: string, fallback: string): string { const text = chrome?.i18n?.getMessage?.(key) ?? ''; return text.length > 0 ? text : fallback; } // ─── Plan 01-09 badge state machine + mode helpers ─────────────────── // 3-state machine: REC (during recording), OFF (idle), ERROR (after // RECORDING_ERROR). Each setBadgeState call is best-effort: chrome.action // methods may be undefined in unit-test contexts that did not stub the // whole surface — wrap each call in try/catch (defense in depth). type BadgeState = 'REC' | 'OFF' | 'ERROR'; function setBadgeState(state: BadgeState): void { let text: string; let color: string; let title: string; if (state === 'REC') { text = BADGE_REC_TEXT; color = BADGE_REC_COLOR; title = i18nMessage('tooltipRecPrefix', BADGE_REC_TITLE_FALLBACK); } else if (state === 'OFF') { text = BADGE_OFF_TEXT; color = BADGE_OFF_COLOR; title = i18nMessage('tooltipOff', BADGE_OFF_TITLE_FALLBACK); } else { text = BADGE_ERROR_TEXT; color = BADGE_ERROR_COLOR; title = i18nMessage('tooltipErr', BADGE_ERROR_TITLE_FALLBACK); } try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); } try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); } try { chrome.action.setTitle({ title }); } catch (e) { logger.warn('setTitle failed:', e); } } // In idle mode the popup is empty ('' string) — this is what makes // chrome.action.onClicked fire on toolbar clicks (per the MV3 docs: // the click event only fires when no default_popup is set). In REC/ // ERROR modes the popup html is set back so toolbar clicks open the // SAVE-only popup. function setIdleMode(): void { try { chrome.action.setPopup({ popup: '' }); } catch (e) { logger.warn('setPopup OFF failed:', e); } setBadgeState('OFF'); } function setRecordingMode(): void { try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup REC failed:', e); } setBadgeState('REC'); } function setErrorMode(): void { // ERROR mode keeps the popup accessible so the operator can still // attempt SAVE if any data is buffered + see the error copy. try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup ERROR failed:', e); } setBadgeState('ERROR'); } // 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; } } } /** * Open the welcome page on first install (Plan 01-10 D-17-onboarding). * * The 'D-17-onboarding' suffix disambiguates from D-17-port-lifecycle * per CONTEXT.md lines 540-545. Trigger conditions (all must hold): * - details.reason === 'install' (NOT 'update' / 'chrome_update' / * 'shared_module_update'); * - chrome.storage.local key 'onboarding-completed' NOT === true. * * Side effects on first install: * - chrome.tabs.create({url: chrome.runtime.getURL('src/welcome/welcome.html')}) * - chrome.storage.local.set({'onboarding-completed': true, * 'installed-at': Date.now()}) * * Failure mode: any thrown chrome.* call is caught + logged via * logger.warn. The welcome tab failing is NOT fatal — the toolbar * onClicked path (D-16-toolbar) remains the operator's start path and * is independent of the onboarding flow. * * Architectural note: the fetch of 'onboarding-completed' uses the * EXACT string key (no array form) so the unit-test contract in * tests/background/onboarding.test.ts Test A's assertion * "saw chrome.storage.local.get('onboarding-completed')" holds. The * storage-schema cross-version-compat pin (I-02 lesson preserved from * prior plan draft) lives in that test. */ async function openWelcomeIfFirstInstall( details: chrome.runtime.InstalledDetails, ): Promise { if (details.reason !== 'install') { return; } try { const stored = await chrome.storage.local.get(ONBOARDING_FLAG); if (stored[ONBOARDING_FLAG] === true) { logger.log('Onboarding already completed; skipping welcome tab.'); return; } const url = chrome.runtime.getURL(WELCOME_PATH); await chrome.tabs.create({ url }); await chrome.storage.local.set({ [ONBOARDING_FLAG]: true, [ONBOARDING_INSTALLED_AT]: Date.now(), }); logger.log( 'Welcome tab opened (D-17-onboarding); onboarding flag set.', ); } catch (err) { logger.warn('openWelcomeIfFirstInstall failed:', err); } } // Outer-bound buffer fetch budget. Larger than the legacy // BUFFER_FETCH_TIMEOUT_MS (was 2 s; per-port-attempt) because the new // architecture covers MULTIPLE port-replacement retries inside one outer // budget. 10 s is generous: the inner per-port encode round-trip is // still ~100-200 ms; the extra headroom covers up to ~50 reconnect // cycles before the operator-visible error surfaces. const BUFFER_FETCH_TIMEOUT_MS = 10_000; // ─── D-P2-01 Blob URL mint/revoke lifecycle state (P0-6 fix) ────────── // pendingDownloadUrlResolvers maps a per-mint requestId to the resolver // of the in-flight downloadArchive's Promise. Mirrors the pendingBuffer // Requests pattern: the onConnect-level port message sink routes the // DOWNLOAD_URL response by id so port replacement mid-mint does not // drop the response. const pendingDownloadUrlResolvers: Map void> = new Map(); // pendingRevokes maps a chrome.downloads downloadId to the minted blob:URL // awaiting revocation. Populated when chrome.downloads.download resolves // with its downloadId; drained by the chrome.downloads.onChanged listener // when the corresponding state transitions to 'complete' or 'interrupted'. // The Map is bounded by O(saves-per-session) which is operationally <100 // (T-02-02-02 threat-register entry); SW idle teardown clears it entirely. const pendingRevokes: Map = new Map(); // Outer-bound budget for the offscreen mint round-trip. The bridge is // purely local (no network), so 5 s is generous — the inner encode + // post round-trip is typically <100 ms for archives <10 MB. const BLOB_URL_MINT_TIMEOUT_MS = 5_000; // Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The // onConnect-level message sink routes BUFFER -> resolve by id, so port // replacement (videoPort changes mid-request) does NOT lose the // response — the offscreen posts BUFFER on the CURRENT port (whichever // that is) and our sink picks it up regardless of which Port object it // arrives on. interface PendingBufferRequest { resolve: (resp: VideoBufferResponse) => void; hardTimer: ReturnType; requestId: string; } const pendingBufferRequests: Map = new Map(); // Generate a per-request correlation id. Uses crypto.randomUUID when // available (Chrome 92+ in SW context per // https://developer.chrome.com/docs/extensions/reference/api/runtime#secure_origin), // with a Math.random fallback that's still unique enough for in-process // routing — collisions would require simultaneous in-flight requests // within the same millisecond on the same SW lifetime, vanishingly // improbable for this UI flow. function generateRequestId(): string { if ( typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ) { return crypto.randomUUID(); } return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; } // Decodes a BUFFER message's wire-format segments into VideoSegment[]. // Extracted from the legacy inline handler so the onConnect sink can // resolve a pending request without duplicating the decode logic. function decodeBufferSegments( wireSegments: TransferredVideoSegment[], ): VideoSegment[] { // WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty // wire.data would decode to a zero-byte Blob; the remux pipeline // (src/background/webm-remux.ts, Plan 01-08 D-14-remux) would try // to parse it via ts-ebml and either fail loudly or emit zero frames, // either way wasting a parse cycle. Two passes (filter -> decode -> // filter-non-empty) keep the iteration semantics declarative. const nonEmptyWires = wireSegments.filter((wire) => { const isEmpty = !wire.data || wire.data.length === 0; if (isEmpty) { logger.warn( 'Skipping empty wire segment (zero-length base64)', 'timestamp:', wire.timestamp, ); } return !isEmpty; }); const segments: VideoSegment[] = nonEmptyWires.flatMap((wire) => { try { const blob = base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK); if (blob.size === 0) { logger.warn( 'Skipping segment that decoded to zero bytes', 'timestamp:', wire.timestamp, ); return []; } return [{ data: blob, timestamp: wire.timestamp }]; } catch (err) { logger.warn( 'base64ToBlob failed; skipping segment', 'timestamp:', wire.timestamp, 'error:', err, ); return []; } }); return segments; } // 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/PONG health probe — Option C) — Chrome 110+ // resets the SW idle timer on every port message, AND the PONG reply // closes the offscreen's health-probe loop; (b) on-demand REQUEST_BUFFER // round-trip during SAVE_ARCHIVE, routed by requestId so port // replacement mid-request does not drop the response. 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; // CR-02 fix: install a permanent onMessage sink on every accepted port. // Chrome 110+ resets the SW idle-timer on any inbound port message, BUT // in the field, behaviour has been observed to differ subtly when no // listener is attached at all — Chrome may skip the idle-timer reset // path entirely on unrouted messages. // // Option C: this sink ALSO routes BUFFER responses to the matching // pending request by requestId (the per-request listener pattern is // gone — it could not handle port replacement). And it echoes PONG on // every PING so the offscreen's health probe sees life. port.onMessage.addListener((msg) => { if (typeof msg !== 'object' || msg === null) { return; } const type = (msg as { type?: unknown }).type; if (type === 'PING') { // Health-probe echo (Option C). Wrapped in try/catch because the // port may have been disconnected between the inbound PING and // our response — silently drop in that race window. try { port.postMessage({ type: 'PONG' }); } catch (err) { logger.warn('PONG postMessage failed (port closed):', err); } return; } if (type === 'BUFFER') { const requestId = (msg as { requestId?: unknown }).requestId; if (typeof requestId !== 'string' || requestId.length === 0) { // Defense-in-depth: BUFFER without a valid requestId is invalid // under the Option C protocol — drop with a warn. (Legacy // offscreen code that didn't carry requestId is gone.) logger.warn('BUFFER without a valid requestId — dropping'); return; } const pending = pendingBufferRequests.get(requestId); if (pending === undefined) { // Stale BUFFER (request already resolved or timed out). Silently // drop — this is the no-cross-talk property the request-id // routing guarantees. return; } const wireSegments = (msg as { segments?: TransferredVideoSegment[] }).segments ?? []; const segments = decodeBufferSegments(wireSegments); clearTimeout(pending.hardTimer); pendingBufferRequests.delete(requestId); pending.resolve({ segments }); return; } if (type === 'DOWNLOAD_URL') { // D-P2-01: route the offscreen-minted blob:URL back to the // in-flight downloadArchive Promise. Mirrors the BUFFER routing // above — keyed by requestId so concurrent mints (theoretically // possible across two SAVE flows) cannot cross-talk. const requestId = (msg as { requestId?: unknown }).requestId; if (typeof requestId !== 'string' || requestId.length === 0) { logger.warn('DOWNLOAD_URL without a valid requestId — dropping'); return; } const resolver = pendingDownloadUrlResolvers.get(requestId); if (resolver === undefined) { // Stale DOWNLOAD_URL (mint already timed out). Silently drop. return; } pendingDownloadUrlResolvers.delete(requestId); const url = (msg as { url?: unknown }).url; resolver(typeof url === 'string' ? url : ''); return; } // Unknown traffic — drop silently (T-1-04 defense-in-depth). }); port.onDisconnect.addListener(() => { logger.log('Offscreen port disconnected; offscreen will reconnect'); if (videoPort === port) { videoPort = null; } }); // If there are pending REQUEST_BUFFER requests at the moment this port // connects, re-issue them on the fresh port with the SAME requestId. // This is the architectural mechanism that retires the H2 silent-drop // class — the BUFFER reaches the SW regardless of port-replacement // timing. (Note: the FIRST onConnect has pendingBufferRequests.size // === 0 so this branch correctly does nothing on bootstrap.) if (pendingBufferRequests.size > 0) { for (const pending of pendingBufferRequests.values()) { try { port.postMessage({ type: 'REQUEST_BUFFER', requestId: pending.requestId, }); } catch (err) { // The fresh port disconnected synchronously — the outer hard // timer will fire and surface the error. logger.warn('REQUEST_BUFFER retry post failed:', err); } } } }); async function getVideoBufferFromOffscreen(): Promise { if (videoPort === null) { logger.warn('No offscreen port available; returning empty buffer'); return { segments: [] }; } const requestId = generateRequestId(); return new Promise((resolve) => { const hardTimer = setTimeout(() => { pendingBufferRequests.delete(requestId); // Outer hard-timeout: covers EVERY retry across port replacements // (the legacy per-port BUFFER_FETCH_TIMEOUT_MS was 2 s per // attempt — too tight to retry across a reconnect). 10 s is // generous; the inner round-trip is still ~100-200 ms. logger.warn( `Buffer fetch outer timeout (${BUFFER_FETCH_TIMEOUT_MS} ms) — no BUFFER for requestId ${requestId}`, ); resolve({ segments: [] }); }, BUFFER_FETCH_TIMEOUT_MS); pendingBufferRequests.set(requestId, { resolve, hardTimer, requestId, }); try { videoPort?.postMessage({ type: 'REQUEST_BUFFER', requestId }); } catch (err) { // The current port disconnected synchronously. Don't resolve here // — the offscreen's reconnect will fire a fresh onConnect, the // sink will detect the in-flight request, and the retry path will // re-post REQUEST_BUFFER on the new port. logger.warn('Initial REQUEST_BUFFER post failed (port disconnected):', err); } }); } // Начало записи видео async function startVideoCapture() { if (isRecording) { logger.log('Video recording already active'); return; } try { // Plan 01-09 D-01 cleanup gap (debug session // `01-09-notification-start-no-active-tab`, 2026-05-20): // The legacy chrome.tabs.query({ active: true, currentWindow: true }) // here was load-bearing in the pre-D-01 chrome.tabCapture era but // is functionally dead post-D-01 — capture is whole-desktop via // getDisplayMedia in offscreen and the SW-side start path needs // no tab reference. The query also failed for chrome.notifications // .onClicked callers (no activeTab grant + no `tabs` permission → // tab.url undefined → "No active tab found" throw) so the onStartup // notification CTA was silently broken. captureScreenshot + // saveArchive retain their own genuine tab queries (tab.windowId // for captureVisibleTab, tab.id for content-script sendMessage). logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)'); // Создаём 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; // Plan 01-09: transition badge + popup mode to REC so the toolbar // click opens the SAVE-only popup (not re-fires onClicked). setRecordingMode(); logger.log('Video recording started successfully'); } catch (error) { logger.error('Failed to start video capture:', error); isRecording = false; // Plan 01-09: transition to ERROR state so the badge surfaces the // failure visually and the popup is reachable for any next-action UI. setErrorMode(); 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; } // mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux): // see src/background/webm-remux.ts for the single-EBML remux path. // The concat-of-self-contained-WebM-segments approach produced a // multi-EBML-header file that mpv / Chrome / ffprobe truncated to // the first segment's local Info.Duration ~9.94 s; the remux path // emits a single-EBML WebM whose Info.Duration covers the full ~30 s // timeline. D-13's recorder-side restart-segments lifecycle is // preserved — only the merge step is replaced. // Создание архива 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(); // Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент). // // Option C (debug session empty-archive-port-race): the upstream // silent-skip branch is GONE. Shipping a zip with no video defeats the // entire purpose of the operator-save flow (Phase 1 goal); the operator // must see a clear failure instead of receiving a 88 KB archive with no // last_30sec.webm. saveArchive's catch translates the throw into the // {success: false, error: 'empty-video-buffer'} response shape the // popup already handles via the RECORDING_ERROR surface. if (videoBufferResponse.segments.length === 0) { throw new EmptyVideoBufferError( 'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)', ); } // Plan 01-08 D-14-remux: replaces the retired mergeVideoSegments() // file-concat with the new single-EBML WebM remux. Async now — // ts-ebml parse + webm-muxer write happen on the SW thread. const videoBlob = await remuxSegments(videoBufferResponse.segments); if (videoBlob.size === 0) { throw new EmptyVideoBufferError( `remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`, ); } zip.file('video/last_30sec.webm', videoBlob); logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`); // Добавляем 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'); // Добавляем метаданные // IN-01 fix: read version from manifest at runtime instead of the // hardcoded '1.0.0'. Previously the metadata would silently lie about // the running version once manifest.json bumps to 1.0.1+. The Chrome // runtime API is always available in the SW context, so no fallback // is needed. const manifest = chrome.runtime.getManifest(); const metadata: SessionMetadata = { timestamp: new Date().toISOString(), url: new URL(chrome.runtime.getURL('')).origin, userAgent: navigator.userAgent, extensionVersion: manifest.version, 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; } // Скачивание архива (D-P2-01: offscreen-minted blob: URL pipeline; P0-6 fix) // // Architectural triangle: SW packages zip → SW asks offscreen to mint URL // (CREATE_DOWNLOAD_URL with base64 archive bytes) → offscreen mints via // URL.createObjectURL (SW lacks it per DEC-006) → offscreen replies // DOWNLOAD_URL{url} → SW calls chrome.downloads.download → onChanged // fires 'complete'/'interrupted' → SW asks offscreen to revoke // (REVOKE_DOWNLOAD_URL). The base64 wire-format reuses the D-12 // precedent from src/shared/binary.ts. // // NO FALLBACK to the legacy data: URL pathway: real operator archives // (5-10 MB) exceed Chrome's ~2 MB data-URL cap and would silently fail // with a 'Network error' download (audit P0-6). The legacy encoding // chain (blobToBase64 + chrome.downloads.download(`data:...`)) is gone. // On any failure (mint timeout, empty url, port unavailable, non-blob: // prefix) we throw a typed Error that routes through saveArchive's // catch block into the RECORDING_ERROR channel — operator gets a // visible failure, not a silently corrupted archive. 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)`); if (videoPort === null) { throw new Error('blob-url-mint-failed: offscreen port unavailable'); } // Encode the archive bytes for the SW→offscreen wire (D-12 base64 // precedent — chrome.runtime.Port JSON-serializes payloads and Blobs // arrive as empty objects without this transform). const dataBase64 = await blobToBase64(archiveBlob); const requestId = generateRequestId(); const urlPromise = new Promise((resolve) => { pendingDownloadUrlResolvers.set(requestId, resolve); }); try { videoPort.postMessage({ type: 'CREATE_DOWNLOAD_URL', requestId, dataBase64, mimeType: 'application/zip', }); } catch (err) { // Port disconnected synchronously between the null-check and post. // Clean up the resolver entry so it doesn't leak; surface a typed // error so saveArchive's catch routes it to RECORDING_ERROR. pendingDownloadUrlResolvers.delete(requestId); throw new Error(`blob-url-mint-failed: CREATE_DOWNLOAD_URL post threw: ${String(err)}`); } const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error('blob-url-mint-timeout')), BLOB_URL_MINT_TIMEOUT_MS, ); }); let url: string; try { url = await Promise.race([urlPromise, timeoutPromise]); } catch (err) { // Timeout fired before the offscreen responded. Drain the resolver // map entry; the late-arriving DOWNLOAD_URL will be silently dropped // by the onConnect sink (stale-id path). pendingDownloadUrlResolvers.delete(requestId); throw err; } if (url === '') { throw new Error('blob-url-mint-failed: offscreen returned empty url'); } // T-02-02-03 mitigation (defense-in-depth): reject any URL that does // not have the blob: scheme. The offscreen is same-extension origin // (sender-id-checked) and the WHATWG URL spec guarantees // URL.createObjectURL emits blob: only — this guard catches future // regressions / hostile-peer-on-shared-port scenarios. if (!url.startsWith('blob:')) { throw new Error(`blob-url-mint-failed: offscreen returned non-blob: url '${url.substring(0, 40)}...'`); } const downloadId = await chrome.downloads.download({ url, filename, saveAs: false, }); if (typeof downloadId === 'number') { // Track the (downloadId → url) pair so the chrome.downloads.onChanged // listener (registered below) can dispatch the revoke when the // download reaches a terminal state. pendingRevokes.set(downloadId, url); } logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`); } // Сохранение архива (полный процесс) // // Plan 01-09 Amendment 3 (2026-05-19, debug session // 01-09-save-does-not-stop-recording): REVERSES the prior Amendment 2 // save-stops-recording fix. SAVE_ARCHIVE creates a new zip but does // NOT stop the recorder — per operator UX iteration preferring the // original "continuous capture / always-on safety net" charter // (`Тз расширение фаза1.md`). The only termination paths are: // 1. Operator clicks Chrome's "Stop sharing" banner → // offscreen's onUserStoppedSharing emits RECORDING_ERROR{error: // 'user-stopped-sharing'} → SW's RECORDING_ERROR handler routes // through setIdleMode (Bug B branch — preserved). // 2. Browser closes (SW + offscreen torn down). // 3. Extension uninstalled. // // Operator workflow: hit a bug → click SAVE (zip lands) → keep working // → the next bug is also captured because the ring buffer keeps // filling. Contract pinned by // `tests/background/save-archive-does-not-stop-recording.test.ts` and // Plan 01-13 harness assertion A14 (both inverted from the prior // Amendment 2 contract). 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.segments.length} segments`); // Получаем 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); // Option C: the empty-video failure is operator-visible. Emit // RECORDING_ERROR so the popup's existing handler can surface it // (same channel codec-unsupported, user-cancelled, etc. ride). // Other createArchive failures (zip libs, JSZip internals) stay // SW-side only — they're not actionable by the operator. if (error instanceof EmptyVideoBufferError) { try { chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: error.code, }); } catch (sendErr) { // Best-effort notification — if the popup is closed we have // nothing else to do. logger.warn('Failed to broadcast RECORDING_ERROR:', sendErr); } } return { success: false, error }; } // Plan 01-09 Amendment 3 (2026-05-19): NO `finally` block here. The // prior Amendment 2 finally block (STOP_RECORDING dispatch + isRecording=false // + setIdleMode) is REMOVED. SAVE_ARCHIVE creates a zip and returns; // the recorder and state machine stay in REC. See the function-level // docstring above for the full charter rationale. } // 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; case 'RECORDING_ERROR': // Plan 01-09 — the offscreen recorder broadcasts this on capture // failure (codec missing, picker cancelled, wrong-display-surface, // mid-record stream end, etc.) AND on operator-initiated stop-sharing // (src/offscreen/recorder.ts: onUserStoppedSharing emits // RECORDING_ERROR{error:'user-stopped-sharing'} after resetBuffer + // track release). // // Bug B (debug 01-09-recovery-flow) conditional routing: the // operator-initiated stop is NOT an error condition, it is a // deliberate end-of-session signal. Routing it through setErrorMode // would (a) leave the popup pinned to src/popup/index.html (SAVE-only) // so chrome.action.onClicked cannot re-fire — the popup wins the // toolbar click forever, and (b) paint the badge yellow as if a // capture failure occurred. Both lock the operator out of restart. // Route 'user-stopped-sharing' through setIdleMode instead: popup // empties (re-enabling onClicked-driven restart), badge returns to // OFF (resetBuffer has already cleared the offscreen buffer so SAVE // mode would be meaningless). No recovery notification: the operator // performed the stop deliberately; surfacing a notification would // be UX noise. // All other error codes preserve the original setErrorMode + recovery // notification routing (defensive fallback for genuine capture // failures — codec-unsupported, wrong-display-surface, capture-failed, // permission-denied, empty-video-buffer, unknown). logger.warn('RECORDING_ERROR received:', message); { // Narrow `message` to read the optional `error` payload. The // canonical Message interface (src/shared/types.ts) does not // typify it (Message has type/data/tabId only); the offscreen // recorder emits the extra `error` field as part of the // RECORDING_ERROR wire shape — read it via a minimal cast that // keeps us off `as any`. const errorCode = (message as unknown as { error?: unknown }).error; if (errorCode === 'user-stopped-sharing') { isRecording = false; setIdleMode(); } else { setErrorMode(); try { const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now(); chrome.notifications.create(recoveryId, { type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH), title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK), message: i18nMessage('notifRecovery', NOTIF_RECOVERY_FALLBACK), priority: 1, }); } catch (e) { logger.warn('Recovery notification create failed:', e); } } } 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'); // CR-03 fix: handshake deadlock after SW respawn. // OFFSCREEN_READY is fired by the offscreen exactly once at its // bootstrap (recorder.ts line ~452). If the SW is evicted (~30 s // idle) while the offscreen document persists, the next SW lifetime // creates a fresh `offscreenReady` Promise (line 33) and waits on // it forever — the offscreen has no signal to re-emit, and // startVideoCapture() hangs at `await offscreenReady`. // Resolve readiness immediately when we detect a pre-existing // offscreen: it MUST have completed its bootstrap before being // observable via hasDocument(). offscreenReadyResolve?.(); offscreenReadyResolve = null; logger.log('Resolving offscreenReady immediately — offscreen survived a prior SW lifetime'); } } } catch (err) { logger.warn('chrome.offscreen.hasDocument check failed:', err); } // Plan 01-09: enter idle mode at SW init — sets popup to '' so the // first toolbar click fires onClicked directly (skipping the popup), // and paints the OFF badge so the operator sees recording state at // a glance. setIdleMode(); logger.log('Service Worker initialized'); } // ─── Plan 01-09 listener registrations ─────────────────────────────── // All listener registrations are wrapped in try/catch so SW module load // stays defensively resilient when the chrome.action / chrome.notifications // / chrome.runtime.onStartup surfaces are absent or partially stubbed // (some unit test contexts only stub the runtime/tabs/offscreen subset). // In production all three surfaces are guaranteed by the MV3 manifest. // chrome.action.onClicked: fires ONLY when setPopup is '' (idle mode). // On click, if we are not already recording, start the capture. The // activation gesture is the toolbar click itself — getDisplayMedia // accepts that as a valid user gesture per the W3C Screen Capture spec. try { chrome.action.onClicked.addListener(async () => { if (isRecording) { logger.log('Toolbar onClicked while already recording — no-op'); return; } try { await startVideoCapture(); } catch (err) { logger.warn('toolbar-onClicked start failed:', err); } }); } catch (e) { logger.warn('chrome.action.onClicked.addListener failed:', e); } // chrome.runtime.onStartup: fires once when a new browser session starts // (NOT on extension install — that's onInstalled). Show an OS-level // notification inviting the operator to start a recording. try { chrome.runtime.onStartup.addListener(() => { setIdleMode(); try { const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now(); chrome.notifications.create(notificationId, { type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH), title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK), message: i18nMessage('notifStartupCta', NOTIF_STARTUP_CTA_FALLBACK), priority: 1, }); } catch (e) { logger.warn('Startup notification create failed:', e); } }); } catch (e) { logger.warn('chrome.runtime.onStartup.addListener failed:', e); } // chrome.notifications.onClicked: T-1-09-01 mitigation — only accept ids // in our 'mokosh-' namespace. chrome.notifications ids are per-extension // scoped at the OS level, but the prefix check is defense in depth. try { chrome.notifications.onClicked.addListener((notificationId) => { if (!notificationId.startsWith('mokosh-')) return; try { chrome.notifications.clear(notificationId); } catch (e) { logger.warn('notifications.clear failed:', e); } // The notification click is itself an activation gesture, so // startVideoCapture can call getDisplayMedia successfully from here. startVideoCapture().catch((err) => { logger.warn('notification-triggered start failed:', err); }); }); } catch (e) { logger.warn('chrome.notifications.onClicked.addListener failed:', e); } // chrome.downloads.onChanged: D-P2-01 (P0-6 fix) — revoke-on-terminal-state. // Closes the URL.revokeObjectURL lifecycle by routing terminal download // state transitions (`complete` / `interrupted`) into a REVOKE_DOWNLOAD_URL // port message back to the offscreen document (which is the URL minting // origin per DEC-006). On benign races (offscreenPort null at revoke time, // e.g. SW respawn between download start and completion) the URL leaks in // offscreen until the document is torn down — bounded per-session, // acceptable per T-02-02-02 threat-register entry (chrome-extension:// // scoped, never exposed to web pages). try { if (chrome.downloads?.onChanged?.addListener !== undefined) { chrome.downloads.onChanged.addListener((delta) => { if (delta.state === undefined) return; const newState = delta.state.current; if (newState !== 'complete' && newState !== 'interrupted') return; const url = pendingRevokes.get(delta.id); if (url === undefined) return; pendingRevokes.delete(delta.id); if (videoPort !== null) { try { videoPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url }); logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`); } catch (err) { logger.warn('REVOKE_DOWNLOAD_URL post failed:', err); } } else { logger.warn( `videoPort null at revoke time; url ${url.substring(0, 30)}... leaks until offscreen teardown`, ); } }); } } catch (e) { logger.warn('chrome.downloads.onChanged.addListener failed:', e); } // Запуск при установке 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(); // Plan 01-10 D-17-onboarding: open welcome tab on first install. // Fire-and-forget — the helper logs its own errors and rejected // promises are caught at the .catch boundary so they cannot escape // the synchronous listener. The toolbar onClicked start path // (D-16-toolbar) is independent of this flow. openWelcomeIfFirstInstall(details).catch((err) => { logger.warn('openWelcomeIfFirstInstall threw:', err); }); }); // Запуск при старте Service Worker initialize();