diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 8aad94d..3fecb23 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -14,6 +14,8 @@ const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallbac const VIDEO_BITRATE = 400_000; // CON-video-codec const TIMESLICE_MS = 2_000; // SPEC §4.1 const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop +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'); @@ -137,20 +139,79 @@ function stopRecording(): void { } } -// ─── Bootstrap (Plan 04 wires the full port + handshake) ──────────────── -// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer + -// codec tests can mock `chrome.runtime` without crashing on module import. -// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect -// loop without rewriting this module's exports. +// ─── 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; } -// Бутстрап (Plan 03 stub; Plan 04 wires the full handshake + reconnect). +// 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') { + if (keepalivePort !== null) { + keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }); + } + } + // Any unknown port message type is silently dropped (T-1-04 defense-in-depth). +} + +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 ring-buffer + codec-check tests can import the module -// without providing a chrome stub. Production runs always have chrome.runtime -// available, so the guard is a no-op there. +// 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; @@ -175,9 +236,8 @@ function bootstrap(): void { } }); } - // Plan 04 will replace this stub with the full reconnect + ping loop. if (typeof chrome.runtime.connect === 'function') { - keepalivePort = chrome.runtime.connect({ name: PORT_NAME }); + connectPort(); } if (typeof chrome.runtime.sendMessage === 'function') { chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); @@ -186,10 +246,6 @@ function bootstrap(): void { bootstrap(); -// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04 -// uses it. Once Plan 04 lands, this line is removed in its refactor pass. -void keepalivePort; - // ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ── // Activated only if the Phase 07 ffprobe gate fails on the simpler // continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.