- 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.
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
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(); |