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

358
src/content/index.ts Normal file
View File

@@ -0,0 +1,358 @@
import { record } from 'rrweb';
import { ContentLogger } from '../shared/logger';
import type { UserEvent } from '../shared/types';
interface RrwebEventsResponse {
events: EventWithTime[];
userEvents: UserEvent[];
}
type EventWithTime = any;
const logger = new ContentLogger('Main');
// Константы
const RRWEB_MAX_EVENTS = 5000;
const RRWEB_RETENTION_MS = 10 * 60 * 1000; // 10 минут
const USER_EVENT_RETENTION_MS = 10 * 60 * 1000; // 10 минут
const CLEANUP_INTERVAL_MS = 60 * 1000; // 1 минута
// Буфер rrweb событий
let rrwebEvents: EventWithTime[] = [];
// Буфер пользовательских событий
let userEvents: UserEvent[] = [];
// Очистка старых событий
function cleanupOldEvents() {
const now = Date.now();
// Очистка rrweb событий
const rrwebBeforeCount = rrwebEvents.length;
rrwebEvents = rrwebEvents.filter(event => {
return (now - event.timestamp) < RRWEB_RETENTION_MS;
});
logger.log(`rrweb cleanup: ${rrwebBeforeCount} -> ${rrwebEvents.length} events`);
// Если все равно слишком много событий, удаляем самые старые
if (rrwebEvents.length > RRWEB_MAX_EVENTS) {
const toRemove = rrwebEvents.length - RRWEB_MAX_EVENTS;
rrwebEvents = rrwebEvents.slice(toRemove);
logger.log(`rrweb limit exceeded, removed ${toRemove} oldest events`);
}
// Очистка пользовательских событий
const userBeforeCount = userEvents.length;
userEvents = userEvents.filter(event => {
return (now - event.timestamp) < USER_EVENT_RETENTION_MS;
});
logger.log(`user events cleanup: ${userBeforeCount} -> ${userEvents.length} events`);
}
// Добавление пользовательского события
function addUserEvent(event: UserEvent) {
event.timestamp = Date.now();
event.url = window.location.href;
userEvents.push(event);
logger.log(`User event: ${event.type}`, event);
}
// Логирование кликов
function setupClickLogging() {
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const selector = getSelector(target);
addUserEvent({
timestamp: Date.now(),
type: 'click',
target: selector,
value: target.textContent?.substring(0, 100) || '',
url: window.location.href
});
}, true);
}
// Логирование ввода (без паролей)
function setupInputLogging() {
document.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement;
// Пропускаем пароли
if (target.type === 'password') {
return;
}
const selector = getSelector(target);
addUserEvent({
timestamp: Date.now(),
type: 'input',
target: selector,
value: target.value.substring(0, 200), // Ограничиваем длину
url: window.location.href
});
}, true);
}
// Логирование навигации
function setupNavigationLogging() {
const handleNavigation = () => {
addUserEvent({
timestamp: Date.now(),
type: 'navigation',
target: 'window',
value: window.location.href,
url: window.location.href,
meta: { previousUrl: history.state?.url || 'unknown' }
});
};
window.addEventListener('popstate', handleNavigation);
window.addEventListener('hashchange', handleNavigation);
// Интерсепт History API
// @ts-ignore
const originalPushState = history.pushState;
// @ts-ignore
const originalReplaceState = history.replaceState;
// @ts-ignore
history.pushState = function(...args) {
originalPushState.apply(this, args);
handleNavigation();
};
// @ts-ignore
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
handleNavigation();
};
}
// Логирование JS ошибок
function setupErrorLogging() {
window.addEventListener('error', (event) => {
addUserEvent({
timestamp: Date.now(),
type: 'js_error',
target: event.filename || 'unknown',
value: event.message,
url: window.location.href,
meta: {
line: event.lineno,
column: event.colno,
stack: event.error?.stack
}
});
});
window.addEventListener('unhandledrejection', (event) => {
addUserEvent({
timestamp: Date.now(),
type: 'js_error',
target: 'unhandledrejection',
value: event.reason?.toString() || 'Unknown rejection',
url: window.location.href,
meta: {
promise: event.promise.toString()
}
});
});
}
// Логирование сетевых ошибок
function setupNetworkLogging() {
// Перехват fetch
const originalFetch = window.fetch;
window.fetch = function(...args) {
return originalFetch.apply(this, args)
.then(response => {
if (!response.ok) {
addUserEvent({
timestamp: Date.now(),
type: 'network_error',
target: args[0]?.toString() || 'unknown',
value: `HTTP ${response.status} ${response.statusText}`,
url: window.location.href,
meta: {
status: response.status,
statusText: response.statusText,
url: response.url
}
});
}
return response;
})
.catch(error => {
addUserEvent({
timestamp: Date.now(),
type: 'network_error',
target: args[0]?.toString() || 'unknown',
value: error.message,
url: window.location.href,
meta: {
errorType: error.name
}
});
throw error;
});
};
// Перехват XMLHttpRequest
// @ts-ignore
const originalXHROpen = XMLHttpRequest.prototype.open;
// @ts-ignore
const originalXHRSend = XMLHttpRequest.prototype.send;
// @ts-ignore
XMLHttpRequest.prototype.open = function(...args) {
(this as any)._method = args[0];
(this as any)._url = args[1];
// @ts-ignore
return originalXHROpen.apply(this, args);
};
// @ts-ignore
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('loadend', function() {
const xhr = this as XMLHttpRequest;
if (xhr.status >= 400 || xhr.status === 0) {
addUserEvent({
timestamp: Date.now(),
type: 'network_error',
target: (xhr as any)._url || 'unknown',
value: `HTTP ${xhr.status} ${xhr.statusText || 'Network Error'}`,
url: window.location.href,
meta: {
method: (xhr as any)._method,
status: xhr.status,
statusText: xhr.statusText
}
});
}
});
// @ts-ignore
return originalXHRSend.apply(this, args);
};
}
// Получение CSS селектора элемента
function getSelector(element: HTMLElement): string {
if (element.id) {
return `#${element.id}`;
}
const parts: string[] = [];
let current: HTMLElement | null = element;
while (current && current.nodeType === Node.ELEMENT_NODE) {
if (current.id) {
parts.unshift(`#${current.id}`);
break;
} else {
let tagName = current.tagName.toLowerCase();
if (current === element) {
// Для целевого элемента берем точный путь
parts.unshift(tagName);
} else {
let siblingIndex = 0;
let sibling = current.previousSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE &&
(sibling as HTMLElement).tagName === current.tagName) {
siblingIndex++;
}
sibling = sibling.previousSibling;
}
if (siblingIndex > 0) {
parts.unshift(`${tagName}:nth-of-type(${siblingIndex + 1})`);
} else {
parts.unshift(tagName);
}
}
current = current.parentElement;
}
if (parts.length >= 5) {
break;
}
}
return parts.join(' > ');
}
// Инициализация rrweb
function initRrweb() {
record({
emit(event) {
rrwebEvents.push(event);
},
maskInputOptions: {
color: false,
date: false,
'datetime-local': false,
email: false,
month: false,
number: false,
range: false,
search: false,
tel: false,
text: false,
time: false,
url: false,
week: false,
textarea: false,
select: false,
password: true, // Маскируем только пароли
},
maskTextSelector: '[data-sensitive="true"]',
});
logger.log('rrweb recorder initialized');
}
// Обработка сообщений от Service Worker
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
logger.log('Received message:', message);
if (message.type === 'GET_RRWEB_EVENTS') {
const response: RrwebEventsResponse = {
events: rrwebEvents,
userEvents: userEvents
};
logger.log(`Sending ${rrwebEvents.length} rrweb events and ${userEvents.length} user events`);
sendResponse(response);
return true;
}
return false;
});
// Запуск
logger.log('Content script starting');
// Ждем готовности DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
function start() {
logger.log('Starting content script on:', window.location.href);
// Инициализация rrweb
initRrweb();
// Настройка логирования
setupClickLogging();
setupInputLogging();
setupNavigationLogging();
setupErrorLogging();
setupNetworkLogging();
// Запуск очистки старых событий
setInterval(cleanupOldEvents, CLEANUP_INTERVAL_MS);
logger.log('Content script initialized');
}

