chore: import broken Phase-1 extension as received

Snapshot of /home/parf/Downloads/manifest.zip as delivered, before any
GSD-driven remediation. Contains a partially-broken first attempt at the
Russian SPEC "Тз расширение фаза1.md" (Phase 1 of operator-session-recorder).

Source layout:
- manifest.json — MV3 declaration with tabCapture/activeTab/downloads/etc.
- src/background/index.ts — service worker (video buffer + archive packaging)
- src/content/index.ts — rrweb + user-event logger
- src/popup/{index.html,index.ts,style.css} — Russian popup UI
- offscreen/{index.html,index.ts} — orphaned offscreen (see audit)
- vite.config.ts — inline plugin emitting a separate live offscreen.js
- generate-icons.js, icons/ — minimal PNG icons
- "Тз расширение фаза1.md" — authoritative Russian SPEC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:16:23 +02:00
commit 555eb0543f
21 changed files with 3935 additions and 0 deletions

536
src/background/index.ts Normal file
View File

@@ -0,0 +1,536 @@
import { Logger } from '../shared/logger';
import type {
Message,
VideoChunk,
SessionMetadata,
VideoBufferResponse
} from '../shared/types';
import JSZip from 'jszip';
const logger = new Logger('Main');
// Константы
const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд
// Состояние
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false; // Флаг что первый чанк уже сохранен
let isRecording = false;
let offscreenCreated = false;
let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null;
// userEvents хранится только в content script
// Для архивации получаем его оттуда
// Кольцевой буфер видео
function addVideoChunkFromBlob(blob: Blob) {
logger.log(`Processing video chunk from Blob, size: ${blob.size} bytes, type: ${blob.type}`);
const chunk: VideoChunk = {
data: blob,
timestamp: Date.now(),
isFirst: !firstChunkSaved // Первый чанк помечаем как isFirst
};
if (!firstChunkSaved) {
firstChunkSaved = true;
logger.log(`This is the FIRST video chunk (WebM header), size: ${blob.size} bytes`);
}
videoBuffer.push(chunk);
logger.log(`Added video chunk, buffer size: ${videoBuffer.length}, chunk size: ${blob.size} bytes, isFirst: ${chunk.isFirst}`);
// Удаляем старые чанки
cleanupVideoBuffer();
}
function cleanupVideoBuffer() {
const now = Date.now();
const beforeCount = videoBuffer.length;
logger.log(`Cleaning up buffer, current size: ${beforeCount}`);
// Всегда сохраняем первый чанк (WebM заголовок, помечен как isFirst)
// Остальные чанки фильтруем по времени (старше 30 секунд удаляем)
videoBuffer = videoBuffer.filter(chunk => {
// Всегда оставляем первый чанк (заголовок)
if (chunk.isFirst) {
return true;
}
// Остальные - только если моложе 30 секунд
const age = now - chunk.timestamp;
const keep = age < VIDEO_BUFFER_DURATION_MS;
if (!keep) {
logger.log(`Removing chunk, age: ${age}ms, limit: ${VIDEO_BUFFER_DURATION_MS}ms`);
}
return keep;
});
const removed = beforeCount - videoBuffer.length;
if (removed > 0) {
logger.log(`Removed ${removed} old video chunks, buffer: ${videoBuffer.length}`);
} else {
logger.log(`No chunks removed, buffer: ${videoBuffer.length}`);
}
}
// Создание offscreen документа
async function ensureOffscreen() {
if (offscreenCreated) {
logger.log('Offscreen already created');
return;
}
try {
const url = chrome.runtime.getURL('offscreen/index.html');
logger.log('Creating offscreen document at:', url);
await chrome.offscreen.createDocument({
url: url,
reasons: ['USER_MEDIA'] as any,
justification: 'Need to record video from tab for error reporting'
});
offscreenCreated = true;
logger.log('Offscreen document created successfully');
} catch (error) {
if ((error as any).message?.includes('already exists')) {
offscreenCreated = true;
logger.log('Offscreen document already exists');
} else {
logger.error('Failed to create offscreen document:', error);
throw error;
}
}
}
// Начало записи видео
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 документ
await ensureOffscreen();
// Получаем streamId для записи вкладки (без диалога)
logger.log('Getting tab media stream ID...');
const streamId = await (chrome.tabCapture as any).getMediaStreamId({
targetTabId: tab.id
});
logger.log('Got stream ID:', streamId?.substring(0, 20) + '...');
// Отправляем в offscreen через chrome.runtime.sendMessage
logger.log('Sending START_RECORDING to offscreen...');
try {
await chrome.runtime.sendMessage({
type: 'START_RECORDING',
streamId: streamId
});
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 для предотвращения выгрузки Service Worker
function setupKeepalive() {
chrome.alarms.create('keepalive', { periodInMinutes: 0.33 }); // 20 секунд
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepalive') {
logger.log('Keepalive ping');
}
});
}
// Получение видеобуфера
function getVideoBuffer(): VideoBufferResponse {
return {
chunks: videoBuffer
};
}
// Получение скриншота активной вкладки
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();
// Получаем видео буфер
const videoBuffer = getVideoBuffer();
logger.log(`Video buffer: ${videoBuffer.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 = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_RRWEB_EVENTS'
}) as any;
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(
videoBuffer,
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 };
}
}
// Проверка разрешений
async function checkPermissions(): Promise<boolean> {
try {
// Проверяем tabCapture
const hasTabCapture = await chrome.permissions.contains({
permissions: ['tabCapture']
});
logger.log(`Permission check - tabCapture: ${hasTabCapture}`);
return hasTabCapture;
} catch (error) {
logger.error('Permission check failed:', error);
return false;
}
}
// Запрос разрешений
async function requestPermissions(): Promise<boolean> {
try {
const granted = await chrome.permissions.request({
permissions: ['tabCapture']
});
logger.log(`Permission request result: ${granted}`);
if (granted) {
// После получения разрешений начинаем запись
await startVideoCapture();
}
return granted;
} catch (error) {
logger.error('Permission request failed:', error);
return false;
}
}
// Обработка сообщений
chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
logger.log('Received message:', message.type, message);
switch (message.type) {
case 'REQUEST_PERMISSIONS':
checkPermissions().then(async (hasPermissions) => {
if (hasPermissions) {
// Разрешения уже есть, запускаем запись видео
if (!isRecording) {
await startVideoCapture();
}
sendResponse({ granted: true });
} else {
requestPermissions().then(granted => {
sendResponse({ granted });
});
}
});
return true;
case 'GET_VIDEO_BUFFER':
sendResponse(getVideoBuffer());
return false;
case 'SAVE_ARCHIVE':
saveArchive().then(result => {
sendResponse(result);
});
return true;
case 'VIDEO_CHUNK':
const videoData = (message as any).data;
const videoTimestamp = (message as any).timestamp;
if (videoData && videoData.size > 0) {
logger.log(`Received video chunk from offscreen, size: ${videoData.size} bytes, timestamp: ${videoTimestamp}`);
addVideoChunkFromBlob(videoData);
} else {
logger.warn('Received empty or invalid video chunk');
}
return false;
case 'VIDEO_CHUNK_SAVED':
const chunkId = (message as any).chunkId;
const size = (message as any).size;
logger.log(`Video chunk ${chunkId} saved, size: ${size} bytes, loading from IndexedDB...`);
loadChunkFromIndexedDB(chunkId);
return false;
default:
logger.warn('Unknown message type:', message.type);
return false;
}
});
// IndexedDB для загрузки видеочанков
async function loadChunkFromIndexedDB(chunkId: number) {
try {
const db = await openIndexedDB();
const transaction = db.transaction(['chunks'], 'readonly');
const store = transaction.objectStore('chunks');
const request = store.get(chunkId);
request.onsuccess = () => {
const record = request.result;
if (record) {
logger.log(`Loaded chunk ${chunkId} from IndexedDB, size: ${record.data.size} bytes`);
addVideoChunkFromBlob(record.data);
} else {
logger.error(`Chunk ${chunkId} not found in IndexedDB`);
}
};
request.onerror = () => {
logger.error(`Error loading chunk ${chunkId} from IndexedDB:`, request.error);
};
} catch (error) {
logger.error(`Failed to open IndexedDB:`, error);
}
}
async function openIndexedDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('VideoRecorderDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('chunks')) {
db.createObjectStore('chunks', { keyPath: 'id' });
}
};
});
}
// Инициализация
function initialize() {
logger.log('Service Worker initializing');
setupKeepalive();
logger.log('Service Worker initialized');
}
// Запуск при установке
chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason);
initialize();
});
// Запуск при старте Service Worker
initialize();