docs(01): create phase 1 plans for video pipeline stabilization .planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md .planning/ROADMAP.md
This commit is contained in:
859
.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
Normal file
859
.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
Normal file
@@ -0,0 +1,859 @@
|
||||
# Phase 1: Stabilize Video Pipeline — Pattern Map
|
||||
|
||||
**Mapped:** 2026-05-15
|
||||
**Files analyzed:** 12 (4 NEW, 4 MODIFIED, 4 DELETED)
|
||||
**Analogs found:** 11 / 12
|
||||
|
||||
> NOTE on line numbers cited in CONTEXT.md: The CONTEXT.md narrative
|
||||
> references some "decimal-style" approximate line ranges that did not
|
||||
> match the live file when I read it. The numbers used **below** in
|
||||
> Pattern Assignments are the verified line numbers from the files as
|
||||
> they exist on disk on 2026-05-15.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New / Modified / Deleted File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------------------|------|-----------|----------------|---------------|
|
||||
| `src/offscreen/recorder.ts` (NEW) | offscreen recorder module | streaming + port + event-driven | `offscreen/index.ts` (dead) + inline string at `vite.config.ts:35-213` + ring-buffer structure at `src/background/index.ts:26-75` | role-match (compose 3 analogs) |
|
||||
| `src/offscreen/index.html` (NEW) | bundle entry HTML | static asset | `offscreen/index.html` (current) + `src/popup/index.html` (script tag) | exact |
|
||||
| `src/background/index.ts` (MODIFIED, heavy shrink) | service worker coordinator | request-response + port-host + event-driven | itself (current file) — refactor in place | exact |
|
||||
| `vite.config.ts` (MODIFIED, heavy reduction) | build config | config | crxjs discussion #919 pattern (cited in RESEARCH.md Example B) | role-match |
|
||||
| `manifest.json` (MODIFIED, permission swap) | manifest | config | itself | exact |
|
||||
| `src/shared/types.ts` (MODIFIED, light) | shared types | type-only | itself (current file) — add/verify message types | exact |
|
||||
| `tests/offscreen/ring-buffer.test.ts` (NEW) | unit test | request-response | none in codebase — Vitest default; structural cousin: `src/background/index.ts:47-75` `cleanupVideoBuffer` (subject under test) | role-only |
|
||||
| `tests/offscreen/codec-check.test.ts` (NEW) | unit test | request-response | none | role-only |
|
||||
| `tests/offscreen/handshake.test.ts` (NEW) | unit test | event-driven | none | role-only |
|
||||
| `tests/offscreen/port.test.ts` (NEW) | unit test | port/event-driven | none | role-only |
|
||||
| `vitest.config.ts` (NEW) | test runner config | config | `vite.config.ts` (defineConfig + plugin shape) + `tsconfig.json` (path/aliases) | role-only |
|
||||
| `offscreen/index.ts` (DELETED) | — | — | — | — |
|
||||
| `offscreen/index.html` (DELETED) | — | — | — | — |
|
||||
| `vite.config.ts:11-217` inline plugin (DELETED) | — | — | — | — |
|
||||
| `src/background/index.ts:156-165, 128, 457-473, 482-520` (DELETED in-place) | — | — | — | — |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `src/offscreen/recorder.ts` (NEW — offscreen recorder; streaming + port + event-driven)
|
||||
|
||||
**Analogs (composed):**
|
||||
- `offscreen/index.ts` (lines 1-60) — the dead-but-correctly-shaped skeleton: module-level `let mediaRecorder`, `chrome.runtime.onMessage.addListener` with a `switch (message.type)`, `MediaRecorder.ondataavailable` push pattern. Phase 1 KEEPS the shape and REWRITES the body.
|
||||
- Inline JS string at `vite.config.ts:35-213` — the live offscreen at runtime. This is the source-of-truth for the *current behavior* but is the explicit DELETE target. Use it ONLY as a reference for what NOT to copy (the codec fallback chain at lines 151-172, the IndexedDB plumbing at lines 43-107, the `let mediaRecorder` shadow at line 158).
|
||||
- `src/background/index.ts:25-75` — ring-buffer structural pattern (currently SW-side). MOVE this to offscreen.
|
||||
- `src/shared/logger.ts:1-25` — `Logger` class shape. Phase 1 adds an `OffscreenLogger` mirroring this (`[OS:...]` prefix) OR reuses `Logger` with prefix `Offscreen:Main`.
|
||||
|
||||
**Skeleton pattern (KEEP shape; rewrite body)** — from `offscreen/index.ts:1-60`:
|
||||
```typescript
|
||||
// offscreen/index.ts:1-2 — module-level state declaration (KEEP shape; rename
|
||||
// `mediaRecorder` → `videoRecorder` per RESEARCH.md anti-pattern fix)
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let videoChunks: Blob[] = [];
|
||||
|
||||
// offscreen/index.ts:45-59 — message-listener-with-switch shape (KEEP)
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
switch (message.type) {
|
||||
case 'START_RECORDING':
|
||||
startRecording(message.streamId);
|
||||
break;
|
||||
case 'STOP_RECORDING':
|
||||
stopRecording();
|
||||
break;
|
||||
case 'GET_CHUNKS':
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CHUNKS_RESPONSE',
|
||||
chunks: getChunks()
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**dataavailable pattern (KEEP behavior; chunk Blob is now pushed to ring buffer with timestamp+isHeader, NOT forwarded via `chrome.runtime.sendMessage`)** — from `offscreen/index.ts:18-27`:
|
||||
```typescript
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
videoChunks.push(event.data);
|
||||
chrome.runtime.sendMessage({ // ← REMOVE: this is the broken-Blob-over-sendMessage path
|
||||
type: 'VIDEO_CHUNK',
|
||||
data: event.data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Ring-buffer pattern (MOVE from SW to offscreen, verbatim)** — from `src/background/index.ts:47-75`:
|
||||
```typescript
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Header-pin add pattern (MOVE from SW to offscreen)** — from `src/background/index.ts:26-45`:
|
||||
```typescript
|
||||
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);
|
||||
cleanupVideoBuffer();
|
||||
}
|
||||
```
|
||||
|
||||
**Constants pattern** — from `src/background/index.ts:12-13`:
|
||||
```typescript
|
||||
// Константы
|
||||
const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд
|
||||
```
|
||||
Add for the new module:
|
||||
```typescript
|
||||
const TIMESLICE_MS = 2000; // CON-video-codec / SPEC §4.1 (was 200ms)
|
||||
const VIDEO_MIME = 'video/webm;codecs=vp9';
|
||||
const VIDEO_BITRATE = 400_000; // CON-video-codec
|
||||
const PORT_NAME = 'video-keepalive';
|
||||
const PORT_PING_MS = 25_000; // < 30s SW idle threshold
|
||||
const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5min port cap
|
||||
```
|
||||
|
||||
**getDisplayMedia call pattern** — from RESEARCH.md Example A (canonical):
|
||||
```typescript
|
||||
async function startRecording(): Promise<void> {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: false, // SPEC §9 — Phase 2/CAP-01 territory
|
||||
});
|
||||
// Codec strict-mode — RESEARCH.md Example E
|
||||
if (!MediaRecorder.isTypeSupported(VIDEO_MIME)) {
|
||||
const err = `vp9 unsupported. UA=${navigator.userAgent}`;
|
||||
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: err });
|
||||
throw new Error(err);
|
||||
}
|
||||
videoRecorder = new MediaRecorder(stream, {
|
||||
mimeType: VIDEO_MIME,
|
||||
videoBitsPerSecond: VIDEO_BITRATE,
|
||||
});
|
||||
videoRecorder.ondataavailable = onDataAvailable;
|
||||
videoRecorder.start(TIMESLICE_MS);
|
||||
// RESEARCH.md Example F — track.ended for "Stop sharing" recovery
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.addEventListener('ended', onUserStoppedSharing, { once: true });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**OFFSCREEN_READY handshake (Pattern 4)** — from RESEARCH.md (matches `src/shared/types.ts:18` declared but unused message type):
|
||||
```typescript
|
||||
// at the bottom of recorder.ts, after the onMessage listener is registered:
|
||||
chrome.runtime.onMessage.addListener((msg) => { /* ... */ });
|
||||
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
|
||||
```
|
||||
|
||||
**Port keepalive pattern (Pattern 5)** — from RESEARCH.md lines 600-628:
|
||||
```typescript
|
||||
const port = chrome.runtime.connect({ name: PORT_NAME });
|
||||
setInterval(() => port.postMessage({ type: 'PING' }), PORT_PING_MS);
|
||||
port.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'REQUEST_BUFFER') {
|
||||
port.postMessage({ type: 'BUFFER', chunks: videoBuffer });
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
// Pitfall 4 — 5-minute port cap, reconnect
|
||||
reconnectPort();
|
||||
});
|
||||
```
|
||||
|
||||
**Russian-comment style (KEEP)** — observed throughout `src/background/index.ts:12, 25, 47, 52-54, 156`:
|
||||
```typescript
|
||||
// Константы
|
||||
// Кольцевой буфер видео
|
||||
// Очистка старых событий
|
||||
// Keepalive для предотвращения выгрузки Service Worker
|
||||
```
|
||||
New code in `src/offscreen/recorder.ts` SHOULD include Russian section headers (matches project provenance from CONTEXT.md "Established patterns").
|
||||
|
||||
**Imports pattern (KEEP project shape)** — from `src/background/index.ts:1-8`:
|
||||
```typescript
|
||||
import { Logger } from '../shared/logger';
|
||||
import type {
|
||||
Message,
|
||||
VideoChunk,
|
||||
SessionMetadata,
|
||||
VideoBufferResponse
|
||||
} from '../shared/types';
|
||||
import JSZip from 'jszip';
|
||||
```
|
||||
For the new offscreen recorder:
|
||||
```typescript
|
||||
import { Logger } from '../shared/logger';
|
||||
import type { Message, VideoChunk } from '../shared/types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/offscreen/index.html` (NEW — bundle entry HTML, static asset)
|
||||
|
||||
**Analog:** `offscreen/index.html` (current) — DELETE and recreate.
|
||||
|
||||
**Current pattern** — `offscreen/index.html:1-10`:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Offscreen Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="./index.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Popup-side analog (same shape, references TS via crxjs)** — `src/popup/index.html:1-21`:
|
||||
```html
|
||||
<!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>
|
||||
...
|
||||
<script type="module" src="index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Pattern to copy** — RESEARCH.md Example A (crxjs discussion #919):
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><title>Mokosh Recorder</title></head>
|
||||
<body>
|
||||
<script type="module" src="./recorder.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/background/index.ts` (MODIFIED — service worker coordinator; shrinks substantially)
|
||||
|
||||
**Analog:** itself. Refactor in place.
|
||||
|
||||
**DELETE blocks (verified line numbers as of 2026-05-15):**
|
||||
|
||||
| Block | Current Lines | Reason |
|
||||
|-------|---------------|--------|
|
||||
| `addVideoChunkFromBlob` | 26-45 | MOVE to offscreen (D-16) |
|
||||
| `cleanupVideoBuffer` | 47-75 | MOVE to offscreen (D-16) |
|
||||
| `getMediaStreamId` call inside `startVideoCapture` | 126-131 | Replaced by `getDisplayMedia` in offscreen (D-01) |
|
||||
| `setupKeepalive` function | 156-165 | Replaced by port keepalive (D-18) |
|
||||
| `VIDEO_CHUNK` case in onMessage | 457-466 | Buffer no longer travels via sendMessage (D-19) |
|
||||
| `VIDEO_CHUNK_SAVED` case in onMessage | 468-473 | IndexedDB path is dead (D-19) |
|
||||
| `loadChunkFromIndexedDB` function | 482-505 | IndexedDB path is dead (D-19) |
|
||||
| `openIndexedDB` function | 507-520 | IndexedDB path is dead (D-19) |
|
||||
| `setupKeepalive()` call inside `initialize` | 525 | Paired with deletion of `setupKeepalive` |
|
||||
|
||||
**Auth/Guard pattern (sender.id validation — RESEARCH.md security recommendation)** — currently MISSING:
|
||||
The SW's onMessage listener at `src/background/index.ts:427-479` does NOT validate `sender.id`. Phase 1 SHOULD add `if (_sender.id !== chrome.runtime.id) return false;` at the top of the new port and onMessage handlers (low-effort hardening; per RESEARCH.md security domain table). Rename `_sender` → `sender` since it is now used.
|
||||
|
||||
**onMessage handler pattern (KEEP shape, modify cases)** — from `src/background/index.ts:427-479`:
|
||||
```typescript
|
||||
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; // ← async response convention
|
||||
|
||||
case 'GET_VIDEO_BUFFER':
|
||||
sendResponse(getVideoBuffer());
|
||||
return false; // ← sync response convention
|
||||
|
||||
case 'SAVE_ARCHIVE':
|
||||
saveArchive().then(result => {
|
||||
sendResponse(result);
|
||||
});
|
||||
return true;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type:', message.type);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
```
|
||||
Phase 1 modifications:
|
||||
- `REQUEST_PERMISSIONS`: replace `startVideoCapture` body with new `ensureOffscreenAndStart()` (offscreen + DISPLAY_MEDIA reason).
|
||||
- `GET_VIDEO_BUFFER`: replace `getVideoBuffer()` with an over-port request to offscreen (return `true`, resolve via port message handler).
|
||||
- `SAVE_ARCHIVE`: same — `getVideoBuffer()` inside `saveArchive()` (line 332) becomes a port-request to offscreen.
|
||||
- Add a new `case 'OFFSCREEN_READY'` to resolve the handshake Promise (Pattern 4).
|
||||
|
||||
**ensureOffscreen pattern (REPLACE reason)** — from `src/background/index.ts:78-104`:
|
||||
```typescript
|
||||
async function ensureOffscreen() {
|
||||
if (offscreenCreated) {
|
||||
logger.log('Offscreen already created');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = chrome.runtime.getURL('offscreen/index.html'); // ← path may change to 'src/offscreen/index.html'; verify after first build
|
||||
logger.log('Creating offscreen document at:', url);
|
||||
|
||||
await chrome.offscreen.createDocument({
|
||||
url: url,
|
||||
reasons: ['USER_MEDIA'] as any, // ← REPLACE: ['DISPLAY_MEDIA']; drop the `as any` once @types/chrome is bumped
|
||||
justification: 'Need to record video from tab for error reporting' // ← OPTIONAL: update copy to match RESEARCH.md Example C
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Anti-pattern note: the `as any` on line 90 maps to audit P1 #13 and the CLAUDE.md "no @ts-ignore / no as any" rule. The fix is `chrome.offscreen.Reason.DISPLAY_MEDIA` after bumping `@types/chrome` from `^0.0.268` to `^0.1.42` (RESEARCH.md Standard Stack). The bump is OPTIONAL in this phase but recommended.
|
||||
|
||||
**ADD: onConnect handler for video-keepalive port (NEW)** — RESEARCH.md Pattern 5, lines 609-617:
|
||||
```typescript
|
||||
let videoPort: chrome.runtime.Port | null = null;
|
||||
chrome.runtime.onConnect.addListener((p) => {
|
||||
if (p.name !== 'video-keepalive') return;
|
||||
videoPort = p;
|
||||
p.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'BUFFER') { /* resolve pending export */ }
|
||||
});
|
||||
p.onDisconnect.addListener(() => { videoPort = null; });
|
||||
});
|
||||
```
|
||||
|
||||
**ADD: one-shot deletion of orphaned IndexedDB on install** — RESEARCH.md Runtime State Inventory:
|
||||
The `chrome.runtime.onInstalled.addListener` at `src/background/index.ts:530-533` is the natural site to call `indexedDB.deleteDatabase('VideoRecorderDB')` to clean up old browser profiles. Add inside that listener.
|
||||
|
||||
**`saveArchive` pattern (MODIFY to fetch buffer via port)** — `src/background/index.ts:314-387`:
|
||||
```typescript
|
||||
async function saveArchive() {
|
||||
// ... unchanged up to line 332 ...
|
||||
const videoBuffer = getVideoBuffer(); // ← REPLACE: await getVideoBufferFromOffscreen()
|
||||
// ... unchanged thereafter ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `vite.config.ts` (MODIFIED — heavy reduction)
|
||||
|
||||
**Analog:** crxjs discussion #919 working pattern (cited in RESEARCH.md Example B).
|
||||
|
||||
**DELETE the entire inline plugin block** — `vite.config.ts:13-216` (the object literal starting `{ name: 'copy-offscreen', generateBundle() { ... } }`).
|
||||
|
||||
**Final shape (REPLACE existing file body)** — RESEARCH.md Example B:
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import { crx } from '@crxjs/vite-plugin';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
crx({ manifest, contentScripts: { injectCss: false } }),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
offscreen: 'src/offscreen/index.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Current `crx` invocation pattern (KEEP)** — `vite.config.ts:5-12`:
|
||||
```typescript
|
||||
crx({
|
||||
manifest,
|
||||
contentScripts: {
|
||||
injectCss: false,
|
||||
},
|
||||
}),
|
||||
```
|
||||
This invocation form is correct and is retained verbatim; only the second plugin entry (the inline `copy-offscreen`) is deleted, and `rollupOptions.input` for the offscreen HTML entry is added per RESEARCH.md Pitfall 5.
|
||||
|
||||
**Pitfall 5 note (per RESEARCH.md):** Inspect `dist/` after the first `npm run build` to verify the emitted path matches what `src/background/index.ts:85` passes to `createDocument`. If crxjs strips the `src/` prefix, change the SW's `chrome.runtime.getURL` arg accordingly.
|
||||
|
||||
---
|
||||
|
||||
### `manifest.json` (MODIFIED — permission swap)
|
||||
|
||||
**Analog:** itself.
|
||||
|
||||
**Current permissions block** — `manifest.json:6-14`:
|
||||
```json
|
||||
"permissions": [
|
||||
"tabCapture",
|
||||
"activeTab",
|
||||
"downloads",
|
||||
"scripting",
|
||||
"storage",
|
||||
"alarms",
|
||||
"offscreen"
|
||||
],
|
||||
```
|
||||
|
||||
**Patch per D-A6:**
|
||||
- Replace `"tabCapture"` with `"desktopCapture"`.
|
||||
- KEEP `"activeTab"` (screenshot path: `chrome.tabs.captureVisibleTab`, see `src/background/index.ts:190`).
|
||||
- Drop `"alarms"` (no longer used after `setupKeepalive` deletion). [OPTIONAL but consistent with D-18.]
|
||||
- KEEP `"downloads"` (used at `src/background/index.ts:305`).
|
||||
- KEEP `"storage"` (used by rrweb? — not used in current code; can drop, but out of scope for this phase).
|
||||
- KEEP `"scripting"` (content script).
|
||||
- KEEP `"offscreen"` (required to call `chrome.offscreen.createDocument`).
|
||||
|
||||
Final shape:
|
||||
```json
|
||||
"permissions": [
|
||||
"desktopCapture",
|
||||
"activeTab",
|
||||
"downloads",
|
||||
"scripting",
|
||||
"storage",
|
||||
"offscreen"
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/shared/types.ts` (MODIFIED — light; verify message types)
|
||||
|
||||
**Analog:** itself.
|
||||
|
||||
**Status check** — `src/shared/types.ts:3-18`:
|
||||
```typescript
|
||||
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';
|
||||
```
|
||||
- `OFFSCREEN_READY` is present (line 18). ✓
|
||||
- `RECORDING_ERROR` is present (line 15). ✓
|
||||
- `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` should be **REMOVED** (lines 16-17) since their handlers are deleted in Phase 1.
|
||||
|
||||
**Phase 1 additions:**
|
||||
- Add port-message string types if planner chooses to type the port traffic:
|
||||
```typescript
|
||||
// Port message types (offscreen ↔ SW over 'video-keepalive' port)
|
||||
export type PortMessageType =
|
||||
| 'PING'
|
||||
| 'REQUEST_BUFFER'
|
||||
| 'BUFFER';
|
||||
export interface PortMessage<T = unknown> {
|
||||
type: PortMessageType;
|
||||
chunks?: VideoChunk[];
|
||||
payload?: T;
|
||||
}
|
||||
```
|
||||
|
||||
**`VideoChunk` interface (KEEP as-is)** — `src/shared/types.ts:27-31`:
|
||||
```typescript
|
||||
export interface VideoChunk {
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
```
|
||||
This shape is exactly what the offscreen ring buffer needs. Reuse unchanged.
|
||||
|
||||
---
|
||||
|
||||
### `tests/offscreen/ring-buffer.test.ts` (NEW — unit test)
|
||||
|
||||
**Analog:** none in codebase (no tests directory exists yet).
|
||||
|
||||
**Subject under test** — the relocated `addVideoChunkFromBlob` + `cleanupVideoBuffer` from `src/background/index.ts:26-75`. Tests assert:
|
||||
1. First chunk is pinned with `isFirst: true`.
|
||||
2. Subsequent chunks have `isFirst: false`.
|
||||
3. After 30+ seconds, non-header chunks are evicted but the header survives.
|
||||
4. Buffer length stays ≤ N for N chunks added within 30 s.
|
||||
|
||||
**Skeleton (standard Vitest)** — pattern inferred from RESEARCH.md Validation Architecture:
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';
|
||||
// NB: planner must export these pure functions from recorder.ts to make them testable.
|
||||
|
||||
describe('ring buffer', () => {
|
||||
beforeEach(() => resetBuffer());
|
||||
|
||||
it('first chunk is header', () => {
|
||||
addChunk({ size: 1024 } as Blob, 1_000);
|
||||
expect(getBuffer()[0].isFirst).toBe(true);
|
||||
});
|
||||
|
||||
it('trim 30s — keeps header, evicts aged tail', () => {
|
||||
addChunk({ size: 1024 } as Blob, 0); // header at t=0
|
||||
addChunk({ size: 512 } as Blob, 10_000); // t=10s
|
||||
addChunk({ size: 512 } as Blob, 35_000); // t=35s — header now 35s old, tail 25s
|
||||
trimAged(/* now= */ 40_000); // header age 40s; first chunk 30s; last chunk 5s
|
||||
const buf = getBuffer();
|
||||
expect(buf[0].isFirst).toBe(true); // header survives unconditionally
|
||||
expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tests/offscreen/codec-check.test.ts` (NEW — unit test)
|
||||
|
||||
**Analog:** none. Subject under test = the codec strict-mode error path (RESEARCH.md Pattern 6 / Example E).
|
||||
|
||||
**Skeleton:**
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('codec strict mode', () => {
|
||||
let originalIsTypeSupported: typeof MediaRecorder.isTypeSupported;
|
||||
beforeEach(() => {
|
||||
originalIsTypeSupported = (globalThis as any).MediaRecorder?.isTypeSupported;
|
||||
(globalThis as any).MediaRecorder = {
|
||||
isTypeSupported: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
(globalThis as any).chrome = { runtime: { sendMessage: vi.fn() } };
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalIsTypeSupported) {
|
||||
(globalThis as any).MediaRecorder = { isTypeSupported: originalIsTypeSupported };
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
|
||||
const { assertCodecSupported } = await import('../../src/offscreen/recorder');
|
||||
expect(() => assertCodecSupported()).toThrow(/vp9 unsupported/);
|
||||
expect((globalThis as any).chrome.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'RECORDING_ERROR' })
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tests/offscreen/handshake.test.ts` (NEW — unit test)
|
||||
|
||||
**Analog:** none. Subject under test = `OFFSCREEN_READY` Promise resolution (RESEARCH.md Pattern 4).
|
||||
|
||||
**Skeleton:**
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('OFFSCREEN_READY handshake', () => {
|
||||
it('sends OFFSCREEN_READY after listener registration', async () => {
|
||||
const calls: any[] = [];
|
||||
(globalThis as any).chrome = {
|
||||
runtime: {
|
||||
sendMessage: (m: unknown) => { calls.push(m); },
|
||||
onMessage: { addListener: vi.fn() },
|
||||
connect: () => ({
|
||||
name: 'video-keepalive',
|
||||
postMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onDisconnect: { addListener: vi.fn() },
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
await import('../../src/offscreen/recorder');
|
||||
expect(calls).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'OFFSCREEN_READY' }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tests/offscreen/port.test.ts` (NEW — unit test)
|
||||
|
||||
**Analog:** none. Subject under test = port reconnect on disconnect (RESEARCH.md Pitfall 4).
|
||||
|
||||
**Skeleton:**
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('port reconnect', () => {
|
||||
it('reconnects when port disconnects', () => {
|
||||
const disconnectListeners: Array<() => void> = [];
|
||||
let connectCount = 0;
|
||||
(globalThis as any).chrome = {
|
||||
runtime: {
|
||||
sendMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
connect: () => {
|
||||
connectCount++;
|
||||
return {
|
||||
name: 'video-keepalive',
|
||||
postMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onDisconnect: {
|
||||
addListener: (fn: () => void) => disconnectListeners.push(fn),
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
// After import, the module connects exactly once
|
||||
return import('../../src/offscreen/recorder').then(() => {
|
||||
expect(connectCount).toBe(1);
|
||||
// Fire the disconnect — module should reconnect
|
||||
disconnectListeners.forEach((fn) => fn());
|
||||
expect(connectCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `vitest.config.ts` (NEW — test runner config)
|
||||
|
||||
**Analogs:** `vite.config.ts` (defineConfig shape) + `tsconfig.json` (compiler-options alignment).
|
||||
|
||||
**Pattern to copy** — RESEARCH.md Wave 0 Gaps + Vitest standard:
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node', // node-only; Blob is shimmed by Vitest 3+ via undici
|
||||
include: ['tests/**/*.test.ts'],
|
||||
reporters: 'dot',
|
||||
typecheck: {
|
||||
enabled: false, // tsc --noEmit runs separately in `npm run build`
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**`package.json` script ADD** — companion to vitest.config.ts:
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run" // ← ADD
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Logging
|
||||
**Source:** `src/shared/logger.ts:1-25` (Logger class) and `src/shared/logger.ts:28-51` (ContentLogger class).
|
||||
|
||||
**Apply to:** All new and modified source files.
|
||||
|
||||
```typescript
|
||||
// src/shared/logger.ts:1-25
|
||||
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); }
|
||||
}
|
||||
```
|
||||
|
||||
**For the new offscreen file:** Either (a) reuse `Logger` with constructor argument `'Offscreen:Main'`, producing `[SW:Offscreen:Main]` (slightly misleading since the runtime is the offscreen, not the SW), or (b) ADD an `OffscreenLogger` class mirroring `ContentLogger` with prefix `[OS:${context}]`. Planner picks; CONTEXT.md "Reusable assets" section permits either.
|
||||
|
||||
Recommendation: add `OffscreenLogger` for consistency with the existing `Logger` / `ContentLogger` split. ~25 lines, matches the shape exactly:
|
||||
```typescript
|
||||
// New OffscreenLogger class to add to src/shared/logger.ts
|
||||
export class OffscreenLogger {
|
||||
private context: string;
|
||||
constructor(context: string) { this.context = context; }
|
||||
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console[level](`[OS:${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); }
|
||||
}
|
||||
```
|
||||
|
||||
### Message handling (sender validation)
|
||||
**Source:** RESEARCH.md Security Domain table (currently MISSING in codebase).
|
||||
**Apply to:** New `chrome.runtime.onMessage` and `chrome.runtime.onConnect` handlers in both `src/background/index.ts` and `src/offscreen/recorder.ts`.
|
||||
|
||||
Current code does NOT validate `sender.id`. Phase 1 SHOULD add:
|
||||
```typescript
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (sender.id !== chrome.runtime.id) return false;
|
||||
// ... existing dispatch ...
|
||||
});
|
||||
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.sender?.id !== chrome.runtime.id) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
// ... existing handling ...
|
||||
});
|
||||
```
|
||||
|
||||
### Async response convention
|
||||
**Source:** `src/background/index.ts:427-479`.
|
||||
**Apply to:** All `onMessage` handlers that perform `await`/`then` before `sendResponse`.
|
||||
|
||||
The convention in the existing code is consistent and correct:
|
||||
- `return true;` — when the handler will call `sendResponse` asynchronously (after a Promise resolves).
|
||||
- `return false;` — when the handler returns synchronously or does not respond.
|
||||
|
||||
Preserve this convention verbatim in the modified background and the new offscreen recorder.
|
||||
|
||||
### Russian inline comments
|
||||
**Source:** `src/background/index.ts:12, 25, 47, 156` and `src/content/index.ts:14-18, 20-23, 27`.
|
||||
**Apply to:** New `src/offscreen/recorder.ts`.
|
||||
|
||||
Project convention: Russian comments are kept as section markers / explanations for business logic.
|
||||
Example:
|
||||
```typescript
|
||||
// Константы
|
||||
const TIMESLICE_MS = 2000;
|
||||
// Кольцевой буфер видео
|
||||
let videoBuffer: VideoChunk[] = [];
|
||||
// Keepalive порт (заменяет chrome.alarms)
|
||||
let keepalivePort: chrome.runtime.Port | null = null;
|
||||
```
|
||||
TypeScript identifiers stay English (per CLAUDE.md naming rules); only comments are Russian.
|
||||
|
||||
### Module-level state pattern
|
||||
**Source:** `src/background/index.ts:15-22` and `src/content/index.ts:20-24`.
|
||||
**Apply to:** New `src/offscreen/recorder.ts`.
|
||||
|
||||
Single-instance modules use top-level `let` state declarations (not classes / singletons):
|
||||
```typescript
|
||||
// src/background/index.ts:15-22
|
||||
let videoBuffer: VideoChunk[] = [];
|
||||
let firstChunkSaved = false;
|
||||
let isRecording = false;
|
||||
let offscreenCreated = false;
|
||||
let lastScreenshotTime = 0;
|
||||
let cachedScreenshot: Blob | null = null;
|
||||
```
|
||||
For `src/offscreen/recorder.ts`, mirror this shape:
|
||||
```typescript
|
||||
let videoRecorder: MediaRecorder | null = null; // RENAMED from 'mediaRecorder' to break the audit P0 #1 shadow
|
||||
let videoBuffer: VideoChunk[] = [];
|
||||
let firstChunkSaved = false;
|
||||
let mediaStream: MediaStream | null = null;
|
||||
let keepalivePort: chrome.runtime.Port | null = null;
|
||||
let pingIntervalId: number | null = null;
|
||||
```
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `tests/offscreen/*.test.ts` (4 files) | unit test | request-response | No test directory exists in the project yet. Pattern comes from Vitest defaults + RESEARCH.md Validation Architecture. |
|
||||
|
||||
`vitest.config.ts` has a partial analog in `vite.config.ts` (defineConfig shape) and is listed under Pattern Assignments rather than here.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:**
|
||||
- `/home/parf/projects/work/repremium/src/` (all subdirectories)
|
||||
- `/home/parf/projects/work/repremium/offscreen/`
|
||||
- `/home/parf/projects/work/repremium/vite.config.ts`
|
||||
- `/home/parf/projects/work/repremium/manifest.json`
|
||||
- `/home/parf/projects/work/repremium/tsconfig.json`
|
||||
- `/home/parf/projects/work/repremium/package.json`
|
||||
|
||||
**Files scanned:** 10 source files; 4 config files.
|
||||
|
||||
**Verified line numbers** (read on 2026-05-15):
|
||||
- `src/background/index.ts` — 536 lines total. Verified delete-target ranges: 26-45 (addVideoChunkFromBlob), 47-75 (cleanupVideoBuffer), 126-131 (getMediaStreamId), 156-165 (setupKeepalive), 457-473 (VIDEO_CHUNK + VIDEO_CHUNK_SAVED cases), 482-520 (loadChunkFromIndexedDB + openIndexedDB).
|
||||
- `vite.config.ts` — 227 lines total. Verified inline plugin range: 13-216 (the `copy-offscreen` object starts at line 13 with `name:` on line 14 inside a plugins array; the embedded JS string template ends at line 213; the trailing build/rollupOptions block at 218-226 also collapses per RESEARCH.md Example B).
|
||||
- `offscreen/index.ts` — 60 lines, intact. DELETE target.
|
||||
- `offscreen/index.html` — 10 lines, intact. DELETE target.
|
||||
- `manifest.json:7` — `"tabCapture"` token to replace.
|
||||
- `src/shared/types.ts:18` — `'OFFSCREEN_READY'` declared, ready to wire up.
|
||||
|
||||
**Pattern extraction date:** 2026-05-15
|
||||
Reference in New Issue
Block a user