Files
mokosh/src/offscreen/recorder.ts

673 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: ~50200 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();