Production changes (src/background/index.ts):
- pendingDownloadUrlResolvers Map<requestId, resolver> routes DOWNLOAD_URL
responses back to the in-flight downloadArchive Promise; mirrors the
pendingBufferRequests pattern from the BUFFER round-trip so port
replacement mid-mint does not lose the response.
- pendingRevokes Map<downloadId, url> tracks (downloadId → minted blob:URL)
for the chrome.downloads.onChanged revoke dispatch.
- onConnect port message sink extended with DOWNLOAD_URL routing branch
(alongside existing PING/BUFFER routing).
- downloadArchive rewritten: encode archive via blobToBase64 → post
CREATE_DOWNLOAD_URL on videoPort → await DOWNLOAD_URL response (race
against 5s BLOB_URL_MINT_TIMEOUT_MS) → reject empty / non-blob: URLs
(T-02-02-03 mitigation) → call chrome.downloads.download → register
(downloadId, url) in pendingRevokes. NO data:URL fallback — typed
errors route through saveArchive's catch to RECORDING_ERROR.
- chrome.downloads.onChanged listener registered at module init:
on terminal state ('complete' / 'interrupted'), posts REVOKE_DOWNLOAD_URL
to videoPort and clears the pendingRevokes entry.
Deviation (Rule 3 — auto-fix blocking issue):
- Plan 02-01's test helpers in blob-url-download.test.ts +
meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts
modeled only the REQUEST_BUFFER → BUFFER round-trip, not the new
CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip Plan 02-02 introduces.
Without the test-side mint simulation, the SW's downloadArchive
times out at the offscreen mint step → chrome.downloads.download
never called → ALL existing meta.json tests timeout.
- Each helper extended with a tryFireDownloadUrl block that decodes
the CREATE_DOWNLOAD_URL.dataBase64, mints a Node-native blob:URL via
URL.createObjectURL, captures the archive bytes for downstream
JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL.
Test 3 (revoke lifecycle) additionally shims port.postMessage to
call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the
test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl.
- Pre-existing Plan-02-02-era TODO comments in both test files
explicitly anticipated this extension ("Plan 02-03 implementer will
likely need a different helper, e.g. spy on URL.createObjectURL").
Verification (full §verification block from plan):
- npx tsc --noEmit: clean
- npm run build: clean
- npx vitest run tests/background/blob-url-download.test.ts: 3/3 GREEN (was 3 RED)
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN
- npm test full suite: 163 passed / 8 failed (was 159 passed / 12 failed);
net delta +4 GREEN = 3 RED→GREEN flips + 1 ffprobe-flaky pass. 8 remaining
RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3
strict-meta-json-validation RED tests).
- grep -c "data:application/zip;base64," src/background/index.ts: 0 (gone)
- grep -c "blob:" src/background/index.ts: 8 (new pipeline)
- grep -c "chrome.downloads.onChanged" src/background/index.ts: 5 (listener wired)
- dist/ post-build: 0 "data:application/zip;base64," matches; 1 file with
"chrome.downloads.onChanged" (the SW chunk).
1235 lines
53 KiB
TypeScript
1235 lines
53 KiB
TypeScript
import { Logger } from '../shared/logger';
|
||
import { base64ToBlob, blobToBase64 } from '../shared/binary';
|
||
import type {
|
||
Message,
|
||
TransferredVideoSegment,
|
||
VideoSegment,
|
||
SessionMetadata,
|
||
VideoBufferResponse
|
||
} from '../shared/types';
|
||
import { remuxSegments } from './webm-remux';
|
||
import JSZip from 'jszip';
|
||
|
||
// ─── Plan 01-13: NO SW-side test hook gate (Approach B) ──────────────
|
||
// Plan 01-11 originally planned a gated `await import('../test-hooks/sw-hooks')`
|
||
// here to instrument chrome.* handler registrations from the SW side.
|
||
// EMPIRICAL FALSIFICATION (01-11 spike, see 01-11-SUMMARY.md): MV3
|
||
// service workers BLOCK dynamic import (Chromium es_modules.md:
|
||
// "Dynamic import is currently blocked in Service Workers, but it
|
||
// will change in the future."; w3c/webextensions#212 still open as of
|
||
// May 2026). The `await import(...)` never resolved → the SW silently
|
||
// died at top-of-module init → production chrome.* listeners never
|
||
// registered → the entire service worker was non-functional.
|
||
//
|
||
// Plan 01-13 (Approach B) DROPS the SW-side hook entirely:
|
||
// - The OFFSCREEN-side test hook (src/test-hooks/offscreen-hooks.ts)
|
||
// still works because offscreen IS a DOM document where dynamic
|
||
// import is supported. See src/offscreen/recorder.ts top-of-module
|
||
// for the surviving `if (__MOKOSH_UAT__) { await import(...) }`.
|
||
// - SW-side state (badge text, popup, isRecording proxy) is queried
|
||
// by the extension-internal harness page via chrome.action.*
|
||
// directly (the page has full chrome.* privilege, unlike the
|
||
// restricted CDP `sw.evaluate` surface that 01-11 tried first).
|
||
// - Architectural rationale + full falsification table:
|
||
// .planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md
|
||
//
|
||
// SECURITY INVARIANT (T-1-11-01, retained): production bundle MUST
|
||
// contain NO test-hook surface strings. The Tier-1 grep gate
|
||
// `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces
|
||
// post-build. In Approach B this invariant is trivially satisfied for
|
||
// the SW chunk (no hook imports here at all); the gate's enduring
|
||
// value is catching regressions in the offscreen chunk's
|
||
// `__MOKOSH_UAT__` gate.
|
||
|
||
// Default MIME applied when a wire chunk somehow lacks a type
|
||
// field (defense-in-depth: in normal operation the offscreen recorder
|
||
// always populates it from chunk.data.type). Matches D-20 strict codec.
|
||
const VIDEO_MIME_FALLBACK = 'video/webm;codecs=vp9';
|
||
|
||
const logger = new Logger('Main');
|
||
|
||
// Option C: a typed error so saveArchive can distinguish the empty-video
|
||
// failure (operator-facing — shows a clear RECORDING_ERROR popup) from
|
||
// generic createArchive failures (zip/manifest/etc.). The 'empty-video-
|
||
// buffer' code joins the CaptureErrorCode union surfaced by the
|
||
// offscreen recorder (see src/offscreen/recorder.ts: classifyCaptureError)
|
||
// — same operator-facing vocabulary, different production point.
|
||
export class EmptyVideoBufferError extends Error {
|
||
readonly code = 'empty-video-buffer' as const;
|
||
constructor(detail: string) {
|
||
super(`empty-video-buffer: ${detail}`);
|
||
this.name = 'EmptyVideoBufferError';
|
||
}
|
||
}
|
||
|
||
// Состояние
|
||
// Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально:
|
||
// при экспорте он спрашивает буфер у offscreen через long-lived port (D-17).
|
||
let isRecording = false;
|
||
let offscreenCreated = false;
|
||
let lastScreenshotTime = 0;
|
||
let cachedScreenshot: Blob | null = null;
|
||
|
||
// ─── Plan 01-09 badge palette + notification constants ───────────────
|
||
// Project naming standard: SCREAMING_SNAKE for true constants. These
|
||
// drive the operator-facing badge state machine + notification flow.
|
||
//
|
||
// Plan 01-12 Wave 4: BADGE_REC_COLOR updated from #00C853 (material
|
||
// green) to #b2543d (= --mks-madder-600 per D-04 loom palette per
|
||
// RESEARCH §10 Open Question A7). OFF + ERROR colors retained as
|
||
// engineering choices (no loom-palette token for material-red /
|
||
// material-amber-700 equivalents); document choice inline.
|
||
//
|
||
// Plan 01-12 Wave 4: BADGE_*_TITLE constants kept as FALLBACKS for the
|
||
// chrome.i18n.getMessage reads at the setBadgeState call sites. Unit
|
||
// tests that don't stub chrome.i18n degrade to these literals rather
|
||
// than empty strings.
|
||
const BADGE_REC_COLOR = '#b2543d'; // --mks-madder-600 per D-04 loom palette (Plan 01-12)
|
||
const BADGE_OFF_COLOR = '#D32F2F'; // material red (no loom-palette token for OFF)
|
||
const BADGE_ERROR_COLOR = '#F9A825'; // material amber-700 (no loom-palette token for ERR)
|
||
const BADGE_REC_TEXT = 'REC';
|
||
const BADGE_OFF_TEXT = '';
|
||
const BADGE_ERROR_TEXT = 'ERR';
|
||
const BADGE_REC_TITLE_FALLBACK = 'Recording — last 30 s buffered. Click to save.';
|
||
const BADGE_OFF_TITLE_FALLBACK = 'Not recording. Click to start.';
|
||
const BADGE_ERROR_TITLE_FALLBACK = 'Recording error. Click to try again.';
|
||
const NOTIFICATION_ICON_PATH = 'icons/icon128.png';
|
||
const NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-';
|
||
const NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-';
|
||
const POPUP_HTML_PATH = 'src/popup/index.html';
|
||
|
||
// ─── Plan 01-10 onboarding constants (D-17-onboarding) ───────────────
|
||
// Project naming standard: SCREAMING_SNAKE for true constants. These
|
||
// drive the first-install welcome-tab flow (chrome.runtime.onInstalled
|
||
// → openWelcomeIfFirstInstall; flag persisted via chrome.storage.local).
|
||
// The 'onboarding-completed' key is the storage-schema identifier
|
||
// preserved across SW respawns (and surveyed by the unit test in
|
||
// tests/background/onboarding.test.ts Test A).
|
||
const ONBOARDING_FLAG = 'onboarding-completed';
|
||
const ONBOARDING_INSTALLED_AT = 'installed-at';
|
||
const WELCOME_PATH = 'src/welcome/welcome.html';
|
||
|
||
// Plan 01-12 Wave 4: operator-facing copy fallbacks for the notification
|
||
// title (extName) + the two notification messages. Same `|| fallback`
|
||
// pattern as the popup — unit-test contexts without chrome.i18n stub
|
||
// degrade to these literals.
|
||
const NOTIF_EXTNAME_FALLBACK = 'Mokosh';
|
||
// Plan 01-09 amendment 2026-05-20: `notifStartup` key was split into
|
||
// `notifStartupCta` (used here by the onStartup handler — CTA-with-gesture
|
||
// pre-recording invite) and `notifRecordingStarted` (reserved for future
|
||
// post-manual-start confirmation flows). The original text falsely implied
|
||
// recording had auto-started; the new text invites the operator to start
|
||
// a recording. See .planning/debug/resolved/01-09-startup-notification-misleading-text.md.
|
||
const NOTIF_STARTUP_CTA_FALLBACK = 'Mokosh ready. Click to start a recording.';
|
||
const NOTIF_RECOVERY_FALLBACK = 'Recording stopped. Click here to start a new session.';
|
||
|
||
/**
|
||
* Safe wrapper around chrome.i18n.getMessage with explicit fallback.
|
||
* Returns the fallback when chrome.i18n is undefined (unit-test contexts
|
||
* without a stub) OR when the key is missing in the resolved locale.
|
||
*
|
||
* chrome.i18n.getMessage is SYNCHRONOUS per Chrome docs — no Promise.
|
||
* Returns '' for missing keys, hence the explicit length check.
|
||
*/
|
||
function i18nMessage(key: string, fallback: string): string {
|
||
const text = chrome?.i18n?.getMessage?.(key) ?? '';
|
||
return text.length > 0 ? text : fallback;
|
||
}
|
||
|
||
// ─── Plan 01-09 badge state machine + mode helpers ───────────────────
|
||
// 3-state machine: REC (during recording), OFF (idle), ERROR (after
|
||
// RECORDING_ERROR). Each setBadgeState call is best-effort: chrome.action
|
||
// methods may be undefined in unit-test contexts that did not stub the
|
||
// whole surface — wrap each call in try/catch (defense in depth).
|
||
type BadgeState = 'REC' | 'OFF' | 'ERROR';
|
||
|
||
function setBadgeState(state: BadgeState): void {
|
||
let text: string;
|
||
let color: string;
|
||
let title: string;
|
||
if (state === 'REC') {
|
||
text = BADGE_REC_TEXT;
|
||
color = BADGE_REC_COLOR;
|
||
title = i18nMessage('tooltipRecPrefix', BADGE_REC_TITLE_FALLBACK);
|
||
} else if (state === 'OFF') {
|
||
text = BADGE_OFF_TEXT;
|
||
color = BADGE_OFF_COLOR;
|
||
title = i18nMessage('tooltipOff', BADGE_OFF_TITLE_FALLBACK);
|
||
} else {
|
||
text = BADGE_ERROR_TEXT;
|
||
color = BADGE_ERROR_COLOR;
|
||
title = i18nMessage('tooltipErr', BADGE_ERROR_TITLE_FALLBACK);
|
||
}
|
||
try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); }
|
||
try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); }
|
||
try { chrome.action.setTitle({ title }); } catch (e) { logger.warn('setTitle failed:', e); }
|
||
}
|
||
|
||
// In idle mode the popup is empty ('' string) — this is what makes
|
||
// chrome.action.onClicked fire on toolbar clicks (per the MV3 docs:
|
||
// the click event only fires when no default_popup is set). In REC/
|
||
// ERROR modes the popup html is set back so toolbar clicks open the
|
||
// SAVE-only popup.
|
||
function setIdleMode(): void {
|
||
try { chrome.action.setPopup({ popup: '' }); } catch (e) { logger.warn('setPopup OFF failed:', e); }
|
||
setBadgeState('OFF');
|
||
}
|
||
|
||
function setRecordingMode(): void {
|
||
try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup REC failed:', e); }
|
||
setBadgeState('REC');
|
||
}
|
||
|
||
function setErrorMode(): void {
|
||
// ERROR mode keeps the popup accessible so the operator can still
|
||
// attempt SAVE if any data is buffered + see the error copy.
|
||
try { chrome.action.setPopup({ popup: POPUP_HTML_PATH }); } catch (e) { logger.warn('setPopup ERROR failed:', e); }
|
||
setBadgeState('ERROR');
|
||
}
|
||
// 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
|
||
// Для архивации получаем его оттуда
|
||
|
||
// Ring-buffer helpers (header-pin + age-trim) and the buffer duration
|
||
// constant were removed in Plan 01-03 — the buffer now lives in
|
||
// src/offscreen/recorder.ts per D-16. Plan 05 completes the SW shrink:
|
||
// see deletions below.
|
||
|
||
// Создание offscreen документа
|
||
async function ensureOffscreen() {
|
||
if (offscreenCreated) {
|
||
logger.log('Offscreen already created');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const url = chrome.runtime.getURL('src/offscreen/index.html');
|
||
logger.log('Creating offscreen document at:', url);
|
||
|
||
await chrome.offscreen.createDocument({
|
||
url: url,
|
||
reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
|
||
justification: 'Continuous screen recording for operator session diagnostics'
|
||
});
|
||
offscreenCreated = true;
|
||
logger.log('Offscreen document created successfully');
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : String(error);
|
||
if (msg.includes('already exists')) {
|
||
offscreenCreated = true;
|
||
logger.log('Offscreen document already exists');
|
||
} else {
|
||
logger.error('Failed to create offscreen document:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open the welcome page on first install (Plan 01-10 D-17-onboarding).
|
||
*
|
||
* The 'D-17-onboarding' suffix disambiguates from D-17-port-lifecycle
|
||
* per CONTEXT.md lines 540-545. Trigger conditions (all must hold):
|
||
* - details.reason === 'install' (NOT 'update' / 'chrome_update' /
|
||
* 'shared_module_update');
|
||
* - chrome.storage.local key 'onboarding-completed' NOT === true.
|
||
*
|
||
* Side effects on first install:
|
||
* - chrome.tabs.create({url: chrome.runtime.getURL('src/welcome/welcome.html')})
|
||
* - chrome.storage.local.set({'onboarding-completed': true,
|
||
* 'installed-at': Date.now()})
|
||
*
|
||
* Failure mode: any thrown chrome.* call is caught + logged via
|
||
* logger.warn. The welcome tab failing is NOT fatal — the toolbar
|
||
* onClicked path (D-16-toolbar) remains the operator's start path and
|
||
* is independent of the onboarding flow.
|
||
*
|
||
* Architectural note: the fetch of 'onboarding-completed' uses the
|
||
* EXACT string key (no array form) so the unit-test contract in
|
||
* tests/background/onboarding.test.ts Test A's assertion
|
||
* "saw chrome.storage.local.get('onboarding-completed')" holds. The
|
||
* storage-schema cross-version-compat pin (I-02 lesson preserved from
|
||
* prior plan draft) lives in that test.
|
||
*/
|
||
async function openWelcomeIfFirstInstall(
|
||
details: chrome.runtime.InstalledDetails,
|
||
): Promise<void> {
|
||
if (details.reason !== 'install') {
|
||
return;
|
||
}
|
||
try {
|
||
const stored = await chrome.storage.local.get(ONBOARDING_FLAG);
|
||
if (stored[ONBOARDING_FLAG] === true) {
|
||
logger.log('Onboarding already completed; skipping welcome tab.');
|
||
return;
|
||
}
|
||
const url = chrome.runtime.getURL(WELCOME_PATH);
|
||
await chrome.tabs.create({ url });
|
||
await chrome.storage.local.set({
|
||
[ONBOARDING_FLAG]: true,
|
||
[ONBOARDING_INSTALLED_AT]: Date.now(),
|
||
});
|
||
logger.log(
|
||
'Welcome tab opened (D-17-onboarding); onboarding flag set.',
|
||
);
|
||
} catch (err) {
|
||
logger.warn('openWelcomeIfFirstInstall failed:', err);
|
||
}
|
||
}
|
||
|
||
// Outer-bound buffer fetch budget. Larger than the legacy
|
||
// BUFFER_FETCH_TIMEOUT_MS (was 2 s; per-port-attempt) because the new
|
||
// architecture covers MULTIPLE port-replacement retries inside one outer
|
||
// budget. 10 s is generous: the inner per-port encode round-trip is
|
||
// still ~100-200 ms; the extra headroom covers up to ~50 reconnect
|
||
// cycles before the operator-visible error surfaces.
|
||
const BUFFER_FETCH_TIMEOUT_MS = 10_000;
|
||
|
||
// ─── D-P2-01 Blob URL mint/revoke lifecycle state (P0-6 fix) ──────────
|
||
// pendingDownloadUrlResolvers maps a per-mint requestId to the resolver
|
||
// of the in-flight downloadArchive's Promise. Mirrors the pendingBuffer
|
||
// Requests pattern: the onConnect-level port message sink routes the
|
||
// DOWNLOAD_URL response by id so port replacement mid-mint does not
|
||
// drop the response.
|
||
const pendingDownloadUrlResolvers: Map<string, (url: string) => void> = new Map();
|
||
// pendingRevokes maps a chrome.downloads downloadId to the minted blob:URL
|
||
// awaiting revocation. Populated when chrome.downloads.download resolves
|
||
// with its downloadId; drained by the chrome.downloads.onChanged listener
|
||
// when the corresponding state transitions to 'complete' or 'interrupted'.
|
||
// The Map is bounded by O(saves-per-session) which is operationally <100
|
||
// (T-02-02-02 threat-register entry); SW idle teardown clears it entirely.
|
||
const pendingRevokes: Map<number, string> = new Map();
|
||
// Outer-bound budget for the offscreen mint round-trip. The bridge is
|
||
// purely local (no network), so 5 s is generous — the inner encode +
|
||
// post round-trip is typically <100 ms for archives <10 MB.
|
||
const BLOB_URL_MINT_TIMEOUT_MS = 5_000;
|
||
|
||
// Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The
|
||
// onConnect-level message sink routes BUFFER -> resolve by id, so port
|
||
// replacement (videoPort changes mid-request) does NOT lose the
|
||
// response — the offscreen posts BUFFER on the CURRENT port (whichever
|
||
// that is) and our sink picks it up regardless of which Port object it
|
||
// arrives on.
|
||
interface PendingBufferRequest {
|
||
resolve: (resp: VideoBufferResponse) => void;
|
||
hardTimer: ReturnType<typeof setTimeout>;
|
||
requestId: string;
|
||
}
|
||
const pendingBufferRequests: Map<string, PendingBufferRequest> = new Map();
|
||
|
||
// Generate a per-request correlation id. Uses crypto.randomUUID when
|
||
// available (Chrome 92+ in SW context per
|
||
// https://developer.chrome.com/docs/extensions/reference/api/runtime#secure_origin),
|
||
// with a Math.random fallback that's still unique enough for in-process
|
||
// routing — collisions would require simultaneous in-flight requests
|
||
// within the same millisecond on the same SW lifetime, vanishingly
|
||
// improbable for this UI flow.
|
||
function generateRequestId(): string {
|
||
if (
|
||
typeof crypto !== 'undefined' &&
|
||
typeof crypto.randomUUID === 'function'
|
||
) {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
// Decodes a BUFFER message's wire-format segments into VideoSegment[].
|
||
// Extracted from the legacy inline handler so the onConnect sink can
|
||
// resolve a pending request without duplicating the decode logic.
|
||
function decodeBufferSegments(
|
||
wireSegments: TransferredVideoSegment[],
|
||
): VideoSegment[] {
|
||
// WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty
|
||
// wire.data would decode to a zero-byte Blob; the remux pipeline
|
||
// (src/background/webm-remux.ts, Plan 01-08 D-14-remux) would try
|
||
// to parse it via ts-ebml and either fail loudly or emit zero frames,
|
||
// either way wasting a parse cycle. Two passes (filter -> decode ->
|
||
// filter-non-empty) keep the iteration semantics declarative.
|
||
const nonEmptyWires = wireSegments.filter((wire) => {
|
||
const isEmpty = !wire.data || wire.data.length === 0;
|
||
if (isEmpty) {
|
||
logger.warn(
|
||
'Skipping empty wire segment (zero-length base64)',
|
||
'timestamp:', wire.timestamp,
|
||
);
|
||
}
|
||
return !isEmpty;
|
||
});
|
||
const segments: VideoSegment[] = nonEmptyWires.flatMap((wire) => {
|
||
try {
|
||
const blob = base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK);
|
||
if (blob.size === 0) {
|
||
logger.warn(
|
||
'Skipping segment that decoded to zero bytes',
|
||
'timestamp:', wire.timestamp,
|
||
);
|
||
return [];
|
||
}
|
||
return [{ data: blob, timestamp: wire.timestamp }];
|
||
} catch (err) {
|
||
logger.warn(
|
||
'base64ToBlob failed; skipping segment',
|
||
'timestamp:', wire.timestamp,
|
||
'error:', err,
|
||
);
|
||
return [];
|
||
}
|
||
});
|
||
return segments;
|
||
}
|
||
|
||
// 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/PONG health probe — Option C) — Chrome 110+
|
||
// resets the SW idle timer on every port message, AND the PONG reply
|
||
// closes the offscreen's health-probe loop; (b) on-demand REQUEST_BUFFER
|
||
// round-trip during SAVE_ARCHIVE, routed by requestId so port
|
||
// replacement mid-request does not drop the response.
|
||
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;
|
||
// CR-02 fix: install a permanent onMessage sink on every accepted port.
|
||
// Chrome 110+ resets the SW idle-timer on any inbound port message, BUT
|
||
// in the field, behaviour has been observed to differ subtly when no
|
||
// listener is attached at all — Chrome may skip the idle-timer reset
|
||
// path entirely on unrouted messages.
|
||
//
|
||
// Option C: this sink ALSO routes BUFFER responses to the matching
|
||
// pending request by requestId (the per-request listener pattern is
|
||
// gone — it could not handle port replacement). And it echoes PONG on
|
||
// every PING so the offscreen's health probe sees life.
|
||
port.onMessage.addListener((msg) => {
|
||
if (typeof msg !== 'object' || msg === null) {
|
||
return;
|
||
}
|
||
const type = (msg as { type?: unknown }).type;
|
||
if (type === 'PING') {
|
||
// Health-probe echo (Option C). Wrapped in try/catch because the
|
||
// port may have been disconnected between the inbound PING and
|
||
// our response — silently drop in that race window.
|
||
try {
|
||
port.postMessage({ type: 'PONG' });
|
||
} catch (err) {
|
||
logger.warn('PONG postMessage failed (port closed):', err);
|
||
}
|
||
return;
|
||
}
|
||
if (type === 'BUFFER') {
|
||
const requestId = (msg as { requestId?: unknown }).requestId;
|
||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||
// Defense-in-depth: BUFFER without a valid requestId is invalid
|
||
// under the Option C protocol — drop with a warn. (Legacy
|
||
// offscreen code that didn't carry requestId is gone.)
|
||
logger.warn('BUFFER without a valid requestId — dropping');
|
||
return;
|
||
}
|
||
const pending = pendingBufferRequests.get(requestId);
|
||
if (pending === undefined) {
|
||
// Stale BUFFER (request already resolved or timed out). Silently
|
||
// drop — this is the no-cross-talk property the request-id
|
||
// routing guarantees.
|
||
return;
|
||
}
|
||
const wireSegments =
|
||
(msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
|
||
const segments = decodeBufferSegments(wireSegments);
|
||
clearTimeout(pending.hardTimer);
|
||
pendingBufferRequests.delete(requestId);
|
||
pending.resolve({ segments });
|
||
return;
|
||
}
|
||
if (type === 'DOWNLOAD_URL') {
|
||
// D-P2-01: route the offscreen-minted blob:URL back to the
|
||
// in-flight downloadArchive Promise. Mirrors the BUFFER routing
|
||
// above — keyed by requestId so concurrent mints (theoretically
|
||
// possible across two SAVE flows) cannot cross-talk.
|
||
const requestId = (msg as { requestId?: unknown }).requestId;
|
||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||
logger.warn('DOWNLOAD_URL without a valid requestId — dropping');
|
||
return;
|
||
}
|
||
const resolver = pendingDownloadUrlResolvers.get(requestId);
|
||
if (resolver === undefined) {
|
||
// Stale DOWNLOAD_URL (mint already timed out). Silently drop.
|
||
return;
|
||
}
|
||
pendingDownloadUrlResolvers.delete(requestId);
|
||
const url = (msg as { url?: unknown }).url;
|
||
resolver(typeof url === 'string' ? url : '');
|
||
return;
|
||
}
|
||
// Unknown traffic — drop silently (T-1-04 defense-in-depth).
|
||
});
|
||
port.onDisconnect.addListener(() => {
|
||
logger.log('Offscreen port disconnected; offscreen will reconnect');
|
||
if (videoPort === port) {
|
||
videoPort = null;
|
||
}
|
||
});
|
||
// If there are pending REQUEST_BUFFER requests at the moment this port
|
||
// connects, re-issue them on the fresh port with the SAME requestId.
|
||
// This is the architectural mechanism that retires the H2 silent-drop
|
||
// class — the BUFFER reaches the SW regardless of port-replacement
|
||
// timing. (Note: the FIRST onConnect has pendingBufferRequests.size
|
||
// === 0 so this branch correctly does nothing on bootstrap.)
|
||
if (pendingBufferRequests.size > 0) {
|
||
for (const pending of pendingBufferRequests.values()) {
|
||
try {
|
||
port.postMessage({
|
||
type: 'REQUEST_BUFFER',
|
||
requestId: pending.requestId,
|
||
});
|
||
} catch (err) {
|
||
// The fresh port disconnected synchronously — the outer hard
|
||
// timer will fire and surface the error.
|
||
logger.warn('REQUEST_BUFFER retry post failed:', err);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
|
||
if (videoPort === null) {
|
||
logger.warn('No offscreen port available; returning empty buffer');
|
||
return { segments: [] };
|
||
}
|
||
const requestId = generateRequestId();
|
||
return new Promise<VideoBufferResponse>((resolve) => {
|
||
const hardTimer = setTimeout(() => {
|
||
pendingBufferRequests.delete(requestId);
|
||
// Outer hard-timeout: covers EVERY retry across port replacements
|
||
// (the legacy per-port BUFFER_FETCH_TIMEOUT_MS was 2 s per
|
||
// attempt — too tight to retry across a reconnect). 10 s is
|
||
// generous; the inner round-trip is still ~100-200 ms.
|
||
logger.warn(
|
||
`Buffer fetch outer timeout (${BUFFER_FETCH_TIMEOUT_MS} ms) — no BUFFER for requestId ${requestId}`,
|
||
);
|
||
resolve({ segments: [] });
|
||
}, BUFFER_FETCH_TIMEOUT_MS);
|
||
pendingBufferRequests.set(requestId, {
|
||
resolve,
|
||
hardTimer,
|
||
requestId,
|
||
});
|
||
try {
|
||
videoPort?.postMessage({ type: 'REQUEST_BUFFER', requestId });
|
||
} catch (err) {
|
||
// The current port disconnected synchronously. Don't resolve here
|
||
// — the offscreen's reconnect will fire a fresh onConnect, the
|
||
// sink will detect the in-flight request, and the retry path will
|
||
// re-post REQUEST_BUFFER on the new port.
|
||
logger.warn('Initial REQUEST_BUFFER post failed (port disconnected):', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Начало записи видео
|
||
async function startVideoCapture() {
|
||
if (isRecording) {
|
||
logger.log('Video recording already active');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Plan 01-09 D-01 cleanup gap (debug session
|
||
// `01-09-notification-start-no-active-tab`, 2026-05-20):
|
||
// The legacy chrome.tabs.query({ active: true, currentWindow: true })
|
||
// here was load-bearing in the pre-D-01 chrome.tabCapture era but
|
||
// is functionally dead post-D-01 — capture is whole-desktop via
|
||
// getDisplayMedia in offscreen and the SW-side start path needs
|
||
// no tab reference. The query also failed for chrome.notifications
|
||
// .onClicked callers (no activeTab grant + no `tabs` permission →
|
||
// tab.url undefined → "No active tab found" throw) so the onStartup
|
||
// notification CTA was silently broken. captureScreenshot +
|
||
// saveArchive retain their own genuine tab queries (tab.windowId
|
||
// for captureVisibleTab, tab.id for content-script sendMessage).
|
||
logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)');
|
||
|
||
// Создаём offscreen документ (с reason из D-02)
|
||
await ensureOffscreen();
|
||
// Ждём, пока offscreen зарегистрирует свой onMessage listener
|
||
// (RESEARCH.md Pattern 4). Иначе гонка: START_RECORDING летит раньше,
|
||
// чем offscreen готов его принять, и Chrome бросает "Receiving end
|
||
// does not exist".
|
||
await offscreenReady;
|
||
|
||
// Просим offscreen запустить запись — getDisplayMedia вызывается там
|
||
// (D-01: больше нет SW-side stream-id юзаства).
|
||
logger.log('Sending START_RECORDING to offscreen...');
|
||
try {
|
||
await chrome.runtime.sendMessage({
|
||
type: 'START_RECORDING'
|
||
});
|
||
logger.log('START_RECORDING sent successfully');
|
||
} catch (msgError) {
|
||
logger.error('Failed to send START_RECORDING:', msgError);
|
||
throw msgError;
|
||
}
|
||
|
||
isRecording = true;
|
||
// Plan 01-09: transition badge + popup mode to REC so the toolbar
|
||
// click opens the SAVE-only popup (not re-fires onClicked).
|
||
setRecordingMode();
|
||
logger.log('Video recording started successfully');
|
||
|
||
} catch (error) {
|
||
logger.error('Failed to start video capture:', error);
|
||
isRecording = false;
|
||
// Plan 01-09: transition to ERROR state so the badge surfaces the
|
||
// failure visually and the popup is reachable for any next-action UI.
|
||
setErrorMode();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Keepalive теперь обеспечивается long-lived портом offscreen→SW (D-17/D-18).
|
||
// Старая alarms-based реализация удалена: alarm callbacks не сбрасывали SW idle
|
||
// timer (audit P1 #8), а порт сбрасывает таймер на каждое сообщение.
|
||
|
||
// Получение видеобуфера — port-based (getVideoBufferFromOffscreen объявлен
|
||
// выше). Старый синхронный SW-локальный буфер удалён в Task 1 этого плана.
|
||
|
||
// Получение скриншота активной вкладки
|
||
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;
|
||
}
|
||
|
||
// mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux):
|
||
// see src/background/webm-remux.ts for the single-EBML remux path.
|
||
// The concat-of-self-contained-WebM-segments approach produced a
|
||
// multi-EBML-header file that mpv / Chrome / ffprobe truncated to
|
||
// the first segment's local Info.Duration ~9.94 s; the remux path
|
||
// emits a single-EBML WebM whose Info.Duration covers the full ~30 s
|
||
// timeline. D-13's recorder-side restart-segments lifecycle is
|
||
// preserved — only the merge step is replaced.
|
||
|
||
// Создание архива
|
||
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();
|
||
|
||
// Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент).
|
||
//
|
||
// Option C (debug session empty-archive-port-race): the upstream
|
||
// silent-skip branch is GONE. Shipping a zip with no video defeats the
|
||
// entire purpose of the operator-save flow (Phase 1 goal); the operator
|
||
// must see a clear failure instead of receiving a 88 KB archive with no
|
||
// last_30sec.webm. saveArchive's catch translates the throw into the
|
||
// {success: false, error: 'empty-video-buffer'} response shape the
|
||
// popup already handles via the RECORDING_ERROR surface.
|
||
if (videoBufferResponse.segments.length === 0) {
|
||
throw new EmptyVideoBufferError(
|
||
'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)',
|
||
);
|
||
}
|
||
// Plan 01-08 D-14-remux: replaces the retired mergeVideoSegments()
|
||
// file-concat with the new single-EBML WebM remux. Async now —
|
||
// ts-ebml parse + webm-muxer write happen on the SW thread.
|
||
const videoBlob = await remuxSegments(videoBufferResponse.segments);
|
||
if (videoBlob.size === 0) {
|
||
throw new EmptyVideoBufferError(
|
||
`remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`,
|
||
);
|
||
}
|
||
zip.file('video/last_30sec.webm', videoBlob);
|
||
logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`);
|
||
|
||
// Добавляем 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');
|
||
|
||
// Добавляем метаданные
|
||
// IN-01 fix: read version from manifest at runtime instead of the
|
||
// hardcoded '1.0.0'. Previously the metadata would silently lie about
|
||
// the running version once manifest.json bumps to 1.0.1+. The Chrome
|
||
// runtime API is always available in the SW context, so no fallback
|
||
// is needed.
|
||
const manifest = chrome.runtime.getManifest();
|
||
const metadata: SessionMetadata = {
|
||
timestamp: new Date().toISOString(),
|
||
url: new URL(chrome.runtime.getURL('')).origin,
|
||
userAgent: navigator.userAgent,
|
||
extensionVersion: manifest.version,
|
||
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;
|
||
}
|
||
|
||
// Скачивание архива (D-P2-01: offscreen-minted blob: URL pipeline; P0-6 fix)
|
||
//
|
||
// Architectural triangle: SW packages zip → SW asks offscreen to mint URL
|
||
// (CREATE_DOWNLOAD_URL with base64 archive bytes) → offscreen mints via
|
||
// URL.createObjectURL (SW lacks it per DEC-006) → offscreen replies
|
||
// DOWNLOAD_URL{url} → SW calls chrome.downloads.download → onChanged
|
||
// fires 'complete'/'interrupted' → SW asks offscreen to revoke
|
||
// (REVOKE_DOWNLOAD_URL). The base64 wire-format reuses the D-12
|
||
// precedent from src/shared/binary.ts.
|
||
//
|
||
// NO FALLBACK to the legacy data: URL pathway: real operator archives
|
||
// (5-10 MB) exceed Chrome's ~2 MB data-URL cap and would silently fail
|
||
// with a 'Network error' download (audit P0-6). The legacy encoding
|
||
// chain (blobToBase64 + chrome.downloads.download(`data:...`)) is gone.
|
||
// On any failure (mint timeout, empty url, port unavailable, non-blob:
|
||
// prefix) we throw a typed Error that routes through saveArchive's
|
||
// catch block into the RECORDING_ERROR channel — operator gets a
|
||
// visible failure, not a silently corrupted archive.
|
||
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)`);
|
||
|
||
if (videoPort === null) {
|
||
throw new Error('blob-url-mint-failed: offscreen port unavailable');
|
||
}
|
||
|
||
// Encode the archive bytes for the SW→offscreen wire (D-12 base64
|
||
// precedent — chrome.runtime.Port JSON-serializes payloads and Blobs
|
||
// arrive as empty objects without this transform).
|
||
const dataBase64 = await blobToBase64(archiveBlob);
|
||
const requestId = generateRequestId();
|
||
const urlPromise = new Promise<string>((resolve) => {
|
||
pendingDownloadUrlResolvers.set(requestId, resolve);
|
||
});
|
||
try {
|
||
videoPort.postMessage({
|
||
type: 'CREATE_DOWNLOAD_URL',
|
||
requestId,
|
||
dataBase64,
|
||
mimeType: 'application/zip',
|
||
});
|
||
} catch (err) {
|
||
// Port disconnected synchronously between the null-check and post.
|
||
// Clean up the resolver entry so it doesn't leak; surface a typed
|
||
// error so saveArchive's catch routes it to RECORDING_ERROR.
|
||
pendingDownloadUrlResolvers.delete(requestId);
|
||
throw new Error(`blob-url-mint-failed: CREATE_DOWNLOAD_URL post threw: ${String(err)}`);
|
||
}
|
||
const timeoutPromise = new Promise<string>((_, reject) => {
|
||
setTimeout(
|
||
() => reject(new Error('blob-url-mint-timeout')),
|
||
BLOB_URL_MINT_TIMEOUT_MS,
|
||
);
|
||
});
|
||
let url: string;
|
||
try {
|
||
url = await Promise.race([urlPromise, timeoutPromise]);
|
||
} catch (err) {
|
||
// Timeout fired before the offscreen responded. Drain the resolver
|
||
// map entry; the late-arriving DOWNLOAD_URL will be silently dropped
|
||
// by the onConnect sink (stale-id path).
|
||
pendingDownloadUrlResolvers.delete(requestId);
|
||
throw err;
|
||
}
|
||
if (url === '') {
|
||
throw new Error('blob-url-mint-failed: offscreen returned empty url');
|
||
}
|
||
// T-02-02-03 mitigation (defense-in-depth): reject any URL that does
|
||
// not have the blob: scheme. The offscreen is same-extension origin
|
||
// (sender-id-checked) and the WHATWG URL spec guarantees
|
||
// URL.createObjectURL emits blob: only — this guard catches future
|
||
// regressions / hostile-peer-on-shared-port scenarios.
|
||
if (!url.startsWith('blob:')) {
|
||
throw new Error(`blob-url-mint-failed: offscreen returned non-blob: url '${url.substring(0, 40)}...'`);
|
||
}
|
||
|
||
const downloadId = await chrome.downloads.download({
|
||
url,
|
||
filename,
|
||
saveAs: false,
|
||
});
|
||
if (typeof downloadId === 'number') {
|
||
// Track the (downloadId → url) pair so the chrome.downloads.onChanged
|
||
// listener (registered below) can dispatch the revoke when the
|
||
// download reaches a terminal state.
|
||
pendingRevokes.set(downloadId, url);
|
||
}
|
||
logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`);
|
||
}
|
||
|
||
// Сохранение архива (полный процесс)
|
||
//
|
||
// Plan 01-09 Amendment 3 (2026-05-19, debug session
|
||
// 01-09-save-does-not-stop-recording): REVERSES the prior Amendment 2
|
||
// save-stops-recording fix. SAVE_ARCHIVE creates a new zip but does
|
||
// NOT stop the recorder — per operator UX iteration preferring the
|
||
// original "continuous capture / always-on safety net" charter
|
||
// (`Тз расширение фаза1.md`). The only termination paths are:
|
||
// 1. Operator clicks Chrome's "Stop sharing" banner →
|
||
// offscreen's onUserStoppedSharing emits RECORDING_ERROR{error:
|
||
// 'user-stopped-sharing'} → SW's RECORDING_ERROR handler routes
|
||
// through setIdleMode (Bug B branch — preserved).
|
||
// 2. Browser closes (SW + offscreen torn down).
|
||
// 3. Extension uninstalled.
|
||
//
|
||
// Operator workflow: hit a bug → click SAVE (zip lands) → keep working
|
||
// → the next bug is also captured because the ring buffer keeps
|
||
// filling. Contract pinned by
|
||
// `tests/background/save-archive-does-not-stop-recording.test.ts` and
|
||
// Plan 01-13 harness assertion A14 (both inverted from the prior
|
||
// Amendment 2 contract).
|
||
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();
|
||
|
||
// Получаем видео буфер из offscreen через long-lived port (D-17)
|
||
const videoBufferResp = await getVideoBufferFromOffscreen();
|
||
logger.log(`Video buffer: ${videoBufferResp.segments.length} segments`);
|
||
|
||
// Получаем 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: { events?: unknown[]; userEvents?: unknown[] } | undefined =
|
||
await chrome.tabs.sendMessage(tab.id, { type: 'GET_RRWEB_EVENTS' });
|
||
|
||
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(
|
||
videoBufferResp,
|
||
rrwebEvents,
|
||
userEvents,
|
||
screenshot
|
||
);
|
||
|
||
// Скачиваем
|
||
await downloadArchive(archiveBlob);
|
||
|
||
logger.log('Archive save completed');
|
||
return { success: true };
|
||
|
||
} catch (error) {
|
||
logger.error('Failed to save archive:', error);
|
||
// Option C: the empty-video failure is operator-visible. Emit
|
||
// RECORDING_ERROR so the popup's existing handler can surface it
|
||
// (same channel codec-unsupported, user-cancelled, etc. ride).
|
||
// Other createArchive failures (zip libs, JSZip internals) stay
|
||
// SW-side only — they're not actionable by the operator.
|
||
if (error instanceof EmptyVideoBufferError) {
|
||
try {
|
||
chrome.runtime.sendMessage({
|
||
type: 'RECORDING_ERROR',
|
||
error: error.code,
|
||
});
|
||
} catch (sendErr) {
|
||
// Best-effort notification — if the popup is closed we have
|
||
// nothing else to do.
|
||
logger.warn('Failed to broadcast RECORDING_ERROR:', sendErr);
|
||
}
|
||
}
|
||
return { success: false, error };
|
||
}
|
||
// Plan 01-09 Amendment 3 (2026-05-19): NO `finally` block here. The
|
||
// prior Amendment 2 finally block (STOP_RECORDING dispatch + isRecording=false
|
||
// + setIdleMode) is REMOVED. SAVE_ARCHIVE creates a zip and returns;
|
||
// the recorder and state machine stay in REC. See the function-level
|
||
// docstring above for the full charter rationale.
|
||
}
|
||
|
||
// checkPermissions / requestPermissions удалены: старая permission
|
||
// больше не нужна (D-A6 — заменена на desktopCapture в manifest), а
|
||
// getDisplayMedia не требует runtime-разрешения — нужен только user gesture.
|
||
// REQUEST_PERMISSIONS теперь просто запускает запись и возвращает granted=true.
|
||
|
||
// Обработка сообщений
|
||
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);
|
||
|
||
switch (message.type) {
|
||
case 'REQUEST_PERMISSIONS':
|
||
// Под getDisplayMedia (D-01) runtime-permission проверять нечего —
|
||
// браузер сам покажет picker по user gesture из popup. Просто
|
||
// запускаем запись и подтверждаем popup-у.
|
||
(async () => {
|
||
try {
|
||
if (!isRecording) {
|
||
await startVideoCapture();
|
||
}
|
||
sendResponse({ granted: true });
|
||
} catch (error) {
|
||
logger.error('startVideoCapture failed:', error);
|
||
sendResponse({ granted: false });
|
||
}
|
||
})();
|
||
return true;
|
||
|
||
case 'GET_VIDEO_BUFFER':
|
||
getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
|
||
return true;
|
||
|
||
case 'SAVE_ARCHIVE':
|
||
saveArchive().then(result => {
|
||
sendResponse(result);
|
||
});
|
||
return true;
|
||
|
||
case 'OFFSCREEN_READY':
|
||
logger.log('OFFSCREEN_READY received');
|
||
offscreenReadyResolve?.();
|
||
offscreenReadyResolve = null;
|
||
return false;
|
||
|
||
case 'RECORDING_ERROR':
|
||
// Plan 01-09 — the offscreen recorder broadcasts this on capture
|
||
// failure (codec missing, picker cancelled, wrong-display-surface,
|
||
// mid-record stream end, etc.) AND on operator-initiated stop-sharing
|
||
// (src/offscreen/recorder.ts: onUserStoppedSharing emits
|
||
// RECORDING_ERROR{error:'user-stopped-sharing'} after resetBuffer +
|
||
// track release).
|
||
//
|
||
// Bug B (debug 01-09-recovery-flow) conditional routing: the
|
||
// operator-initiated stop is NOT an error condition, it is a
|
||
// deliberate end-of-session signal. Routing it through setErrorMode
|
||
// would (a) leave the popup pinned to src/popup/index.html (SAVE-only)
|
||
// so chrome.action.onClicked cannot re-fire — the popup wins the
|
||
// toolbar click forever, and (b) paint the badge yellow as if a
|
||
// capture failure occurred. Both lock the operator out of restart.
|
||
// Route 'user-stopped-sharing' through setIdleMode instead: popup
|
||
// empties (re-enabling onClicked-driven restart), badge returns to
|
||
// OFF (resetBuffer has already cleared the offscreen buffer so SAVE
|
||
// mode would be meaningless). No recovery notification: the operator
|
||
// performed the stop deliberately; surfacing a notification would
|
||
// be UX noise.
|
||
// All other error codes preserve the original setErrorMode + recovery
|
||
// notification routing (defensive fallback for genuine capture
|
||
// failures — codec-unsupported, wrong-display-surface, capture-failed,
|
||
// permission-denied, empty-video-buffer, unknown).
|
||
logger.warn('RECORDING_ERROR received:', message);
|
||
{
|
||
// Narrow `message` to read the optional `error` payload. The
|
||
// canonical Message interface (src/shared/types.ts) does not
|
||
// typify it (Message has type/data/tabId only); the offscreen
|
||
// recorder emits the extra `error` field as part of the
|
||
// RECORDING_ERROR wire shape — read it via a minimal cast that
|
||
// keeps us off `as any`.
|
||
const errorCode = (message as unknown as { error?: unknown }).error;
|
||
if (errorCode === 'user-stopped-sharing') {
|
||
isRecording = false;
|
||
setIdleMode();
|
||
} else {
|
||
setErrorMode();
|
||
try {
|
||
const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
|
||
chrome.notifications.create(recoveryId, {
|
||
type: 'basic',
|
||
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
|
||
title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK),
|
||
message: i18nMessage('notifRecovery', NOTIF_RECOVERY_FALLBACK),
|
||
priority: 1,
|
||
});
|
||
} catch (e) {
|
||
logger.warn('Recovery notification create failed:', e);
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
|
||
// Legacy chunk-streaming and IndexedDB save/load handlers were removed
|
||
// in Plan 01-03:
|
||
// - the offscreen recorder now owns the buffer (D-16);
|
||
// - chunks no longer travel via chrome.runtime.sendMessage (D-19);
|
||
// - SW-side IDB plumbing was the audit P0 #2 broken path.
|
||
// The IDB helpers were only reachable from those deleted cases.
|
||
// Plan 05 finishes the SW shrink (see deletions above).
|
||
|
||
default:
|
||
logger.warn('Unknown message type:', message.type);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// Инициализация
|
||
async function initialize() {
|
||
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');
|
||
// CR-03 fix: handshake deadlock after SW respawn.
|
||
// OFFSCREEN_READY is fired by the offscreen exactly once at its
|
||
// bootstrap (recorder.ts line ~452). If the SW is evicted (~30 s
|
||
// idle) while the offscreen document persists, the next SW lifetime
|
||
// creates a fresh `offscreenReady` Promise (line 33) and waits on
|
||
// it forever — the offscreen has no signal to re-emit, and
|
||
// startVideoCapture() hangs at `await offscreenReady`.
|
||
// Resolve readiness immediately when we detect a pre-existing
|
||
// offscreen: it MUST have completed its bootstrap before being
|
||
// observable via hasDocument().
|
||
offscreenReadyResolve?.();
|
||
offscreenReadyResolve = null;
|
||
logger.log('Resolving offscreenReady immediately — offscreen survived a prior SW lifetime');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
logger.warn('chrome.offscreen.hasDocument check failed:', err);
|
||
}
|
||
// Plan 01-09: enter idle mode at SW init — sets popup to '' so the
|
||
// first toolbar click fires onClicked directly (skipping the popup),
|
||
// and paints the OFF badge so the operator sees recording state at
|
||
// a glance.
|
||
setIdleMode();
|
||
logger.log('Service Worker initialized');
|
||
}
|
||
|
||
// ─── Plan 01-09 listener registrations ───────────────────────────────
|
||
// All listener registrations are wrapped in try/catch so SW module load
|
||
// stays defensively resilient when the chrome.action / chrome.notifications
|
||
// / chrome.runtime.onStartup surfaces are absent or partially stubbed
|
||
// (some unit test contexts only stub the runtime/tabs/offscreen subset).
|
||
// In production all three surfaces are guaranteed by the MV3 manifest.
|
||
|
||
// chrome.action.onClicked: fires ONLY when setPopup is '' (idle mode).
|
||
// On click, if we are not already recording, start the capture. The
|
||
// activation gesture is the toolbar click itself — getDisplayMedia
|
||
// accepts that as a valid user gesture per the W3C Screen Capture spec.
|
||
try {
|
||
chrome.action.onClicked.addListener(async () => {
|
||
if (isRecording) {
|
||
logger.log('Toolbar onClicked while already recording — no-op');
|
||
return;
|
||
}
|
||
try {
|
||
await startVideoCapture();
|
||
} catch (err) {
|
||
logger.warn('toolbar-onClicked start failed:', err);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
logger.warn('chrome.action.onClicked.addListener failed:', e);
|
||
}
|
||
|
||
// chrome.runtime.onStartup: fires once when a new browser session starts
|
||
// (NOT on extension install — that's onInstalled). Show an OS-level
|
||
// notification inviting the operator to start a recording.
|
||
try {
|
||
chrome.runtime.onStartup.addListener(() => {
|
||
setIdleMode();
|
||
try {
|
||
const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
|
||
chrome.notifications.create(notificationId, {
|
||
type: 'basic',
|
||
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
|
||
title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK),
|
||
message: i18nMessage('notifStartupCta', NOTIF_STARTUP_CTA_FALLBACK),
|
||
priority: 1,
|
||
});
|
||
} catch (e) {
|
||
logger.warn('Startup notification create failed:', e);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
logger.warn('chrome.runtime.onStartup.addListener failed:', e);
|
||
}
|
||
|
||
// chrome.notifications.onClicked: T-1-09-01 mitigation — only accept ids
|
||
// in our 'mokosh-' namespace. chrome.notifications ids are per-extension
|
||
// scoped at the OS level, but the prefix check is defense in depth.
|
||
try {
|
||
chrome.notifications.onClicked.addListener((notificationId) => {
|
||
if (!notificationId.startsWith('mokosh-')) return;
|
||
try {
|
||
chrome.notifications.clear(notificationId);
|
||
} catch (e) {
|
||
logger.warn('notifications.clear failed:', e);
|
||
}
|
||
// The notification click is itself an activation gesture, so
|
||
// startVideoCapture can call getDisplayMedia successfully from here.
|
||
startVideoCapture().catch((err) => {
|
||
logger.warn('notification-triggered start failed:', err);
|
||
});
|
||
});
|
||
} catch (e) {
|
||
logger.warn('chrome.notifications.onClicked.addListener failed:', e);
|
||
}
|
||
|
||
// chrome.downloads.onChanged: D-P2-01 (P0-6 fix) — revoke-on-terminal-state.
|
||
// Closes the URL.revokeObjectURL lifecycle by routing terminal download
|
||
// state transitions (`complete` / `interrupted`) into a REVOKE_DOWNLOAD_URL
|
||
// port message back to the offscreen document (which is the URL minting
|
||
// origin per DEC-006). On benign races (offscreenPort null at revoke time,
|
||
// e.g. SW respawn between download start and completion) the URL leaks in
|
||
// offscreen until the document is torn down — bounded per-session,
|
||
// acceptable per T-02-02-02 threat-register entry (chrome-extension://
|
||
// scoped, never exposed to web pages).
|
||
try {
|
||
if (chrome.downloads?.onChanged?.addListener !== undefined) {
|
||
chrome.downloads.onChanged.addListener((delta) => {
|
||
if (delta.state === undefined) return;
|
||
const newState = delta.state.current;
|
||
if (newState !== 'complete' && newState !== 'interrupted') return;
|
||
const url = pendingRevokes.get(delta.id);
|
||
if (url === undefined) return;
|
||
pendingRevokes.delete(delta.id);
|
||
if (videoPort !== null) {
|
||
try {
|
||
videoPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
||
logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`);
|
||
} catch (err) {
|
||
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
||
}
|
||
} else {
|
||
logger.warn(
|
||
`videoPort null at revoke time; url ${url.substring(0, 30)}... leaks until offscreen teardown`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logger.warn('chrome.downloads.onChanged.addListener failed:', e);
|
||
}
|
||
|
||
// Запуск при установке
|
||
chrome.runtime.onInstalled.addListener((details) => {
|
||
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();
|
||
// Plan 01-10 D-17-onboarding: open welcome tab on first install.
|
||
// Fire-and-forget — the helper logs its own errors and rejected
|
||
// promises are caught at the .catch boundary so they cannot escape
|
||
// the synchronous listener. The toolbar onClicked start path
|
||
// (D-16-toolbar) is independent of this flow.
|
||
openWelcomeIfFirstInstall(details).catch((err) => {
|
||
logger.warn('openWelcomeIfFirstInstall threw:', err);
|
||
});
|
||
});
|
||
|
||
// Запуск при старте Service Worker
|
||
initialize();
|