21
src/popup/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Call Recorder</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<button id="saveButton" class="save-button" disabled>
<span class="button-text">Сохранить отчёт об ошибке</span>
</button>
<p class="info-text">
Последние 30 сек видео + 10 мин лога
</p>
<div id="statusMessage" class="status-message"></div>
</div>
<script type="module" src="index.ts"></script>
</body>
</html>

170
src/popup/index.ts Normal file
View File

@@ -0,0 +1,170 @@
import type { PopupState } from '../shared/types';
// Элементы UI
const saveButton = document.getElementById('saveButton') as HTMLButtonElement;
const statusMessage = document.getElementById('statusMessage') as HTMLDivElement;
// Состояние
let popupState: PopupState = {
isRecording: false,
hasPermissions: false,
status: 'idle'
};
// Логирование
function log(...args: any[]) {
console.log('[Popup]', ...args);
}
// Обновление UI
function updateUI() {
log('Updating UI:', popupState);
// Текст кнопки
const buttonText = saveButton.querySelector('.button-text') as HTMLSpanElement;
switch (popupState.status) {
case 'idle':
buttonText.textContent = 'Сохранить отчёт об ошибке';
saveButton.className = 'save-button';
saveButton.disabled = !popupState.hasPermissions;
break;
case 'saving':
buttonText.textContent = 'Сохраняю...';
saveButton.className = 'save-button saving';
saveButton.disabled = true;
break;
case 'done':
buttonText.textContent = 'Готово! ✓';
saveButton.className = 'save-button done';
saveButton.disabled = true;
break;
}
// Сообщение о статусе
if (!popupState.hasPermissions && popupState.status === 'idle') {
statusMessage.textContent = 'Необходимо разрешение на запись экрана';
statusMessage.className = 'status-message error';
} else {
statusMessage.textContent = '';
statusMessage.className = 'status-message';
}
}
// Проверка разрешений при открытии popup
async function checkPermissions() {
try {
log('Sending REQUEST_PERMISSIONS message...');
const response = await chrome.runtime.sendMessage({
type: 'REQUEST_PERMISSIONS'
});
log('Got response:', response);
popupState.hasPermissions = response.granted;
log('Permissions check:', popupState.hasPermissions);
if (popupState.hasPermissions) {
popupState.isRecording = true;
}
updateUI();
// Если нет разрешений, запрашиваем автоматически
if (!popupState.hasPermissions) {
await requestPermissions();
}
} catch (error) {
log('Error checking permissions:', error);
popupState.hasPermissions = false;
updateUI();
}
}
// Запрос разрешений
async function requestPermissions() {
try {
const response = await chrome.runtime.sendMessage({
type: 'REQUEST_PERMISSIONS'
});
popupState.hasPermissions = response.granted;
popupState.isRecording = response.granted;
log('Permission request result:', response.granted);
updateUI();
if (!response.granted) {
statusMessage.textContent = 'Разрешение на запись обязательно для работы расширения';
statusMessage.className = 'status-message error';
}
} catch (error) {
log('Error requesting permissions:', error);
popupState.hasPermissions = false;
updateUI();
}
}
// Сохранение архива
async function saveArchive() {
if (!popupState.hasPermissions) {
log('Cannot save: no permissions');
await requestPermissions();
return;
}
popupState.status = 'saving';
updateUI();
try {
log('Starting archive save...');
const response = await chrome.runtime.sendMessage({
type: 'SAVE_ARCHIVE'
});
log('Save response:', response);
if (response.success) {
popupState.status = 'done';
statusMessage.textContent = 'Архив успешно сохранён!';
statusMessage.className = 'status-message success';
// Возвращаемся в idle через 3 секунды
setTimeout(() => {
popupState.status = 'idle';
statusMessage.textContent = '';
updateUI();
}, 3000);
} else {
throw new Error(response.error || 'Unknown error');
}
} catch (error) {
log('Error saving archive:', error);
popupState.status = 'idle';
statusMessage.textContent = 'Ошибка сохранения: ' + (error as Error).message;
statusMessage.className = 'status-message error';
updateUI();
}
}
// Инициализация
function init() {
log('Popup initializing');
// Привязка событий
saveButton.addEventListener('click', saveArchive);
// Проверяем разрешения при открытии
checkPermissions();
log('Popup initialized');
}
// Запуск при загрузке
document.addEventListener('DOMContentLoaded', init);

