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:
536
src/background/index.ts
Normal file
536
src/background/index.ts
Normal 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
358
src/content/index.ts
Normal 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
21
src/popup/index.html
Normal 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
170
src/popup/index.ts
Normal 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
85
src/popup/style.css
Normal 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
51
src/shared/logger.ts
Normal 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
68
src/shared/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user