Files
mokosh/src/background/index.ts
Mark d5bb948d95 feat(fix-d12): decode chunks from base64 in SW BUFFER receive
- Read incoming port.chunks as TransferredVideoChunk[] (was
  VideoChunk[] — but that was a lie because Blob doesn't survive
  JSON serialization across the port boundary).
- Decode each wire chunk via base64ToBlob(wire.data, wire.type) and
  resolve VideoBufferResponse with the resulting VideoChunk[]. The
  existing mergeVideoChunks downstream sees real Blobs and produces
  a real WebM-prefixed merged blob.
- Defensive per-chunk decode: log + skip individual decode failures
  rather than blowing up the whole fetch. Falls back to
  video/webm;codecs=vp9 if the wire chunk somehow omits the type
  (defense-in-depth — the offscreen always populates it).
- Document the 2 s BUFFER_FETCH_TIMEOUT_MS budget: covers worst-case
  encode + post-message + JSON parse with > 1.5 s of headroom for
  the current 15-chunk × 100 KB sizing.

Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
2026-05-15 20:18:31 +02:00

527 lines
19 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.
import { Logger } from '../shared/logger';
import { base64ToBlob } from '../shared/binary';
import type {
Message,
TransferredVideoChunk,
VideoChunk,
SessionMetadata,
VideoBufferResponse
} from '../shared/types';
import JSZip from 'jszip';
// Default MIME applied when a wire chunk somehow lacks a type
// field (defense-in-depth: in normal operation the offscreen recorder
// always populates it from chunk.data.type). Matches D-20 strict codec.
const VIDEO_MIME_FALLBACK = 'video/webm;codecs=vp9';
const logger = new Logger('Main');
// Состояние
// Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально:
// при экспорте он спрашивает буфер у offscreen через long-lived port (D-17).
let isRecording = false;
let offscreenCreated = false;
let lastScreenshotTime = 0;
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
// Для архивации получаем его оттуда
// Ring-buffer helpers (header-pin + age-trim) and the buffer duration
// constant were removed in Plan 01-03 — the buffer now lives in
// src/offscreen/recorder.ts per D-16. Plan 05 completes the SW shrink:
// see deletions below.
// Создание offscreen документа
async function ensureOffscreen() {
if (offscreenCreated) {
logger.log('Offscreen already created');
return;
}
try {
const url = chrome.runtime.getURL('src/offscreen/index.html');
logger.log('Creating offscreen document at:', url);
await chrome.offscreen.createDocument({
url: url,
reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
justification: 'Continuous screen recording for operator session diagnostics'
});
offscreenCreated = true;
logger.log('Offscreen document created successfully');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('already exists')) {
offscreenCreated = true;
logger.log('Offscreen document already exists');
} else {
logger.error('Failed to create offscreen document:', error);
throw error;
}
}
}
// 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).
});
// 2 s budget covers the worst-case round-trip: offscreen base64-encodes
// up to ~15 chunks of ~100 KB each (~1.5 MB raw → ~2 MB base64) in
// well under 100 ms, post-message + JSON parse adds < 50 ms, leaving
// plenty of headroom. Bumping later is cheap if real-world recordings
// produce significantly larger buffers; today this is sufficient.
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);
// D-12 fix: chunks arrive as TransferredVideoChunk[] (base64
// string + MIME). Decode each back into a VideoChunk so
// mergeVideoChunks keeps operating on real Blobs. See
// src/shared/binary.ts and the GREEN block of
// tests/offscreen/port-serialization.test.ts.
const wireChunks =
(msg as { chunks?: TransferredVideoChunk[] }).chunks ?? [];
const chunks: VideoChunk[] = [];
for (const wire of wireChunks) {
try {
chunks.push({
data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK),
timestamp: wire.timestamp,
isFirst: wire.isFirst,
});
} catch (err) {
logger.warn(
'base64ToBlob failed; skipping chunk',
'timestamp:', wire.timestamp,
'isFirst:', wire.isFirst,
'error:', err,
);
}
}
resolve({ chunks });
}
};
port.onMessage.addListener(handler);
port.postMessage({ type: 'REQUEST_BUFFER' });
});
}
// Начало записи видео
async function startVideoCapture() {
if (isRecording) {
logger.log('Video recording already active');
return;
}
try {
// Получаем активную вкладку
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id || !tab.url) {
throw new Error('No active tab found');
}
logger.log(`Starting video capture for tab ${tab.id}: ${tab.url}`);
// Создаём offscreen документ (с reason из D-02)
await ensureOffscreen();
// Ждём, пока offscreen зарегистрирует свой onMessage listener
// (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше,
// чем offscreen готов его принять, и Chrome бросает "Receiving end
// does not exist".
await offscreenReady;
// Просим offscreen запустить запись — getDisplayMedia вызывается там
// (D-01: больше нет SW-side stream-id юзаства).
logger.log('Sending START_RECORDING to offscreen...');
try {
await chrome.runtime.sendMessage({
type: 'START_RECORDING'
});
logger.log('START_RECORDING sent successfully');
} catch (msgError) {
logger.error('Failed to send START_RECORDING:', msgError);
throw msgError;
}
isRecording = true;
logger.log('Video recording started successfully');
} catch (error) {
logger.error('Failed to start video capture:', error);
isRecording = false;
throw error;
}
}
// Keepalive теперь обеспечивается long-lived портом offscreen→SW (D-17/D-18).
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.
// Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен
// выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана.
// Получение скриншота активной вкладки
async function captureScreenshot(): Promise<Blob> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) {
throw new Error('No active tab');
}
// Проверяем кэш и лимит частоты (максимум 1 скриншот в 2 секунды)
const now = Date.now();
if (cachedScreenshot && (now - lastScreenshotTime) < 2000) {
logger.log('Using cached screenshot');
return cachedScreenshot;
}
// Делаем новый скриншот
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
// Конвертируем data URL в Blob
const response = await fetch(dataUrl);
cachedScreenshot = await response.blob();
lastScreenshotTime = now;
return cachedScreenshot;
}
// Склейка видео чанков
function mergeVideoChunks(chunks: VideoChunk[]): Blob {
logger.log(`Merging ${chunks.length} chunks`);
// Сортируем по времени, чтобы сохранить правильный порядок
const sortedChunks = [...chunks].sort((a, b) => a.timestamp - b.timestamp);
logger.log(`Chunks sorted, first timestamp: ${sortedChunks[0]?.timestamp}, last: ${sortedChunks[sortedChunks.length - 1]?.timestamp}`);
// Конвертируем в массив Blob
const blobs: Blob[] = sortedChunks.map((chunk, index) => {
logger.log(`Adding chunk ${index}, size: ${chunk.data.size} bytes, isFirst: ${chunk.isFirst}`);
return chunk.data;
});
const finalBlob = new Blob(blobs, { type: 'video/webm' });
logger.log(`Final video blob size: ${finalBlob.size} bytes, total chunks merged: ${blobs.length}`);
return finalBlob;
}
// Создание архива
async function createArchive(
videoBufferResponse: VideoBufferResponse,
rrwebEvents: unknown[],
userEvents: unknown[],
screenshot: Blob
): Promise<Blob> {
logger.log('=== Creating archive ===');
logger.log(`Events to include: ${rrwebEvents.length} rrweb, ${userEvents.length} user`);
const zip = new JSZip();
// Добавляем видео
if (videoBufferResponse.chunks.length > 0) {
const videoBlob = mergeVideoChunks(videoBufferResponse.chunks);
zip.file('video/last_30sec.webm', videoBlob);
logger.log(`✓ Added video: ${videoBlob.size} bytes`);
} else {
logger.warn('✗ No video chunks to add');
}
// Добавляем rrweb события
const rrwebJson = JSON.stringify(rrwebEvents, null, 2);
zip.file('rrweb/session.json', rrwebJson);
if (rrwebEvents.length > 0) {
logger.log(`✓ Added rrweb events: ${rrwebEvents.length} events, ${rrwebJson.length} bytes`);
} else {
logger.warn('✗ rrweb session.json created but EMPTY (content script may not be running)');
}
// Добавляем пользовательские события
const eventsJson = JSON.stringify(userEvents, null, 2);
zip.file('logs/events.json', eventsJson);
if (userEvents.length > 0) {
logger.log(`✓ Added user events: ${userEvents.length} events, ${eventsJson.length} bytes`);
} else {
logger.warn('✗ logs/events.json created but EMPTY (no user interactions detected)');
}
// Добавляем скриншот
zip.file('screenshot.png', screenshot);
logger.log('✓ Added screenshot');
// Добавляем метаданные
const metadata: SessionMetadata = {
timestamp: new Date().toISOString(),
url: new URL(chrome.runtime.getURL('')).origin,
userAgent: navigator.userAgent,
extensionVersion: '1.0.0',
videoBufferSeconds: 30,
logDurationMinutes: 10,
totalEvents: rrwebEvents.length + userEvents.length
};
zip.file('meta.json', JSON.stringify(metadata, null, 2));
logger.log('✓ Added metadata');
// Генерируем архив
const archiveBlob = await zip.generateAsync({ type: 'blob' });
logger.log(`✓ Archive created: ${archiveBlob.size} bytes`);
return archiveBlob;
}
// Скачивание архива
async function downloadArchive(archiveBlob: Blob) {
const now = new Date();
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-');
const filename = `session_report_${dateStr}_${timeStr}.zip`;
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
// Конвертируем Blob в Data URL (работает в Service Worker)
const arrayBuffer = await archiveBlob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < uint8Array.length; i++) {
binary += String.fromCharCode(uint8Array[i]);
}
const base64 = btoa(binary);
const url = `data:application/zip;base64,${base64}`;
await chrome.downloads.download({
url: url,
filename: filename,
saveAs: false
});
logger.log('Archive download started');
}
// Сохранение архива (полный процесс)
async function saveArchive() {
try {
logger.log('Starting archive save process');
// Получаем текущую активную вкладку
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) {
throw new Error('No active tab found');
}
logger.log(`Using tab ${tab.id} for archive, url: ${tab.url}`);
// Получаем скриншот
logger.log('Capturing screenshot...');
const screenshot = await captureScreenshot();
// Получаем видео буфер из offscreen через long-lived port (D-17)
const videoBufferResp = await getVideoBufferFromOffscreen();
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
// Получаем rrweb события от content script
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);
let rrwebEvents: unknown[] = [];
let userEvents: unknown[] = [];
try {
logger.log(`Sending GET_RRWEB_EVENTS message to tab ${tab.id}...`);
const response: { events?: unknown[]; userEvents?: unknown[] } | undefined =
await chrome.tabs.sendMessage(tab.id, { type: 'GET_RRWEB_EVENTS' });
logger.log(`Got response from tab ${tab.id}:`, response);
rrwebEvents = response?.events ?? [];
userEvents = response?.userEvents ?? [];
logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`);
// Логируем первые несколько событий для отладки
if (rrwebEvents.length > 0) {
logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...');
}
if (userEvents.length > 0) {
logger.log('First user event:', userEvents[0]);
}
} catch (messageError) {
logger.warn('✗ Failed to get events from content script:', messageError);
logger.warn('This may happen on special pages (chrome://, about:blank) or if content script is not injected');
logger.warn('Check if content script is running on the active tab (open DevTools Console on the page)');
// Продолжаем без событий, если контент-скрипт недоступен
}
// Создаем архив
const archiveBlob = await createArchive(
videoBufferResp,
rrwebEvents,
userEvents,
screenshot
);
// Скачиваем
await downloadArchive(archiveBlob);
logger.log('Archive save completed');
return { success: true };
} catch (error) {
logger.error('Failed to save archive:', error);
return { success: false, error };
}
}
// checkPermissions / requestPermissions удалены: старая permission
// больше не нужна (D-A6 — заменена на desktopCapture в manifest), а
// getDisplayMedia не требует runtime-разрешения — нужен только user gesture.
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.
// Обработка сообщений
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);
switch (message.type) {
case 'REQUEST_PERMISSIONS':
// Под getDisplayMedia (D-01) runtime-permission проверять нечего —
// браузер сам покажет picker по user gesture из popup. Просто
// запускаем запись и подтверждаем popup-у.
(async () => {
try {
if (!isRecording) {
await startVideoCapture();
}
sendResponse({ granted: true });
} catch (error) {
logger.error('startVideoCapture failed:', error);
sendResponse({ granted: false });
}
})();
return true;
case 'GET_VIDEO_BUFFER':
getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
return true;
case 'SAVE_ARCHIVE':
saveArchive().then(result => {
sendResponse(result);
});
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
// in Plan 01-03:
// - the offscreen recorder now owns the buffer (D-16);
// - chunks no longer travel via chrome.runtime.sendMessage (D-19);
// - SW-side IDB plumbing was the audit P0 #2 broken path.
// The IDB helpers were only reachable from those deleted cases.
// Plan 05 finishes the SW shrink (see deletions above).
default:
logger.warn('Unknown message type:', message.type);
return false;
}
});
// Инициализация
async function initialize() {
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');
}
// Запуск при установке
chrome.runtime.onInstalled.addListener((details) => {
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();
});
// Запуск при старте Service Worker
initialize();