// src/offscreen/recorder.ts — Phase 01 source of truth. // Owns: getDisplayMedia capture, MediaRecorder restart-segments lifecycle // (D-13), in-memory segment ring buffer, codec strict-mode, track.onended // cleanup, long-lived port keepalive (D-17), and the OFFSCREEN_READY // handshake (Pattern 4). // // Architecture note (D-13 activation, debug session webm-playback-freeze): // the recorder rotates segments — stop+restart the MediaRecorder every // SEGMENT_DURATION_MS on the SAME MediaStream. Each segment is therefore // a self-contained WebM with its own EBML header and seed keyframe, // eliminating the orphan-keyframe gap that froze playback at ~1 s under // the prior single-continuous-recorder + age-trim approach (D-09..D-11, // retired). Trade-off: ~50–200 ms recording gap at each rotation boundary // (accepted per CONTEXT.md D-13; the operator-facing "last 30 s" promise // is preserved as 3 × 10 s = 30 s.) import { OffscreenLogger } from '../shared/logger'; import { blobToBase64 } from '../shared/binary'; import type { Message, TransferredVideoSegment } from '../shared/types'; // ─── Константы (per CON-video-codec, CON-video-window, D-13) ──────────── // Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3). export const SEGMENT_DURATION_MS = 10_000; // Сколько сегментов держим в памяти — 3 × 10 с = 30 с (CON-video-window). export const MAX_SEGMENTS = 3; // Окно «последних 30 секунд» в миллисекундах — выводимая константа. // Сохранена под старым именем для обратной совместимости с тестами и // для self-документации (см. segment-rotation.test.ts инвариант). export const VIDEO_BUFFER_DURATION_MS = MAX_SEGMENTS * SEGMENT_DURATION_MS; const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback const VIDEO_BITRATE = 400_000; // CON-video-codec // На втором уровне MediaRecorder.start() мы НЕ передаём timeslice — // дождёмся финального ondataavailable из onstop(). Если потребуется // промежуточная диагностика, можно вернуть таймслайс позже, не меняя // контракт ротации. const PORT_NAME = 'video-keepalive'; // long-lived port name (D-17, Pattern 5) const PORT_PING_MS = 25_000; // < 30 s SW idle threshold const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4) const logger = new OffscreenLogger('Recorder'); // ─── Состояние модуля ─────────────────────────────────────────────────── // videoRecorder — текущий MediaRecorder; пересоздаётся при ротации. // mediaStream — общий захватываемый поток (НЕ пересоздаётся при ротации, // иначе getDisplayMedia затребует новый user gesture, что недопустимо). // segments — завершённые сегменты в порядке возраста (старшие первыми). // currentChunks — буфер dataavailable-чанков для текущего сегмента; // сливается в один Blob по onstop и пушится в `segments`. // rotationTimerId — setTimeout-хэндл следующей ротации; должен быть // очищен при STOP_RECORDING / onUserStoppedSharing. let videoRecorder: MediaRecorder | null = null; let mediaStream: MediaStream | null = null; let segments: Blob[] = []; let currentChunks: Blob[] = []; let rotationTimerId: ReturnType | null = null; let keepalivePort: chrome.runtime.Port | null = null; // long-lived SW keepalive (D-17, Pattern 5) // WR-03 fix: strictly-monotonic per-process counter for segment timestamps. // Replaces the previous `Date.now() + idx` scheme, which could collide // across two REQUEST_BUFFER calls within the same millisecond. The SW-side // `mergeVideoSegments` sorts by this `timestamp` ascending; a pure counter // guarantees deterministic ordering with zero wall-clock dependency. let segmentSeq = 0; // ─── Сегментный буфер (pure functions — testable in Node) ─────────────── /** * Возвращает копию массива завершённых сегментов в порядке от старого * к новому. Копия — чтобы вызывающий код (например, SW-side encode) * не мог случайно повредить внутреннее состояние. */ export function getSegments(): Blob[] { return [...segments]; } /** * Тестовый seam (D-13): позволяет vitest-у драйвить ротацию * детерминистически, не инстанцируя реальный MediaRecorder (jsdom не * поставляет capture pipeline). Производственный путь — onstop → blob * → push — приходит через MediaRecorder.onstop в `onSegmentStopped`. * * Не использовать из продакшен-кода. Имя содержит `ForTest`, чтобы * grep по prod-коду не нашёл случайных вызовов. */ export function pushSegmentForTest(segment: Blob): void { segments.push(segment); if (segments.length > MAX_SEGMENTS) { segments.shift(); } } /** * Полная очистка буферов: завершённые сегменты, in-flight чанки и * запланированная ротация. Вызывается из `onUserStoppedSharing` и * между тестами. */ export function resetBuffer(): void { segments = []; currentChunks = []; if (rotationTimerId !== null) { clearTimeout(rotationTimerId); rotationTimerId = null; } } // ─── Проверка кодека (D-20 strict-mode — no fallback chain) ───────────── // WR-02 fix: assertCodecSupported is a PURE predicate that throws. The // previous implementation broadcast a RECORDING_ERROR before throwing, // which violated single responsibility (a function named `assert*` // shouldn't have side effects) AND double-emitted with the // startRecording catch block — popup received two RECORDING_ERROR // messages for the same underlying problem. The single-source-of-truth // for the broadcast is now `startRecording`'s catch block via // classifyCaptureError. export function assertCodecSupported(): void { const supported = typeof MediaRecorder !== 'undefined' && typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(VIDEO_MIME); if (!supported) { const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; throw new Error(`vp9 unsupported. UA=${ua}`); } } // WR-01 fix: classify capture-pipeline failures into a stable, // programmatically actionable error code. The previous implementation // forwarded the raw DOMException message ("Permission denied by user", // "Could not start video source", etc.) which changes between Chrome // versions and locales. The popup / future telemetry needs a stable // vocabulary — a string union the SW can switch on. The raw browser // error stays in the logs at warn level for forensic value. // // Codes: // 'user-cancelled' — operator dismissed the getDisplayMedia picker // (DOMException name 'NotAllowedError' with no // system-permission denial context). // 'permission-denied' — system-level screen-recording permission denied // by the OS (macOS Screen Recording privacy // toggle, etc.). NotAllowedError + DOMException // message hints OR SecurityError. // 'codec-unsupported' — assertCodecSupported() threw; vp9 not in // MediaRecorder.isTypeSupported. // 'no-source-selected' — NotFoundError: picker yielded no source // (rare; theoretically impossible if the picker // closes via Cancel — that is NotAllowedError). // 'capture-failed' — AbortError / generic stream-acquisition failure. // 'unknown' — anything else; we still log the raw error. export type CaptureErrorCode = | 'user-cancelled' | 'permission-denied' | 'codec-unsupported' | 'no-source-selected' | 'capture-failed' | 'unknown'; export function classifyCaptureError(error: unknown): CaptureErrorCode { if (error instanceof Error) { // Our own assertion is the cleanest signal — it never wraps a // DOMException, so prefix-matching is safe and stable. if (error.message.startsWith('vp9 unsupported')) { return 'codec-unsupported'; } // DOMException has a stable `name` field per the WebIDL standard: // https://webidl.spec.whatwg.org/#idl-DOMException // The name does NOT vary between Chrome versions or locales, unlike // the human-readable message — exactly what we want for routing. const name = (error as { name?: string }).name; if (name === 'NotAllowedError') { // Distinguish system-permission denial from user-cancel by sniffing // the message for the "system" keyword Chrome uses on macOS denial // ("Permission denied by system"). On Linux/Windows the system case // is typically wrapped as a SecurityError instead — handled below. // The message text is locale-stable for English Chrome; for other // locales we fall back to 'user-cancelled' which is the dominant // NotAllowedError path in practice. if (/system/i.test(error.message)) { return 'permission-denied'; } return 'user-cancelled'; } if (name === 'SecurityError') { return 'permission-denied'; } if (name === 'NotFoundError') { return 'no-source-selected'; } if (name === 'AbortError') { return 'capture-failed'; } } return 'unknown'; } // ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ──────────── async function startRecording(): Promise { if (videoRecorder !== null && videoRecorder.state !== 'inactive') { logger.warn('Recording already active; ignoring duplicate START_RECORDING'); return; } try { assertCodecSupported(); // throws if vp9 missing — no fallback const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, // SPEC §9 — Phase 2 / CAP-01 territory }); mediaStream = stream; // Track end detection — RESEARCH.md Example F. Attach to ALL tracks // (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в // несинхронизованном состоянии. Регистрация — один раз на поток; // при ротации MediaRecorder поток тот же, перерегистрировать не нужно. stream.getTracks().forEach((track) => { track.addEventListener('ended', onUserStoppedSharing, { once: true }); }); // Чистим возможные остатки прежней сессии (на случай повторного // START_RECORDING после ручного STOP_RECORDING без перезагрузки // offscreen-документа). resetBuffer(); startNewSegment(); logger.log( 'Recording started (D-13 restart-segments), mime:', VIDEO_MIME, 'segment_ms:', SEGMENT_DURATION_MS, 'max_segments:', MAX_SEGMENTS, ); } catch (error) { // WR-01: emit a stable error code, not the raw DOMException message. // Raw message stays in logs at warn level for forensic value. const code = classifyCaptureError(error); const msg = error instanceof Error ? error.message : String(error); logger.warn('startRecording failed (raw):', msg); logger.error('startRecording failed (code):', code); chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code }); throw error; } } /** * Запуск нового сегмента: создаём свежий MediaRecorder на том же * MediaStream, навешиваем обработчики, стартуем без timeslice (один * dataavailable на остановку → один blob на сегмент → один keyframe в * заголовке за счёт fresh-encoder-инициализации). * * Sweep #3 hardening: the MediaRecorder constructor and .start() may * throw (codec mid-session unavailability, GPU/driver hot-swap, etc.). * Without a guard the rotation chain would silently die — onSegmentStopped * is never called again, no new RECORDING_ERROR is emitted, the popup * shows green while nothing is recording. Catch + classify + emit + * tear down the session so the operator gets actionable feedback. */ function startNewSegment(): void { if (mediaStream === null) { logger.warn('startNewSegment called without an active mediaStream — abort'); return; } currentChunks = []; try { videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE, }); videoRecorder.ondataavailable = onDataAvailable; videoRecorder.onstop = onSegmentStopped; videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event); // Без timeslice: одно событие dataavailable придёт по .stop() — это // ровно один blob, содержащий целиком сегмент (EBML-заголовок + // кластеры). Так каждый сегмент гарантированно декодируется // независимо. Если когда-то потребуется живая стат-телеметрия, // можно дать timeslice назад без изменения семантики ротации. videoRecorder.start(); scheduleRotation(); } catch (err) { // Sweep #3 fix: MediaRecorder construction / start failed mid-session. // Most common cause is the codec becoming unavailable (GPU hot-swap, // driver change). Classify, notify, and tear down so the operator // sees an actionable error instead of silent recording cessation. const code = classifyCaptureError(err); const msg = err instanceof Error ? err.message : String(err); logger.warn('startNewSegment failed (raw):', msg); logger.error('startNewSegment failed (code):', code); chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code }); // Tear down — same shape as onUserStoppedSharing's cleanup so the // SW-side state machine doesn't get a half-recorded session. const streamToStop = mediaStream; mediaStream = null; videoRecorder = null; if (rotationTimerId !== null) { clearTimeout(rotationTimerId); rotationTimerId = null; } if (streamToStop !== null) { streamToStop.getTracks().forEach((t) => { try { t.stop(); } catch (terr) { logger.warn('track.stop() during startNewSegment cleanup failed:', terr); } }); } } } /** * Планируем следующую ротацию через SEGMENT_DURATION_MS. Хэндл * сохраняем в `rotationTimerId`, чтобы `resetBuffer` / остановка * могли её отменить — иначе на остановленном MediaRecorder будет * вызван .stop(), что бросит исключение. */ function scheduleRotation(): void { if (rotationTimerId !== null) { clearTimeout(rotationTimerId); } rotationTimerId = setTimeout(rotateSegment, SEGMENT_DURATION_MS); } /** * Вызывает stop() на текущем рекордере. Финальный ondataavailable * и onstop сработают синхронно сразу после; именно в onSegmentStopped * сегмент будет упакован и запушен в массив сегментов. */ function rotateSegment(): void { if (videoRecorder === null) { return; } if (videoRecorder.state !== 'recording') { // Кто-то остановил рекордер вручную раньше таймера — выходим // молча; стартовать новый сегмент тут не нужно (он будет запущен // следующим START_RECORDING, если он придёт). return; } try { videoRecorder.stop(); // onstop → onSegmentStopped → новый сегмент } catch (err) { logger.error('rotateSegment: stop() failed:', err); } } /** * Финализация одного сегмента: склеиваем накопленные dataavailable-чанки * в один Blob, кладём в `segments`, обрезаем до MAX_SEGMENTS, стартуем * следующий сегмент на том же mediaStream. * * Edge case: если по какой-то причине currentChunks пустой (например, * MediaRecorder.stop() сработал прежде, чем энкодер успел эмитнуть хоть * один пакет — теоретически возможно сразу после start() без timeslice * на пустом потоке), сегмент не пушим — не корраптим буфер пустым blob-ом. */ function onSegmentStopped(): void { if (currentChunks.length > 0) { const segmentBlob = new Blob(currentChunks, { type: 'video/webm' }); segments.push(segmentBlob); if (segments.length > MAX_SEGMENTS) { segments.shift(); } logger.log( 'Segment finalized, size:', segmentBlob.size, 'kept:', segments.length, '/', MAX_SEGMENTS, ); } else { logger.warn('Segment finalized with zero chunks — skipping push'); } currentChunks = []; // Стартуем следующий сегмент только если поток ещё активен. Если // пользователь нажал «прекратить шеринг» между stop() и сюда — // onUserStoppedSharing уже сбросил mediaStream в null. if (mediaStream !== null) { startNewSegment(); } } function onDataAvailable(event: BlobEvent): void { if (!event.data || event.data.size === 0) { return; } // Накапливаем в per-segment буфер; финализация — в onSegmentStopped. currentChunks.push(event.data); } // Sweep #4 fix: onUserStoppedSharing is registered with `{ once: true }` // on EACH track of the captured stream. With multiple tracks (video + // audio — audio currently always disabled per D-13 but the registration // walks `getTracks()` defensively), the handler could fire twice if // both tracks emit `ended` in close succession. `resetBuffer()` and the // stream-cleanup steps are idempotent, but the `chrome.runtime.sendMessage` // would double-emit RECORDING_ERROR — same double-emit pattern WR-02 // fixed for the codec/getDisplayMedia path. Guard with a flag that // gates the broadcast + cleanup. let teardownInProgress = false; function onUserStoppedSharing(): void { if (teardownInProgress) { logger.log('onUserStoppedSharing already ran — second track ended, ignoring'); return; } teardownInProgress = true; logger.log('Operator stopped sharing — clearing buffer'); resetBuffer(); if (videoRecorder !== null && videoRecorder.state !== 'inactive') { try { videoRecorder.stop(); } catch (err) { logger.warn('stop() on user-stopped-sharing failed:', err); } } if (mediaStream !== null) { mediaStream.getTracks().forEach((t) => t.stop()); mediaStream = null; } videoRecorder = null; chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' }); // Reset the guard so a future startRecording → onUserStoppedSharing // cycle works correctly. Place AFTER the broadcast so a same-tick // second invocation is still gated. // Use a microtask deferral (queueMicrotask) so the reset happens after // every synchronous re-entrant invocation in the same dispatcher tick. queueMicrotask(() => { teardownInProgress = false; }); } function stopRecording(): void { // Sweep #1 fix: race between rotateSegment / onSegmentStopped and a // STOP_RECORDING that does NOT null the mediaStream. The previous // implementation cleared the rotation timer and called .stop(), which // triggers onSegmentStopped async; onSegmentStopped then unconditionally // calls startNewSegment() whenever `mediaStream !== null`. Result: after // a manual STOP_RECORDING, a new segment would spawn anyway and the // recorder kept rolling silently. The fix mirrors onUserStoppedSharing: // null the mediaStream FIRST so onSegmentStopped's gate sees the stopped // state, then stop the recorder and release the tracks. This also fixes // the secondary issue of leaked MediaStreamTracks (the picker indicator // would have stayed up until the operator manually clicked "Stop // sharing" in Chrome's UI). if (rotationTimerId !== null) { clearTimeout(rotationTimerId); rotationTimerId = null; } // Capture stream before nulling so we can release its tracks. const streamToStop = mediaStream; mediaStream = null; if (videoRecorder !== null && videoRecorder.state !== 'inactive') { try { videoRecorder.stop(); } catch (err) { logger.warn('stop() during stopRecording failed:', err); } logger.log('Recording stopped manually'); } if (streamToStop !== null) { streamToStop.getTracks().forEach((t) => t.stop()); } // Note: we do NOT call resetBuffer() here — the operator may want to // STOP and then SAVE the buffered footage. resetBuffer happens on // onUserStoppedSharing (operator dismissed Chrome's sharing indicator) // and at the start of the next startRecording(). } // ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ── // T-1-04 sender-id check: defense-in-depth on the offscreen side. The SW-side // `onConnect` listener (Plan 05) MUST also validate // `port.sender?.id === chrome.runtime.id` to reject port-hijack attempts // from other extensions. See threat register T-1-04 in 01-04-PLAN.md. function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean { return sender?.id === chrome.runtime.id; } // Stable handles for the ping interval and the pre-emptive reconnect timer, // so we can clear them on disconnect / re-init. let pingIntervalId: ReturnType | null = null; let preemptiveReconnectId: ReturnType | null = null; function teardownPortTimers(): void { if (pingIntervalId !== null) { clearInterval(pingIntervalId); pingIntervalId = null; } if (preemptiveReconnectId !== null) { clearTimeout(preemptiveReconnectId); preemptiveReconnectId = null; } } function onPortMessage(message: unknown): void { // Defense-in-depth: explicit shape check before destructuring (T-1-04) if (typeof message !== 'object' || message === null) { return; } const type = (message as { type?: unknown }).type; if (type === 'REQUEST_BUFFER') { // Fire-and-forget: the chrome.runtime.Port.onMessage listener API // ignores the return value. We do the async base64 encoding inside // an IIFE so the listener stays synchronous-typed (void return). // // D-12 fix: each segment's Blob is encoded to base64 BEFORE postMessage // because chrome.runtime.Port JSON-serializes across extension contexts, // and JSON.stringify(blob) === "{}". See src/shared/binary.ts and // tests/offscreen/port-serialization.test.ts for the contract. void encodeAndSendBuffer(); } // Any unknown port message type is silently dropped (T-1-04 defense-in-depth). } // Sweep #2 fix: in-flight guard against re-entrant encodeAndSendBuffer. // The SW only issues one REQUEST_BUFFER per saveArchive in production, but // nothing in the API design forbids back-to-back REQUEST_BUFFER messages. // Without this guard, two concurrent encode passes would: // (a) interleave `++segmentSeq` increments — each request's segments // end up with non-contiguous timestamps that look like gaps from // the SW sort perspective (benign but noisy) // (b) both call getSegments() against the same buffer snapshot, so // the SW would receive two BUFFERs with overlapping content if // it accidentally combined them // (c) inflate base64-encode CPU cost unnecessarily during the // encode latency window (~150 ms for 3 segments) // The guard drops the second concurrent call with a warn log; the SW // timeout fires cleanly and the next saveArchive retries on the fresh // post-completion state. let encodeInFlight = false; async function encodeAndSendBuffer(): Promise { if (encodeInFlight) { logger.warn('encodeAndSendBuffer already running — dropping concurrent call'); return; } // CR-01 fix: capture the port identity BEFORE the await. If `keepalivePort` // is replaced by a fresh reconnect during base64 encoding, posting on the // new port would silently leak the BUFFER to a stranger — the SW's // per-request listener is still bound to the OLD port. The SW already // times out cleanly (BUFFER_FETCH_TIMEOUT_MS = 2 s), so dropping a stale // response on the floor is the correct behaviour: the next SAVE_ARCHIVE // round-trip will REQUEST_BUFFER on the fresh port. const portAtRequest = keepalivePort; if (portAtRequest === null) { logger.warn('encodeAndSendBuffer called without an active port — drop'); return; } encodeInFlight = true; try { await doEncodeAndSendBuffer(portAtRequest); } finally { encodeInFlight = false; } } async function doEncodeAndSendBuffer(portAtRequest: chrome.runtime.Port): Promise { // WR-09 fix: an in-flight segment lacks the Matroska finalization that // MediaRecorder.stop() performs (SegmentSize, Cues) — splicing it onto // a finalized tail re-introduces the "File ended prematurely" symptom // documented in the debug session webm-playback-freeze. We still want // SOME data if SAVE_ARCHIVE fires within the first 10 s window (before // any segment rotation has completed); in that single case we accept // the trade-off and ship the in-flight Blob alone. Once we have at // least one finalized segment, we drop the in-flight tail unconditionally. const finalized = getSegments(); const inFlight = finalized.length === 0 && currentChunks.length > 0 ? new Blob(currentChunks, { type: 'video/webm' }) : null; const allSegments: Blob[] = inFlight !== null ? [inFlight] : finalized; // WR-03 fix: monotonically-increasing per-process counter, NOT // `Date.now() + idx`. Date.now() at millisecond resolution collides // across two REQUEST_BUFFER calls within the same millisecond (e.g., // diagnostic prefetch + real save). The merge code on the SW side // sorts by `timestamp` ascending, so a strictly monotonic counter // guarantees a deterministic order independent of wall-clock skew. const transferred: TransferredVideoSegment[] = []; for (let idx = 0; idx < allSegments.length; idx++) { const segment = allSegments[idx]; try { const data = await blobToBase64(segment); transferred.push({ data, type: segment.type || 'video/webm', timestamp: ++segmentSeq, }); } catch (err) { // Per-segment defensive encode: a single Blob failing to encode // (e.g. unexpected ArrayBuffer detach) is logged and skipped — // partial video > no video at all. logger.error( 'blobToBase64 failed; skipping segment', 'index:', idx, 'size:', segment.size, 'error:', err, ); } } // After the await: refuse to post on a port that has been REPLACED // by reconnect. The SW listens on the OLD port; posting on the NEW // port would silently lose the data. Letting the SW time out is // correct — the next SAVE_ARCHIVE will re-issue REQUEST_BUFFER on // the fresh port. if (keepalivePort !== portAtRequest) { logger.warn( 'port replaced during encode; dropping BUFFER response (SW will time out and retry)', ); return; } portAtRequest.postMessage({ type: 'BUFFER', segments: transferred }); } function connectPort(): void { teardownPortTimers(); try { keepalivePort = chrome.runtime.connect({ name: PORT_NAME }); } catch (err) { logger.error('port connect failed:', err); keepalivePort = null; return; } keepalivePort.onMessage.addListener(onPortMessage); keepalivePort.onDisconnect.addListener(() => { logger.warn('port disconnected — reconnecting'); teardownPortTimers(); keepalivePort = null; // Synchronous reconnect — tests/offscreen/port.test.ts pins this connectPort(); }); pingIntervalId = setInterval(() => { keepalivePort?.postMessage({ type: 'PING' }); }, PORT_PING_MS); preemptiveReconnectId = setTimeout(() => { logger.log('pre-emptive port reconnect (290 s cap)'); keepalivePort?.disconnect(); // onDisconnect handler above triggers a fresh connectPort() call }, PORT_RECONNECT_MS); } // Бутстрап (handshake + port lifecycle). // Guarded so the pure segment-rotation + codec-check tests can import the // module without providing a full chrome stub. Production always has // chrome.runtime fully populated, so the guard is a no-op there. Order // matters per Pattern 4: onMessage listener registration MUST happen BEFORE // OFFSCREEN_READY is sent, so the SW can safely send START_RECORDING the // moment it sees the ready signal. function bootstrap(): void { if (typeof chrome === 'undefined' || !chrome.runtime) { return; } if (typeof chrome.runtime.onMessage?.addListener === 'function') { chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => { if (!isFromOwnExtension(sender)) { return false; } switch (message.type) { case 'START_RECORDING': startRecording() .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ ok: false, error: String(err) })); return true; case 'STOP_RECORDING': stopRecording(); sendResponse({ ok: true }); return false; default: return false; } }); } if (typeof chrome.runtime.connect === 'function') { connectPort(); } if (typeof chrome.runtime.sendMessage === 'function') { chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); } } bootstrap();