feat(01-05): wire SW-side port host and port-based buffer fetch

Plan 05 Task 2 — make the SW a pure coordinator that talks to the offscreen
via the long-lived 'video-keepalive' port (D-17, RESEARCH.md Pattern 5).

Additions:
- chrome.runtime.onConnect.addListener handler scoped to port name
  'video-keepalive' + T-1-04 mitigation (port.sender?.id check). Stores port
  in module-level videoPort: chrome.runtime.Port | null.
- getVideoBufferFromOffscreen(): port-based REQUEST_BUFFER round-trip with
  a 2s timeout fallback to { chunks: [] }. Replaces the synchronous SW-local
  getVideoBuffer() stub from Task 1.
- offscreenReady Promise + OFFSCREEN_READY onMessage case (RESEARCH.md
  Pattern 4): startVideoCapture awaits this before sending START_RECORDING,
  closing the 'Receiving end does not exist' race window (audit P1 #12).
- onMessage GET_VIDEO_BUFFER + SAVE_ARCHIVE rewritten to fetch the buffer
  via the port instead of the deleted local array.
- onMessage sender.id !== chrome.runtime.id guard at handler top
  (T-1-NEW-05-01 mitigation).
- chrome.runtime.onInstalled now calls indexedDB.deleteDatabase('VideoRecorderDB')
  once to clean up the orphaned database from pre-Phase-01 builds
  (T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory).

Rule 2 deviation (orchestrator-flagged robustness):
- initialize() now calls chrome.offscreen.hasDocument() to detect existing
  offscreen documents across SW respawns and update offscreenCreated
  accordingly (audit P1 #8). Guarded with a typeof check to stay safe under
  partial chrome stubs.

Verified: npx tsc --noEmit clean; npx vitest run 9/9 green (Plan 04
offscreen-side tests stay un-touched); no as any / @ts-ignore.
This commit is contained in:
2026-05-15 18:02:51 +02:00
parent 886376e789
commit 5cd1519858

View File

@@ -16,6 +16,16 @@ let isRecording = false;
let offscreenCreated = false; let offscreenCreated = false;
let lastScreenshotTime = 0; let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null; let cachedScreenshot: Blob | null = null;
// 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<void> = new Promise((res) => {
offscreenReadyResolve = res;
});
// userEvents хранится только в content script // userEvents хранится только в content script
// Для архивации получаем его оттуда // Для архивации получаем его оттуда
@@ -54,6 +64,61 @@ async function ensureOffscreen() {
} }
} }
// 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) — Chrome 110+ resets the SW idle timer on every
// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE.
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;
port.onDisconnect.addListener(() => {
logger.log('Offscreen port disconnected; offscreen will reconnect');
videoPort = null;
});
// Inbound traffic is mostly PING (ignored) and BUFFER (handled by the
// per-request listener installed in getVideoBufferFromOffscreen).
});
const BUFFER_FETCH_TIMEOUT_MS = 2_000;
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer');
return { chunks: [] };
}
const port = videoPort;
return new Promise<VideoBufferResponse>((resolve) => {
const timer = setTimeout(() => {
port.onMessage.removeListener(handler);
logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
resolve({ chunks: [] });
}, BUFFER_FETCH_TIMEOUT_MS);
const handler = (msg: unknown) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'BUFFER'
) {
clearTimeout(timer);
port.onMessage.removeListener(handler);
const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
resolve({ chunks });
}
};
port.onMessage.addListener(handler);
port.postMessage({ type: 'REQUEST_BUFFER' });
});
}
// Начало записи видео // Начало записи видео
async function startVideoCapture() { async function startVideoCapture() {
if (isRecording) { if (isRecording) {
@@ -73,6 +138,11 @@ async function startVideoCapture() {
// Создаём offscreen документ (с reason из D-02) // Создаём offscreen документ (с reason из D-02)
await ensureOffscreen(); await ensureOffscreen();
// Ждём, пока offscreen зарегистрирует свой onMessage listener
// (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше,
// чем offscreen готов его принять, и Chrome бросает "Receiving end
// does not exist".
await offscreenReady;
// Просим offscreen запустить запись — getDisplayMedia вызывается там // Просим offscreen запустить запись — getDisplayMedia вызывается там
// (D-01: больше нет SW-side stream-id юзаства). // (D-01: больше нет SW-side stream-id юзаства).
@@ -101,11 +171,8 @@ async function startVideoCapture() {
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle // Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение. // timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.
// Получение видеобуфера (временный синхронный стаб; Task 2 заменит его // Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен
// на асинхронный запрос к offscreen через long-lived port). // выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана.
function getVideoBuffer(): VideoBufferResponse {
return { chunks: [] };
}
// Получение скриншота активной вкладки // Получение скриншота активной вкладки
async function captureScreenshot(): Promise<Blob> { async function captureScreenshot(): Promise<Blob> {
@@ -264,9 +331,9 @@ async function saveArchive() {
logger.log('Capturing screenshot...'); logger.log('Capturing screenshot...');
const screenshot = await captureScreenshot(); const screenshot = await captureScreenshot();
// Получаем видео буфер // Получаем видео буфер из offscreen через long-lived port (D-17)
const videoBuffer = getVideoBuffer(); const videoBufferResp = await getVideoBufferFromOffscreen();
logger.log(`Video buffer: ${videoBuffer.chunks.length} chunks`); logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
// Получаем rrweb события от content script // Получаем rrweb события от content script
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`); logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);
@@ -303,7 +370,7 @@ async function saveArchive() {
// Создаем архив // Создаем архив
const archiveBlob = await createArchive( const archiveBlob = await createArchive(
videoBuffer, videoBufferResp,
rrwebEvents, rrwebEvents,
userEvents, userEvents,
screenshot screenshot
@@ -327,7 +394,14 @@ async function saveArchive() {
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true. // REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.
// Обработка сообщений // Обработка сообщений
chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => { 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); logger.log('Received message:', message.type, message);
switch (message.type) { switch (message.type) {
@@ -349,8 +423,8 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
return true; return true;
case 'GET_VIDEO_BUFFER': case 'GET_VIDEO_BUFFER':
sendResponse(getVideoBuffer()); getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
return false; return true;
case 'SAVE_ARCHIVE': case 'SAVE_ARCHIVE':
saveArchive().then(result => { saveArchive().then(result => {
@@ -358,6 +432,12 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
}); });
return true; return true;
case 'OFFSCREEN_READY':
logger.log('OFFSCREEN_READY received');
offscreenReadyResolve?.();
offscreenReadyResolve = null;
return false;
// Legacy chunk-streaming and IndexedDB save/load handlers were removed // Legacy chunk-streaming and IndexedDB save/load handlers were removed
// in Plan 01-03: // in Plan 01-03:
// - the offscreen recorder now owns the buffer (D-16); // - the offscreen recorder now owns the buffer (D-16);
@@ -373,14 +453,38 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
}); });
// Инициализация // Инициализация
function initialize() { async function initialize() {
logger.log('Service Worker initializing'); 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');
}
}
} catch (err) {
logger.warn('chrome.offscreen.hasDocument check failed:', err);
}
logger.log('Service Worker initialized'); logger.log('Service Worker initialized');
} }
// Запуск при установке // Запуск при установке
chrome.runtime.onInstalled.addListener((details) => { chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason); 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(); initialize();
}); });