Files
mokosh/src/background/index.ts
Mark 79964e62d2 feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)
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).
2026-05-20 15:54:28 +02:00

1235 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();