Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 5cd1519858 - Show all commits

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();
}); });