673 lines
31 KiB
TypeScript
673 lines
31 KiB
TypeScript
// 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<typeof setTimeout> | 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 : '<unknown>';
|
||
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<void> {
|
||
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<typeof setInterval> | null = null;
|
||
let preemptiveReconnectId: ReturnType<typeof setTimeout> | 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<void> {
|
||
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<void> {
|
||
// 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();
|