diff --git a/src/offscreen/index.html b/src/offscreen/index.html
new file mode 100644
index 0000000..cfd95a0
--- /dev/null
+++ b/src/offscreen/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Mokosh Recorder
+
+
+
+
+
diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts
new file mode 100644
index 0000000..8aad94d
--- /dev/null
+++ b/src/offscreen/recorder.ts
@@ -0,0 +1,214 @@
+// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
+// offscreen/index.ts and the inline string in vite.config.ts:13-216).
+// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
+// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
+// track.onended cleanup. Port keepalive + OFFSCREEN_READY handshake are
+// wired by Plan 04 in the matching wave-1 lane.
+
+import { OffscreenLogger } from '../shared/logger';
+import type { Message, VideoChunk } from '../shared/types';
+
+// ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
+export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд
+const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
+const VIDEO_BITRATE = 400_000; // CON-video-codec
+const TIMESLICE_MS = 2_000; // SPEC §4.1
+const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
+
+const logger = new OffscreenLogger('Recorder');
+
+// ─── Состояние модуля (module-level, NOT inside startRecording — fixes audit P0 #1 shadow) ───
+let videoRecorder: MediaRecorder | null = null; // renamed from 'mediaRecorder' to prevent shadowing
+let mediaStream: MediaStream | null = null;
+let videoBuffer: VideoChunk[] = [];
+let firstChunkSaved = false;
+let keepalivePort: chrome.runtime.Port | null = null; // Plan 04 fills the lifecycle
+
+// ─── Кольцевой буфер (pure functions — testable in Node) ────────────────
+
+export function addChunk(blob: Blob, timestamp: number): void {
+ const chunk: VideoChunk = {
+ data: blob,
+ timestamp,
+ isFirst: !firstChunkSaved,
+ };
+ if (!firstChunkSaved) {
+ firstChunkSaved = true;
+ logger.log('First chunk (WebM header) pinned, size:', blob.size);
+ }
+ videoBuffer.push(chunk);
+ trimAged(timestamp);
+}
+
+export function trimAged(now: number): void {
+ const cutoff = now - VIDEO_BUFFER_DURATION_MS;
+ videoBuffer = videoBuffer.filter((chunk) => {
+ if (chunk.isFirst) {
+ return true;
+ }
+ return chunk.timestamp >= cutoff;
+ });
+}
+
+export function getBuffer(): VideoChunk[] {
+ return videoBuffer;
+}
+
+export function resetBuffer(): void {
+ videoBuffer = [];
+ firstChunkSaved = false;
+}
+
+// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
+
+export function assertCodecSupported(): void {
+ const supported =
+ typeof MediaRecorder !== 'undefined' &&
+ typeof MediaRecorder.isTypeSupported === 'function' &&
+ MediaRecorder.isTypeSupported(VIDEO_MIME);
+ if (!supported) {
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
+ const errMessage = `vp9 unsupported. UA=${ua}`;
+ chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
+ throw new Error(errMessage);
+ }
+}
+
+// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
+
+async function startRecording(): Promise {
+ if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
+ logger.warn('Recording already active; ignoring duplicate START_RECORDING');
+ return;
+ }
+ try {
+ assertCodecSupported(); // throws if vp9 missing — no fallback
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: true,
+ audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
+ });
+ mediaStream = stream;
+ videoRecorder = new MediaRecorder(stream, {
+ mimeType: VIDEO_MIME,
+ videoBitsPerSecond: VIDEO_BITRATE,
+ });
+ videoRecorder.ondataavailable = onDataAvailable;
+ videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
+ // Track end detection — RESEARCH.md Example F. Attach to ALL tracks
+ // (Pitfall 6) so an audio-track edge case won't silently desync.
+ stream.getTracks().forEach((track) => {
+ track.addEventListener('ended', onUserStoppedSharing, { once: true });
+ });
+ videoRecorder.start(TIMESLICE_MS);
+ logger.log('Recording started, mime:', VIDEO_MIME, 'timeslice:', TIMESLICE_MS, 'ms');
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ logger.error('startRecording failed:', msg);
+ chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
+ throw error;
+ }
+}
+
+function onDataAvailable(event: BlobEvent): void {
+ if (!event.data || event.data.size === 0) {
+ return;
+ }
+ addChunk(event.data, Date.now());
+}
+
+function onUserStoppedSharing(): void {
+ logger.log('Operator stopped sharing — clearing buffer');
+ resetBuffer();
+ if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
+ videoRecorder.stop();
+ }
+ if (mediaStream !== null) {
+ mediaStream.getTracks().forEach((t) => t.stop());
+ mediaStream = null;
+ }
+ videoRecorder = null;
+ chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
+}
+
+function stopRecording(): void {
+ if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
+ videoRecorder.stop();
+ logger.log('Recording stopped manually');
+ }
+}
+
+// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
+// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
+// codec tests can mock `chrome.runtime` without crashing on module import.
+// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
+// loop without rewriting this module's exports.
+
+function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
+ return sender?.id === chrome.runtime.id;
+}
+
+// Бутстрап (Plan 03 stub; Plan 04 wires the full handshake + reconnect).
+// Guarded so the pure ring-buffer + codec-check tests can import the module
+// without providing a chrome stub. Production runs always have chrome.runtime
+// available, so the guard is a no-op there.
+function bootstrap(): void {
+ if (typeof chrome === 'undefined' || !chrome.runtime) {
+ return;
+ }
+ if (typeof chrome.runtime.onMessage?.addListener === 'function') {
+ chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
+ if (!isFromOwnExtension(sender)) {
+ return false;
+ }
+ switch (message.type) {
+ case 'START_RECORDING':
+ startRecording()
+ .then(() => sendResponse({ ok: true }))
+ .catch((err) => sendResponse({ ok: false, error: String(err) }));
+ return true;
+ case 'STOP_RECORDING':
+ stopRecording();
+ sendResponse({ ok: true });
+ return false;
+ default:
+ return false;
+ }
+ });
+ }
+ // Plan 04 will replace this stub with the full reconnect + ping loop.
+ if (typeof chrome.runtime.connect === 'function') {
+ keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
+ }
+ if (typeof chrome.runtime.sendMessage === 'function') {
+ chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
+ }
+}
+
+bootstrap();
+
+// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
+// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
+void keepalivePort;
+
+// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
+// Activated only if the Phase 07 ffprobe gate fails on the simpler
+// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
+//
+// FALLBACK D-13: restart-segments
+// const SEGMENT_MS = 10_000;
+// const MAX_SEGMENTS = 3;
+// let segments: Blob[] = [];
+// let currentChunks: Blob[] = [];
+// function rotateSegment(): void { videoRecorder?.stop(); /* onstop assembles a segment */ }
+// function onSegmentStopped(): void {
+// segments.push(new Blob(currentChunks, { type: 'video/webm' }));
+// if (segments.length > MAX_SEGMENTS) segments.shift();
+// currentChunks = [];
+// if (mediaStream) {
+// videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE });
+// videoRecorder.ondataavailable = (e) => { if (e.data.size > 0) currentChunks.push(e.data); };
+// videoRecorder.onstop = onSegmentStopped;
+// videoRecorder.start();
+// setTimeout(rotateSegment, SEGMENT_MS);
+// }
+// }
diff --git a/src/shared/logger.ts b/src/shared/logger.ts
index e7da2c2..2d73483 100644
--- a/src/shared/logger.ts
+++ b/src/shared/logger.ts
@@ -48,4 +48,32 @@ export class ContentLogger {
error(...args: any[]) {
this.logWithLevel('error', ...args);
}
+}
+
+// Логгер для Offscreen Document
+// Note: uses `...args: unknown[]` (strict-mode hygiene) vs Logger / ContentLogger
+// which retain the legacy `...args: any[]` — see plan 01-03 style_divergence_note.
+export class OffscreenLogger {
+ private context: string;
+
+ constructor(context: string) {
+ this.context = context;
+ }
+
+ private logWithLevel(level: 'log' | 'warn' | 'error', ...args: unknown[]) {
+ const timestamp = new Date().toISOString();
+ console[level](`[OS:${this.context}] ${timestamp}`, ...args);
+ }
+
+ log(...args: unknown[]) {
+ this.logWithLevel('log', ...args);
+ }
+
+ warn(...args: unknown[]) {
+ this.logWithLevel('warn', ...args);
+ }
+
+ error(...args: unknown[]) {
+ this.logWithLevel('error', ...args);
+ }
}
\ No newline at end of file