85
src/popup/style.css Normal file
View File

@@ -0,0 +1,85 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f8f9fa;
min-width: 320px;
padding: 16px;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.save-button {
width: 100%;
padding: 14px 20px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 8px;
background: #2563eb;
color: white;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.save-button:hover:not(:disabled) {
background: #1d4ed8;
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
transform: translateY(-1px);
}
.save-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.save-button:disabled {
background: #94a3b8;
cursor: not-allowed;
box-shadow: none;
opacity: 0.6;
}
.save-button.saving {
background: #0891b2;
cursor: wait;
}
.save-button.done {
background: #059669;
}
.info-text {
font-size: 12px;
color: #64748b;
text-align: center;
}
.status-message {
font-size: 13px;
font-weight: 500;
min-height: 20px;
text-align: center;
}
.status-message.error {
color: #dc2626;
}
.status-message.success {
color: #059669;
}
.status-message.info {
color: #0891b2;
}

51
src/shared/logger.ts Normal file
View File

@@ -0,0 +1,51 @@
// Логгер для Service Worker
export class Logger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
const timestamp = new Date().toISOString();
console[level](`[SW:${this.context}] ${timestamp}`, ...args);
}
log(...args: any[]) {
this.logWithLevel('log', ...args);
}
warn(...args: any[]) {
this.logWithLevel('warn', ...args);
}
error(...args: any[]) {
this.logWithLevel('error', ...args);
}
}
// Логгер для Content Script
export class ContentLogger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
const timestamp = new Date().toISOString();
console[level](`[CS:${this.context}] ${timestamp}`, ...args);
}
log(...args: any[]) {
this.logWithLevel('log', ...args);
}
warn(...args: any[]) {
this.logWithLevel('warn', ...args);
}
error(...args: any[]) {
this.logWithLevel('error', ...args);
}
}

