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).
This commit is contained in:
2026-05-20 15:54:28 +02:00
parent f0b95f4a83
commit 79964e62d2
4 changed files with 379 additions and 32 deletions

View File

@@ -294,6 +294,25 @@ async function openWelcomeIfFirstInstall(
// 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
@@ -438,6 +457,26 @@ chrome.runtime.onConnect.addListener((port) => {
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(() => {
@@ -691,7 +730,24 @@ async function createArchive(
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];
@@ -701,20 +757,72 @@ async function downloadArchive(archiveBlob: Blob) {
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
// WR-08 fix: delegate to the shared `blobToBase64` helper instead of
// re-implementing the same per-byte concat + btoa inline. Keeps the
// wire-format encoding single-source-of-truth (also used by the
// offscreen↔SW port; see src/shared/binary.ts) and ensures any future
// performance work (chunked apply, etc.) propagates to both call sites.
const base64 = await blobToBase64(archiveBlob);
const url = `data:application/zip;base64,${base64}`;
if (videoPort === null) {
throw new Error('blob-url-mint-failed: offscreen port unavailable');
}
await chrome.downloads.download({
url: url,
filename: filename,
saveAs: false
// 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);
});
logger.log('Archive download started');
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)}...`);
}
// Сохранение архива (полный процесс)
@@ -1063,6 +1171,42 @@ try {
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);