Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -16,6 +16,16 @@ let isRecording = false;
|
|||||||
let offscreenCreated = false;
|
let offscreenCreated = false;
|
||||||
let lastScreenshotTime = 0;
|
let lastScreenshotTime = 0;
|
||||||
let cachedScreenshot: Blob | null = null;
|
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
|
// userEvents хранится только в content script
|
||||||
// Для архивации получаем его оттуда
|
// Для архивации получаем его оттуда
|
||||||
|
|
||||||
@@ -54,6 +64,61 @@ async function ensureOffscreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
|
||||||
|
resolve({ chunks });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
port.onMessage.addListener(handler);
|
||||||
|
port.postMessage({ type: 'REQUEST_BUFFER' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Начало записи видео
|
// Начало записи видео
|
||||||
async function startVideoCapture() {
|
async function startVideoCapture() {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
@@ -73,6 +138,11 @@ async function startVideoCapture() {
|
|||||||
|
|
||||||
// Создаём offscreen документ (с reason из D-02)
|
// Создаём offscreen документ (с reason из D-02)
|
||||||
await ensureOffscreen();
|
await ensureOffscreen();
|
||||||
|
// Ждём, пока offscreen зарегистрирует свой onMessage listener
|
||||||
|
// (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше,
|
||||||
|
// чем offscreen готов его принять, и Chrome бросает "Receiving end
|
||||||
|
// does not exist".
|
||||||
|
await offscreenReady;
|
||||||
|
|
||||||
// Просим offscreen запустить запись — getDisplayMedia вызывается там
|
// Просим offscreen запустить запись — getDisplayMedia вызывается там
|
||||||
// (D-01: больше нет SW-side stream-id юзаства).
|
// (D-01: больше нет SW-side stream-id юзаства).
|
||||||
@@ -101,11 +171,8 @@ async function startVideoCapture() {
|
|||||||
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
|
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
|
||||||
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.
|
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.
|
||||||
|
|
||||||
// Получение видеобуфера (временный синхронный стаб; Task 2 заменит его
|
// Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен
|
||||||
// на асинхронный запрос к offscreen через long-lived port).
|
// выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана.
|
||||||
function getVideoBuffer(): VideoBufferResponse {
|
|
||||||
return { chunks: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получение скриншота активной вкладки
|
// Получение скриншота активной вкладки
|
||||||
async function captureScreenshot(): Promise<Blob> {
|
async function captureScreenshot(): Promise<Blob> {
|
||||||
@@ -264,9 +331,9 @@ async function saveArchive() {
|
|||||||
logger.log('Capturing screenshot...');
|
logger.log('Capturing screenshot...');
|
||||||
const screenshot = await captureScreenshot();
|
const screenshot = await captureScreenshot();
|
||||||
|
|
||||||
// Получаем видео буфер
|
// Получаем видео буфер из offscreen через long-lived port (D-17)
|
||||||
const videoBuffer = getVideoBuffer();
|
const videoBufferResp = await getVideoBufferFromOffscreen();
|
||||||
logger.log(`Video buffer: ${videoBuffer.chunks.length} chunks`);
|
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
|
||||||
|
|
||||||
// Получаем rrweb события от content script
|
// Получаем rrweb события от content script
|
||||||
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);
|
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);
|
||||||
@@ -303,7 +370,7 @@ async function saveArchive() {
|
|||||||
|
|
||||||
// Создаем архив
|
// Создаем архив
|
||||||
const archiveBlob = await createArchive(
|
const archiveBlob = await createArchive(
|
||||||
videoBuffer,
|
videoBufferResp,
|
||||||
rrwebEvents,
|
rrwebEvents,
|
||||||
userEvents,
|
userEvents,
|
||||||
screenshot
|
screenshot
|
||||||
@@ -327,7 +394,14 @@ async function saveArchive() {
|
|||||||
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.
|
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.
|
||||||
|
|
||||||
// Обработка сообщений
|
// Обработка сообщений
|
||||||
chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
|
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);
|
logger.log('Received message:', message.type, message);
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
@@ -349,8 +423,8 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'GET_VIDEO_BUFFER':
|
case 'GET_VIDEO_BUFFER':
|
||||||
sendResponse(getVideoBuffer());
|
getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
|
||||||
return false;
|
return true;
|
||||||
|
|
||||||
case 'SAVE_ARCHIVE':
|
case 'SAVE_ARCHIVE':
|
||||||
saveArchive().then(result => {
|
saveArchive().then(result => {
|
||||||
@@ -358,6 +432,12 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
|
|||||||
});
|
});
|
||||||
return true;
|
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
|
// Legacy chunk-streaming and IndexedDB save/load handlers were removed
|
||||||
// in Plan 01-03:
|
// in Plan 01-03:
|
||||||
// - the offscreen recorder now owns the buffer (D-16);
|
// - the offscreen recorder now owns the buffer (D-16);
|
||||||
@@ -373,14 +453,38 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Инициализация
|
// Инициализация
|
||||||
function initialize() {
|
async function initialize() {
|
||||||
logger.log('Service Worker initializing');
|
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');
|
logger.log('Service Worker initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запуск при установке
|
// Запуск при установке
|
||||||
chrome.runtime.onInstalled.addListener((details) => {
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
logger.log('Extension installed/updated:', details.reason);
|
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();
|
initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user