68
src/shared/types.ts Normal file
View File

@@ -0,0 +1,68 @@
// Типы для обмена сообщениями между компонентами
export type MessageType =
| 'REQUEST_PERMISSIONS'
| 'PERMISSIONS_GRANTED'
| 'PERMISSIONS_DENIED'
| 'GET_VIDEO_BUFFER'
| 'VIDEO_BUFFER_RESPONSE'
| 'GET_RRWEB_EVENTS'
| 'RRWEB_EVENTS_RESPONSE'
| 'SAVE_ARCHIVE'
| 'ARCHIVE_SAVED'
| 'START_RECORDING'
| 'STOP_RECORDING'
| 'RECORDING_ERROR'
| 'VIDEO_CHUNK'
| 'VIDEO_CHUNK_SAVED'
| 'OFFSCREEN_READY';
export interface Message<T = any> {
type: MessageType;
data?: T;
tabId?: number;
}
// Видеобуфер
export interface VideoChunk {
data: Blob;
timestamp: number;
isFirst?: boolean;
}
// Лог событий пользователя
export interface UserEvent {
timestamp: number;
type: 'click' | 'input' | 'navigation' | 'js_error' | 'network_error';
target: string;
value?: string;
url: string;
meta?: Record<string, any>;
}
// Метаданные сессии
export interface SessionMetadata {
timestamp: string;
url: string;
userAgent: string;
extensionVersion: string;
videoBufferSeconds: number;
logDurationMinutes: number;
totalEvents: number;
}
// Сообщения для popup
export interface PopupState {
isRecording: boolean;
hasPermissions: boolean;
status: 'idle' | 'saving' | 'done';
}
// Ответы от Service Worker
export interface VideoBufferResponse {
chunks: VideoChunk[];
}
export interface RrwebEventsResponse {
events: any[];
}