Milestone v1 (v2.0.0): Mokosh — Session Capture #1
232
.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md
Normal file
232
.planning/phases/02-stabilize-export-pipeline/02-02-SUMMARY.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
---
|
||||||
|
phase: 02-stabilize-export-pipeline
|
||||||
|
plan: 02
|
||||||
|
subsystem: export-pipeline
|
||||||
|
tags:
|
||||||
|
- blob-url-migration
|
||||||
|
- p0-6-fix
|
||||||
|
- offscreen-port-bridge
|
||||||
|
- chrome-downloads-onchanged
|
||||||
|
- url-createObjectURL
|
||||||
|
- url-revokeObjectURL
|
||||||
|
- d-p2-01
|
||||||
|
- wave-2-green
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 02-stabilize-export-pipeline
|
||||||
|
provides: "Plan 02-01 RED gate (tests/background/blob-url-download.test.ts pinning D-P2-01 wire-format + revoke lifecycle contracts)"
|
||||||
|
- phase: 01-stabilize-video-pipeline
|
||||||
|
provides: "D-12 base64 wire-format helpers (src/shared/binary.ts); D-17 keepalivePort lifecycle; src/offscreen/recorder.ts URL.createObjectURL surface; pendingBufferRequests requestId routing pattern (template for pendingDownloadUrlResolvers)"
|
||||||
|
provides:
|
||||||
|
- "Offscreen-minted blob:URL download pipeline (D-P2-01 closes audit P0-6)"
|
||||||
|
- "PortMessageType triplet for D-P2-01: CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL"
|
||||||
|
- "chrome.downloads.onChanged revoke lifecycle wired in SW (state.current==='complete'/'interrupted' → REVOKE_DOWNLOAD_URL → URL.revokeObjectURL in offscreen)"
|
||||||
|
- "Test-side blob:URL extraction pattern (capturedArchiveBytes from CREATE_DOWNLOAD_URL.dataBase64) — reusable by Plan 02-03 + future export-pipeline tests"
|
||||||
|
- "Typed blob-url-mint-failed errors routed through saveArchive's RECORDING_ERROR channel (no silent corrupt-archive paths)"
|
||||||
|
- "T-02-02-03 mitigation: SW rejects DOWNLOAD_URL responses that do not start with blob: (defense-in-depth against future regressions / hostile-peer-on-shared-port)"
|
||||||
|
affects:
|
||||||
|
- "Plan 02-03 (Wave 2 GREEN for meta.urls + tab-url-tracker — must extend its own test helpers with the CREATE_DOWNLOAD_URL round-trip simulation; the pattern is now established in tests/background/meta-json-urls-schema.test.ts + tests/build/strict-meta-json-validation.test.ts)"
|
||||||
|
- "Plan 02-04 (Wave 3 UAT harness extensions — A24+ operator empirical verification that the produced zip uses blob: URL via DevTools Network panel)"
|
||||||
|
- "Any future export-pipeline plan that wants to extract archive bytes from a unit-test SAVE_ARCHIVE flow (use the CREATE_DOWNLOAD_URL port-message capture pattern)"
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Offscreen-as-DOM-globals-proxy: SW posts (CREATE_DOWNLOAD_URL, base64) → offscreen mints URL.createObjectURL → returns (DOWNLOAD_URL, url) → SW uses URL. Same pattern can carry future DOM-only APIs the SW needs (Atomics.notify, requestAnimationFrame timing, etc.) without expanding chrome.* permissions."
|
||||||
|
- "Per-request resolver Map for port-bridged async (pendingDownloadUrlResolvers) — mirrors pendingBufferRequests; the pattern is now the canonical SW↔offscreen async-await idiom."
|
||||||
|
- "Test-side port-bridge simulation: helper tracks mintedRequestIds Set + replies DOWNLOAD_URL with a Node-native blob:URL minted via URL.createObjectURL. capturedArchiveBytes snapshot lets downstream JSZip extraction work without spying on URL.createObjectURL globally."
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- "src/shared/types.ts (PortMessageType union + PortMessage interface extended with the D-P2-01 triplet)"
|
||||||
|
- "src/offscreen/recorder.ts (handleCreateDownloadUrl helper + onPortMessage branches for CREATE_DOWNLOAD_URL / REVOKE_DOWNLOAD_URL; mintedDownloadUrls hygiene Set; base64ToBlob import added)"
|
||||||
|
- "src/background/index.ts (pendingDownloadUrlResolvers Map + pendingRevokes Map; onConnect DOWNLOAD_URL routing branch; downloadArchive rewritten; chrome.downloads.onChanged listener registered at module init; T-02-02-03 blob: prefix guard)"
|
||||||
|
- "tests/background/blob-url-download.test.ts (runSaveAndCaptureDownloadArg + Test 2 drain loop + Test 3 port.postMessage shim all extended to simulate the offscreen-side CREATE_DOWNLOAD_URL → DOWNLOAD_URL → REVOKE_DOWNLOAD_URL round-trip)"
|
||||||
|
- "tests/background/meta-json-urls-schema.test.ts (runSaveAndCaptureArchiveBlob extended with tryFireDownloadUrl + capturedArchiveBytes so the helper returns the archive Blob regardless of data: vs blob: URL pathway)"
|
||||||
|
- "tests/build/strict-meta-json-validation.test.ts (runAndParseMetaJson extended identically)"
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Reuse the existing keepalivePort (D-17) for the SW↔offscreen mint/revoke bridge rather than opening a parallel port. Saves two connect-overhead penalties per save flow and keeps the lifecycle ownership single-sourced."
|
||||||
|
- "Reuse the D-12 base64 wire-format from src/shared/binary.ts for SW→offscreen archive Blob transfer rather than inventing a new encoding. Same precedent as the BUFFER segment transfer in the reverse direction."
|
||||||
|
- "NO data: URL fallback. Per CONTEXT.md Revision Log 2026-05-20 F3 resolution: typed `blob-url-mint-failed` errors route through saveArchive's catch into the RECORDING_ERROR channel. Silent corrupt-archive paths are forbidden — operator must see a clear failure."
|
||||||
|
- "downloadArchive rejects DOWNLOAD_URL responses whose url does not start with `blob:` (T-02-02-03 defense-in-depth). The offscreen is same-extension origin so a non-blob: response would itself be a bug, but the explicit guard catches future regressions."
|
||||||
|
- "Concurrent mints allowed (each gets its own requestId) — URL minting is stateless per-Blob, unlike encodeAndSendBuffer which gates concurrent calls because they share segment ring buffer state."
|
||||||
|
- "Reused the existing generateRequestId() helper in src/background/index.ts rather than introducing a parallel crypto.randomUUID() call site, consistent with CLAUDE.md 'extension over duplication'."
|
||||||
|
- "Test-side helper captures archive bytes from CREATE_DOWNLOAD_URL.dataBase64 (not from the blob:URL string, which is opaque in Node). This is cleaner than the spy-on-URL.createObjectURL pattern the test-author originally anticipated and provides direct access to the bytes the SW transmitted."
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "D-P2-01 mint/revoke triplet: CREATE_DOWNLOAD_URL (SW→offscreen base64 payload) → DOWNLOAD_URL (offscreen→SW url) → REVOKE_DOWNLOAD_URL (SW→offscreen url) — fire-and-forget revoke; URL.revokeObjectURL on unknown URL is a WHATWG no-op so the hygiene Set is purely diagnostic."
|
||||||
|
- "Test-side offscreen-port-bridge round-trip simulation pattern: helper tracks mintedRequestIds Set + decodes CREATE_DOWNLOAD_URL.dataBase64 + mints Node-native blob:URL + replies DOWNLOAD_URL on the same port. Used by Plan 02-03 + future export-pipeline test helpers."
|
||||||
|
|
||||||
|
requirements-completed: []
|
||||||
|
# Per plan instructions: REQ-archive-export-latency is pinned by Plan 02-01's RED
|
||||||
|
# test 2 (6 MB archive < 5000 ms) which is now GREEN, but the requirement is
|
||||||
|
# best marked complete after Plan 02-04's UAT harness empirically validates
|
||||||
|
# the operator-facing latency assertion (A24+). Plan 02-02 unblocks the
|
||||||
|
# latency contract architecturally; Plan 02-04 closes the empirical loop.
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 13min
|
||||||
|
completed: 2026-05-20
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 Plan 02: Blob URL download pipeline Summary
|
||||||
|
|
||||||
|
**Offscreen-minted blob:URL replaces base64 data:URL in downloadArchive; SW↔offscreen mint/revoke bridge rides existing keepalivePort with chrome.downloads.onChanged-driven revoke lifecycle.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 13 min
|
||||||
|
- **Started:** 2026-05-20T13:41:31Z
|
||||||
|
- **Completed:** 2026-05-20T13:55:01Z
|
||||||
|
- **Tasks:** 3/3 completed
|
||||||
|
- **Files modified:** 6 (3 production source + 3 test files; +518 lines / −35 lines)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- **Audit P0-6 closed.** downloadArchive no longer mints a `data:application/zip;base64,...` URL. The legacy ~2 MB Chrome data-URL cap is gone; real operator archives (5-10 MB) now download to disk without 'Network error'.
|
||||||
|
- **3 Plan 02-01 RED tests flipped GREEN** in tests/background/blob-url-download.test.ts:
|
||||||
|
- Test 1: chrome.downloads.download arg0.url starts with `blob:` and not `data:application/zip;base64,`.
|
||||||
|
- Test 2: 6 MB archive completes in under 5 000 ms AND emits a blob: URL.
|
||||||
|
- Test 3: URL.revokeObjectURL fires with the minted url after chrome.downloads.onChanged 'complete'.
|
||||||
|
- **chrome.downloads.onChanged revoke lifecycle wired** for the first time. SW tracks (downloadId → minted URL) in `pendingRevokes` Map; on terminal state ('complete' or 'interrupted'), the SW posts REVOKE_DOWNLOAD_URL to offscreen which calls URL.revokeObjectURL. Memory hygiene preserved across saves; T-02-02-02 mitigation in place.
|
||||||
|
- **D-P2-01 wire contract landed** in `PortMessageType`: 3 new variants (CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL) following the same JSON-serializable optional-tagged-union pattern as the existing BUFFER triplet.
|
||||||
|
- **Test infrastructure extended** for Plan 02-03 reuse: all three Plan-02-01-era test helpers (blob-url-download / meta-json-urls-schema / strict-meta-json-validation) now simulate the SW↔offscreen mint round-trip and can extract archive bytes from CREATE_DOWNLOAD_URL.dataBase64.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Extend PortMessageType + PortMessage** — `483998d` (feat)
|
||||||
|
2. **Task 2: Offscreen handler — CREATE/REVOKE Blob URL handlers** — `f0b95f4` (feat)
|
||||||
|
3. **Task 3: SW downloadArchive rewrite + chrome.downloads.onChanged revoke wiring** — `79964e6` (feat, includes Rule 3 test-harness deviation fixes)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### Production source
|
||||||
|
|
||||||
|
- **`src/shared/types.ts`** (+33 lines)
|
||||||
|
- `PortMessageType` union grows 4 → 7 entries with the D-P2-01 triplet.
|
||||||
|
- `PortMessage` interface gains optional `dataBase64?: string`, `mimeType?: string`, `url?: string`.
|
||||||
|
- Top-of-union docstring explaining the SW↔offscreen mint/revoke lifecycle.
|
||||||
|
|
||||||
|
- **`src/offscreen/recorder.ts`** (+107 lines)
|
||||||
|
- `mintedDownloadUrls: Set<string>` hygiene tracker (purely diagnostic — WHATWG spec says URL.revokeObjectURL on unknown URL is a no-op).
|
||||||
|
- `handleCreateDownloadUrl(requestId, dataBase64, mimeType)` async helper: decodes base64 → constructs Blob → mints URL.createObjectURL → posts DOWNLOAD_URL on keepalivePort. Best-effort error path responds with empty url so the SW's typed-error path can fire cleanly.
|
||||||
|
- `onPortMessage` extended with CREATE_DOWNLOAD_URL and REVOKE_DOWNLOAD_URL branches matching the existing PONG / REQUEST_BUFFER style.
|
||||||
|
- `import { blobToBase64, base64ToBlob }` (was: blobToBase64 only).
|
||||||
|
|
||||||
|
- **`src/background/index.ts`** (+172 lines)
|
||||||
|
- `pendingDownloadUrlResolvers: Map<string, (url: string) => void>` and `pendingRevokes: Map<number, string>` module-scoped state, with docstrings citing T-02-02-02.
|
||||||
|
- `BLOB_URL_MINT_TIMEOUT_MS = 5_000`.
|
||||||
|
- `onConnect` port message sink extended with `DOWNLOAD_URL` routing branch (alongside existing PING / BUFFER routing).
|
||||||
|
- `downloadArchive(archiveBlob)` rewritten end-to-end (encode → post CREATE_DOWNLOAD_URL → race DOWNLOAD_URL vs timeout → guard blob: prefix → call chrome.downloads.download → register in pendingRevokes).
|
||||||
|
- `chrome.downloads.onChanged.addListener` registered at module init (try/catch defensive like other listener registrations); on terminal state, posts REVOKE_DOWNLOAD_URL to `videoPort` and clears `pendingRevokes`.
|
||||||
|
- Comment cleanup: removed the literal `data:application/zip;base64,` string from the docstring to satisfy the plan §verification grep gate.
|
||||||
|
|
||||||
|
### Test infrastructure (Rule 3 deviation — see below)
|
||||||
|
|
||||||
|
- **`tests/background/blob-url-download.test.ts`** (+111 lines)
|
||||||
|
- `runSaveAndCaptureDownloadArg` helper extended with `tryFireDownloadUrl` + `mintedUrlsHere` set; replies DOWNLOAD_URL with a Node-native blob:URL on receipt of CREATE_DOWNLOAD_URL.
|
||||||
|
- Test 2 (6 MB archive) drain loop mirrors the same offscreen-side simulation.
|
||||||
|
- Test 3 (revoke lifecycle) installs a `port.postMessage.mockImplementation` shim that calls `URL.revokeObjectURL` on receipt of REVOKE_DOWNLOAD_URL — the test-side equivalent of `handleRevokeDownloadUrl` in src/offscreen/recorder.ts.
|
||||||
|
|
||||||
|
- **`tests/background/meta-json-urls-schema.test.ts`** (+62 lines)
|
||||||
|
- `runSaveAndCaptureArchiveBlob` extended with the same `tryFireDownloadUrl` pattern; archive bytes captured from CREATE_DOWNLOAD_URL.dataBase64 (not from the opaque blob:URL string).
|
||||||
|
|
||||||
|
- **`tests/build/strict-meta-json-validation.test.ts`** (+68 lines)
|
||||||
|
- `runAndParseMetaJson` extended identically.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
See `key-decisions` in the frontmatter (7 decisions covering wire-format reuse, no-fallback contract, T-02-02-03 mitigation, concurrent-mint policy, requestId helper reuse, and test-side bytes-capture pattern).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Plan 02-01 test helpers missing CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip simulation**
|
||||||
|
|
||||||
|
- **Found during:** Task 3 (SW downloadArchive rewrite) — first verification run.
|
||||||
|
- **Issue:** The Plan 02-01 RED tests (and the related Plan 02-03-territory helpers in meta-json-urls-schema.test.ts and strict-meta-json-validation.test.ts) were authored with helpers that ONLY simulated the REQUEST_BUFFER → BUFFER round-trip. After my Plan 02-02 changes, the SW's `downloadArchive` posts CREATE_DOWNLOAD_URL on the offscreen port and awaits DOWNLOAD_URL — but the test stubs do not load `src/offscreen/recorder.ts`. With no test-side response to CREATE_DOWNLOAD_URL, the SW's 5 s mint timeout fires, `chrome.downloads.download` is never called, and ALL three sets of helpers time out.
|
||||||
|
- **Failure shape:** ALL 13 vitest failures were `Test timed out in 5000ms` (drain loop never sees `mock.calls.length > 0`) — including the 5 GREEN-today regression-guard tests in strict-meta-json-validation.test.ts (Tests 2/4/5/6/7) that Plan 02-01 SUMMARY explicitly listed as "preserved unchanged".
|
||||||
|
- **Fix:** Extended each helper to model the offscreen side of the bridge. The helper detects port.postMessage calls of type CREATE_DOWNLOAD_URL, decodes the dataBase64 to a Uint8Array, mints a Node-native blob:URL via URL.createObjectURL (Node 24 supplies it as a global), captures the bytes for downstream JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL on the same port. For Test 3 (revoke lifecycle) additionally installs `port.postMessage.mockImplementation` to call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl / handleRevokeDownloadUrl.
|
||||||
|
- **Why this is Rule 3 not Rule 4:** Plan 02-01's test authors explicitly anticipated this extension via TODO comments ("Plan 02-03 implementer will likely need a different helper, e.g. spy on URL.createObjectURL to capture the underlying Blob reference"). The bytes-capture pattern via the port message is cleaner than the spy-on-globals approach and provides identical semantics. No architectural change; just test infrastructure catching up to the new contract.
|
||||||
|
- **Files modified:** tests/background/blob-url-download.test.ts, tests/background/meta-json-urls-schema.test.ts, tests/build/strict-meta-json-validation.test.ts.
|
||||||
|
- **Verification:**
|
||||||
|
- blob-url-download: 3/3 GREEN (the plan's target).
|
||||||
|
- meta-json-urls-schema: 5/5 fail with the SAME Plan-02-03-territory error messages they had at baseline (missing tab-url-tracker module, missing urls field). No regression in failure mode.
|
||||||
|
- strict-meta-json-validation: 3 RED + 5 GREEN regression-guards (matches Plan 02-01 SUMMARY's "8 tests, 3 RED + 5 GREEN-today" claim).
|
||||||
|
- **Committed in:** `79964e6` (part of Task 3 commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 3 — blocking)
|
||||||
|
|
||||||
|
**Impact on plan:** The deviation extends test infrastructure to validate the contract Task 3 introduces. No architectural change; no scope creep beyond what Plan 02-01's authors explicitly anticipated. The bytes-capture pattern via port message is the canonical Plan-02-02-era equivalent of the deprecated data:URL base64-decode pathway and is reusable by Plan 02-03.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None. The plan-checker passed Plan 02-02 cleanly and the implementation matched the plan's `<action>` blocks closely. The one source of friction — the test infrastructure deviation above — was anticipated by the Plan 02-01 SUMMARY (note 7 of key-decisions) and resolved with a pattern that improves clarity (port-message bytes capture vs spy-on-globals).
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None. The migration is fully internal — no manifest permission changes, no new chrome.* surfaces (chrome.downloads.onChanged was already available under the existing `downloads` permission per Plan 01 manifest baseline), no operator-visible configuration. The 6 MB archive that previously failed with 'Network error' will now download cleanly on the next operator save.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- **Plan 02-03 (next, Wave 2 GREEN — meta.urls + tab-url-tracker):** ready. Plan 02-03's implementer will extend its own test helpers using the same `tryFireDownloadUrl` + `capturedArchiveBytes` pattern this plan establishes — the three test files already showcase it. The 5 RED tests in meta-json-urls-schema.test.ts and the 3 RED tests in strict-meta-json-validation.test.ts remain RED, exactly as Plan 02-01 SUMMARY's "Plan 02-03 territory" forward link predicted.
|
||||||
|
- **Plan 02-04 (Wave 3 — UAT harness extensions):** ready. The chrome.downloads.onChanged listener is now reachable by the UAT harness; A24+ assertions can probe the produced zip + verify blob: URL via DevTools Network panel as the empirical close on D-P2-01.
|
||||||
|
|
||||||
|
## Verification Reconciliation
|
||||||
|
|
||||||
|
| Plan §verification gate | Result |
|
||||||
|
|---|---|
|
||||||
|
| `npx tsc --noEmit` | clean |
|
||||||
|
| `npm run build` | clean (377 kB SW chunk; ~ +2 kB vs baseline for the new listener + Map state) |
|
||||||
|
| `npm run build:test` | clean (dist-test/) |
|
||||||
|
| `npx vitest run tests/background/blob-url-download.test.ts` | **3/3 GREEN** (was 3 RED — the plan's target) |
|
||||||
|
| `npx vitest run` (full suite) | **163 passed / 8 failed / 171 total** — +4 GREEN net vs Plan 02-01 baseline (159 passed / 12 failed). The 8 remaining RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3 strict-meta-json-validation). |
|
||||||
|
| Tier-1 grep gate (FORBIDDEN_HOOK_STRINGS) | **13/13 GREEN** (unchanged — no new test-hook surfaces; the D-P2-01 triplet is production surface) |
|
||||||
|
| `grep -c "data:application/zip;base64," src/background/index.ts` | **0** (legacy path gone, including the docstring mention which was rewritten to avoid the literal) |
|
||||||
|
| `grep -c "blob:" src/background/index.ts` | **8** (the new pipeline) |
|
||||||
|
| `grep -c "chrome.downloads.onChanged" src/background/index.ts` | **5** (listener wired + docstring references) |
|
||||||
|
| dist/ `data:application/zip;base64,` | **0 matches** |
|
||||||
|
| dist/ `chrome.downloads.onChanged` | 1 file (the SW chunk) |
|
||||||
|
| Always-on charter (no finally in saveArchive) | **preserved** |
|
||||||
|
| `await import(...)` in src/background/index.ts | **0** (Plan 01-11 SUMMARY invariant preserved) |
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
### Files modified (verified via git diff)
|
||||||
|
|
||||||
|
- FOUND: src/shared/types.ts (+33 lines)
|
||||||
|
- FOUND: src/offscreen/recorder.ts (+107 lines)
|
||||||
|
- FOUND: src/background/index.ts (+172 lines)
|
||||||
|
- FOUND: tests/background/blob-url-download.test.ts (+111 lines, Rule 3 deviation)
|
||||||
|
- FOUND: tests/background/meta-json-urls-schema.test.ts (+62 lines, Rule 3 deviation)
|
||||||
|
- FOUND: tests/build/strict-meta-json-validation.test.ts (+68 lines, Rule 3 deviation)
|
||||||
|
|
||||||
|
### Commits exist (verified via git log)
|
||||||
|
|
||||||
|
- FOUND: 483998d feat(02-02): wire-format — extend PortMessage with CREATE_DOWNLOAD_URL/DOWNLOAD_URL/REVOKE_DOWNLOAD_URL (D-P2-01)
|
||||||
|
- FOUND: f0b95f4 feat(02-02): offscreen — CREATE/REVOKE Blob URL handlers on keepalivePort (D-P2-01)
|
||||||
|
- FOUND: 79964e6 feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)
|
||||||
|
|
||||||
|
### Success criteria reconciliation (from prompt)
|
||||||
|
|
||||||
|
1. [x] All 3 tasks in plan executed.
|
||||||
|
2. [x] Each task committed individually (--no-verify per parallel-execution mandate).
|
||||||
|
3. [x] 3 RED tests in tests/background/blob-url-download.test.ts now GREEN.
|
||||||
|
4. [x] 160 pre-existing GREEN tests still GREEN (no regression — full-suite delta is +4 GREEN, not -anything).
|
||||||
|
5. [x] Tier-1 grep gate still 13 entries (no new test-hook surfaces; CREATE_DOWNLOAD_URL / DOWNLOAD_URL / REVOKE_DOWNLOAD_URL are production message types riding existing keepalivePort).
|
||||||
|
6. [x] No modifications to meta.json schema or SessionMetadata fields (Plan 02-03 territory untouched).
|
||||||
|
7. [x] SUMMARY.md created and (about to be) committed.
|
||||||
|
8. [x] No modifications to STATE.md or ROADMAP.md (worktree mode invariant).
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 02-stabilize-export-pipeline*
|
||||||
|
*Completed: 2026-05-20*
|
||||||
@@ -294,6 +294,25 @@ async function openWelcomeIfFirstInstall(
|
|||||||
// cycles before the operator-visible error surfaces.
|
// cycles before the operator-visible error surfaces.
|
||||||
const BUFFER_FETCH_TIMEOUT_MS = 10_000;
|
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
|
// Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The
|
||||||
// onConnect-level message sink routes BUFFER -> resolve by id, so port
|
// onConnect-level message sink routes BUFFER -> resolve by id, so port
|
||||||
// replacement (videoPort changes mid-request) does NOT lose the
|
// replacement (videoPort changes mid-request) does NOT lose the
|
||||||
@@ -438,6 +457,26 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||||||
pending.resolve({ segments });
|
pending.resolve({ segments });
|
||||||
return;
|
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).
|
// Unknown traffic — drop silently (T-1-04 defense-in-depth).
|
||||||
});
|
});
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
@@ -691,7 +730,24 @@ async function createArchive(
|
|||||||
return archiveBlob;
|
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) {
|
async function downloadArchive(archiveBlob: Blob) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
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)`);
|
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
|
||||||
|
|
||||||
// WR-08 fix: delegate to the shared `blobToBase64` helper instead of
|
if (videoPort === null) {
|
||||||
// re-implementing the same per-byte concat + btoa inline. Keeps the
|
throw new Error('blob-url-mint-failed: offscreen port unavailable');
|
||||||
// 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}`;
|
|
||||||
|
|
||||||
await chrome.downloads.download({
|
// Encode the archive bytes for the SW→offscreen wire (D-12 base64
|
||||||
url: url,
|
// precedent — chrome.runtime.Port JSON-serializes payloads and Blobs
|
||||||
filename: filename,
|
// arrive as empty objects without this transform).
|
||||||
saveAs: false
|
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);
|
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) => {
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
logger.log('Extension installed/updated:', details.reason);
|
logger.log('Extension installed/updated:', details.reason);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// is preserved as 3 × 10 s = 30 s.)
|
// is preserved as 3 × 10 s = 30 s.)
|
||||||
|
|
||||||
import { OffscreenLogger } from '../shared/logger';
|
import { OffscreenLogger } from '../shared/logger';
|
||||||
import { blobToBase64 } from '../shared/binary';
|
import { blobToBase64, base64ToBlob } from '../shared/binary';
|
||||||
import type { Message, TransferredVideoSegment } from '../shared/types';
|
import type { Message, TransferredVideoSegment } from '../shared/types';
|
||||||
|
|
||||||
// ─── Plan 01-11: gated test-hook dynamic import ───────────────────────
|
// ─── Plan 01-11: gated test-hook dynamic import ───────────────────────
|
||||||
@@ -611,6 +611,65 @@ function teardownPortTimers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-P2-01 Blob URL hygiene set: track minted URLs so we can warn (not
|
||||||
|
// error) on unexpected revoke ops. URL.revokeObjectURL on an unknown URL
|
||||||
|
// is a no-op per the WHATWG URL spec, so this is purely diagnostic. The
|
||||||
|
// Set lives at module scope because URL minting is stateless per-Blob
|
||||||
|
// and an instance-per-mint container would itself be the only consumer
|
||||||
|
// (the postMessage round-trip happens via requestId, not Set membership).
|
||||||
|
const mintedDownloadUrls = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-P2-01 (P0-6 fix) CREATE_DOWNLOAD_URL handler: decode the SW-supplied
|
||||||
|
* base64 archive bytes into a Blob, mint a `blob:chrome-extension://...`
|
||||||
|
* URL via URL.createObjectURL, and post the URL string back to the SW
|
||||||
|
* via the keepalivePort as a DOWNLOAD_URL message.
|
||||||
|
*
|
||||||
|
* Defense-in-depth: on any failure (empty base64, decode throw, mint
|
||||||
|
* throw, port disconnected mid-flight) the function responds with
|
||||||
|
* `{type:'DOWNLOAD_URL', requestId, url:''}` and lets the SW's outer
|
||||||
|
* timeout fire. Mirrors the encodeAndSendBuffer best-effort pattern —
|
||||||
|
* partial recovery > silent stall.
|
||||||
|
*
|
||||||
|
* @param requestId - Per-mint correlation id (echoed in the DOWNLOAD_URL
|
||||||
|
* response). The SW's pendingDownloadUrlResolvers Map
|
||||||
|
* is keyed by this id.
|
||||||
|
* @param dataBase64 - Base64-encoded archive bytes (no `data:` prefix).
|
||||||
|
* @param mimeType - MIME type to assign to the reconstructed Blob.
|
||||||
|
* Defaults to 'application/zip' at the call site.
|
||||||
|
*/
|
||||||
|
async function handleCreateDownloadUrl(
|
||||||
|
requestId: string,
|
||||||
|
dataBase64: string,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (keepalivePort === null) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL: port unavailable at handler entry — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const blob = base64ToBlob(dataBase64, mimeType);
|
||||||
|
if (blob.size === 0) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL: empty blob from base64 decode — responding with empty url');
|
||||||
|
} else {
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
mintedDownloadUrls.add(url);
|
||||||
|
logger.log(`Minted Blob URL: ${url.substring(0, 30)}... (size: ${blob.size} bytes)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('CREATE_DOWNLOAD_URL: base64ToBlob or createObjectURL threw:', err);
|
||||||
|
}
|
||||||
|
// Always respond, even on empty url — the SW's downloadArchive checks
|
||||||
|
// `url === ''` and throws a typed `blob-url-mint-failed` error, which
|
||||||
|
// routes through saveArchive's catch into the RECORDING_ERROR channel.
|
||||||
|
try {
|
||||||
|
keepalivePort.postMessage({ type: 'DOWNLOAD_URL', requestId, url });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('DOWNLOAD_URL post failed (port may have disconnected):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onPortMessage(message: unknown): void {
|
function onPortMessage(message: unknown): void {
|
||||||
// Defense-in-depth: explicit shape check before destructuring (T-1-04).
|
// Defense-in-depth: explicit shape check before destructuring (T-1-04).
|
||||||
if (typeof message !== 'object' || message === null) {
|
if (typeof message !== 'object' || message === null) {
|
||||||
@@ -646,6 +705,52 @@ function onPortMessage(message: unknown): void {
|
|||||||
// and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
|
// and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
|
||||||
// tests/offscreen/port-serialization.test.ts for the contract.
|
// tests/offscreen/port-serialization.test.ts for the contract.
|
||||||
void encodeAndSendBuffer(requestId);
|
void encodeAndSendBuffer(requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'CREATE_DOWNLOAD_URL') {
|
||||||
|
// D-P2-01 (P0-6 fix): mint a Blob URL on behalf of the SW (which
|
||||||
|
// lacks URL.createObjectURL per DEC-006). Each mint gets its own
|
||||||
|
// requestId; concurrent mints are allowed because URL minting is
|
||||||
|
// stateless per-Blob — unlike encodeAndSendBuffer, which gates
|
||||||
|
// concurrent calls because they share the segment ring buffer state.
|
||||||
|
const requestId = (message as { requestId?: unknown }).requestId;
|
||||||
|
const dataBase64 = (message as { dataBase64?: unknown }).dataBase64;
|
||||||
|
const mimeType = (message as { mimeType?: unknown }).mimeType;
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL without requestId — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof dataBase64 !== 'string') {
|
||||||
|
logger.warn('CREATE_DOWNLOAD_URL with non-string dataBase64 — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const effectiveMime = typeof mimeType === 'string' ? mimeType : 'application/zip';
|
||||||
|
// Fire-and-forget IIFE so the listener stays sync-typed; the helper
|
||||||
|
// owns its own error reporting + DOWNLOAD_URL response post.
|
||||||
|
void handleCreateDownloadUrl(requestId, dataBase64, effectiveMime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'REVOKE_DOWNLOAD_URL') {
|
||||||
|
// D-P2-01: free the URL once chrome.downloads.onChanged reports the
|
||||||
|
// download is complete or interrupted. URL.revokeObjectURL on an
|
||||||
|
// unknown URL is a no-op per WHATWG spec, so the mintedDownloadUrls
|
||||||
|
// Set is purely a diagnostic signal (unexpected revokes get a warn
|
||||||
|
// but still execute).
|
||||||
|
const url = (message as { url?: unknown }).url;
|
||||||
|
if (typeof url !== 'string' || url.length === 0) {
|
||||||
|
logger.warn('REVOKE_DOWNLOAD_URL without url — dropping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mintedDownloadUrls.has(url)) {
|
||||||
|
logger.warn(`REVOKE_DOWNLOAD_URL for unminted url ${url.substring(0, 30)}... — proceeding anyway (WHATWG no-op)`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
mintedDownloadUrls.delete(url);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('URL.revokeObjectURL threw:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
|
// Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,27 @@ export interface Message<T = unknown> {
|
|||||||
// SW can match a response to the in-flight request that issued it.
|
// SW can match a response to the in-flight request that issued it.
|
||||||
// This retires the silent-cross-talk failure mode where a stale
|
// This retires the silent-cross-talk failure mode where a stale
|
||||||
// BUFFER from a prior request would route into a newer Promise.
|
// BUFFER from a prior request would route into a newer Promise.
|
||||||
|
//
|
||||||
|
// D-P2-01 Blob URL migration triplet (P0-6 fix, Phase 02 Plan 02-02):
|
||||||
|
// - CREATE_DOWNLOAD_URL + DOWNLOAD_URL + REVOKE_DOWNLOAD_URL.
|
||||||
|
// SW posts CREATE_DOWNLOAD_URL with the archive bytes as base64;
|
||||||
|
// offscreen mints URL.createObjectURL and responds with DOWNLOAD_URL;
|
||||||
|
// SW calls chrome.downloads.download(url); when chrome.downloads
|
||||||
|
// .onChanged reports 'complete' or 'interrupted', SW posts
|
||||||
|
// REVOKE_DOWNLOAD_URL so offscreen can free the URL. The base64
|
||||||
|
// wire-format reuses the D-12 precedent from src/shared/binary.ts —
|
||||||
|
// chrome.runtime.Port JSON-serializes payloads, so Blob → empty
|
||||||
|
// object; base64 round-trips cleanly. See
|
||||||
|
// .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md
|
||||||
|
// D-P2-01 for the full architectural rationale.
|
||||||
export type PortMessageType =
|
export type PortMessageType =
|
||||||
| 'PING'
|
| 'PING'
|
||||||
| 'PONG'
|
| 'PONG'
|
||||||
| 'REQUEST_BUFFER'
|
| 'REQUEST_BUFFER'
|
||||||
| 'BUFFER';
|
| 'BUFFER'
|
||||||
|
| 'CREATE_DOWNLOAD_URL' // SW → offscreen: "here is a Blob as base64; mint a URL for it"
|
||||||
|
| 'DOWNLOAD_URL' // offscreen → SW: "here is the minted blob:URL"
|
||||||
|
| 'REVOKE_DOWNLOAD_URL'; // SW → offscreen: "you can revoke this URL now"
|
||||||
|
|
||||||
export interface PortMessage {
|
export interface PortMessage {
|
||||||
type: PortMessageType;
|
type: PortMessageType;
|
||||||
@@ -52,6 +68,10 @@ export interface PortMessage {
|
|||||||
// each REQUEST_BUFFER call and only resolves on BUFFER responses that
|
// each REQUEST_BUFFER call and only resolves on BUFFER responses that
|
||||||
// echo the same id. PING/PONG do not carry a requestId — they are
|
// echo the same id. PING/PONG do not carry a requestId — they are
|
||||||
// pure liveness signals.
|
// pure liveness signals.
|
||||||
|
// Also used by the D-P2-01 triplet: CREATE_DOWNLOAD_URL → DOWNLOAD_URL
|
||||||
|
// pair carries a per-mint requestId so concurrent mints (theoretically
|
||||||
|
// possible across two SAVE flows) cannot cross-talk. REVOKE_DOWNLOAD_URL
|
||||||
|
// is fire-and-forget — no requestId required.
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
// Wire-format (D-12 base64 transfer + D-13 segment lifecycle):
|
// Wire-format (D-12 base64 transfer + D-13 segment lifecycle):
|
||||||
// segments travel as TransferredVideoSegment[] because
|
// segments travel as TransferredVideoSegment[] because
|
||||||
@@ -61,6 +81,17 @@ export interface PortMessage {
|
|||||||
// + seed keyframe). The receive side reconstructs VideoSegment[]
|
// + seed keyframe). The receive side reconstructs VideoSegment[]
|
||||||
// via src/shared/binary.ts.
|
// via src/shared/binary.ts.
|
||||||
segments?: TransferredVideoSegment[];
|
segments?: TransferredVideoSegment[];
|
||||||
|
// D-P2-01 (P0-6 fix): archive bytes as base64 + MIME type for Blob
|
||||||
|
// reconstruction in the offscreen document on CREATE_DOWNLOAD_URL.
|
||||||
|
// Reuses src/shared/binary.ts blobToBase64/base64ToBlob helpers; same
|
||||||
|
// wire-format precedent as the BUFFER segment transfer.
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
// D-P2-01: the minted blob:URL string on DOWNLOAD_URL (offscreen → SW)
|
||||||
|
// OR the URL to free on REVOKE_DOWNLOAD_URL (SW → offscreen). The
|
||||||
|
// string is a `blob:chrome-extension://<id>/<uuid>` URL minted by
|
||||||
|
// URL.createObjectURL in the offscreen document origin.
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory segment shape used by mergeVideoSegments after the SW
|
// In-memory segment shape used by mergeVideoSegments after the SW
|
||||||
|
|||||||
@@ -362,6 +362,55 @@ async function runSaveAndCaptureDownloadArg(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
|
||||||
|
// handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
|
||||||
|
// base64; the real offscreen mints a blob:URL via URL.createObjectURL
|
||||||
|
// (src/offscreen/recorder.ts:handleCreateDownloadUrl). In tests we
|
||||||
|
// decode the base64 to a Blob ourselves and mint the URL on the test
|
||||||
|
// side, then reply with DOWNLOAD_URL{requestId,url}. The minted URL
|
||||||
|
// round-trips through the SW's pendingDownloadUrlResolvers Map and
|
||||||
|
// becomes the arg0.url passed to chrome.downloads.download. The
|
||||||
|
// mintedUrlsHere set is exported so revoke-lifecycle tests (Test 3)
|
||||||
|
// can probe the URL string passed to URL.revokeObjectURL.
|
||||||
|
const mintedUrlsHere = new Set<string>();
|
||||||
|
const tryFireDownloadUrl = () => {
|
||||||
|
const mintCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
|
||||||
|
);
|
||||||
|
for (const call of mintCalls) {
|
||||||
|
const mintMsg = call[0] as {
|
||||||
|
requestId?: string;
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
const requestId = mintMsg.requestId;
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) continue;
|
||||||
|
if (mintedUrlsHere.has(requestId)) continue;
|
||||||
|
mintedUrlsHere.add(requestId);
|
||||||
|
const dataBase64 = mintMsg.dataBase64 ?? '';
|
||||||
|
const mimeType = mintMsg.mimeType ?? 'application/zip';
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
// Node 24 ships URL.createObjectURL + Blob as globals. The
|
||||||
|
// resulting URL has the `blob:nodedata:<uuid>` shape, which
|
||||||
|
// satisfies the `url.startsWith('blob:')` polarity guard.
|
||||||
|
const binary = atob(dataBase64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'DOWNLOAD_URL', requestId, url }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onMsg = stub.runtime.onMessage._callbacks[0];
|
const onMsg = stub.runtime.onMessage._callbacks[0];
|
||||||
onMsg(
|
onMsg(
|
||||||
{ type: 'SAVE_ARCHIVE' },
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
@@ -372,6 +421,7 @@ async function runSaveAndCaptureDownloadArg(
|
|||||||
const DRAIN_ITERATIONS = 1_500;
|
const DRAIN_ITERATIONS = 1_500;
|
||||||
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
||||||
tryFireBuffer();
|
tryFireBuffer();
|
||||||
|
tryFireDownloadUrl();
|
||||||
if (stub.downloads.download.mock.calls.length > 0) break;
|
if (stub.downloads.download.mock.calls.length > 0) break;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
@@ -509,6 +559,12 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
|
|||||||
);
|
);
|
||||||
|
|
||||||
let buffered = false;
|
let buffered = false;
|
||||||
|
// Plan 02-02 (D-P2-01): mirror runSaveAndCaptureDownloadArg's
|
||||||
|
// offscreen-side CREATE_DOWNLOAD_URL handler so the SW's
|
||||||
|
// downloadArchive can complete the bridge round-trip. The 6 MB
|
||||||
|
// archive's base64 payload is sizeable (~8 MB after encoding); the
|
||||||
|
// local URL.createObjectURL mint is still essentially free.
|
||||||
|
const mintedRequestIds = new Set<string>();
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const DRAIN_ITERATIONS = 1_500;
|
const DRAIN_ITERATIONS = 1_500;
|
||||||
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
||||||
@@ -531,6 +587,36 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const mintCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
|
||||||
|
);
|
||||||
|
for (const call of mintCalls) {
|
||||||
|
const mintMsg = call[0] as {
|
||||||
|
requestId?: string;
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
const requestId = mintMsg.requestId;
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) continue;
|
||||||
|
if (mintedRequestIds.has(requestId)) continue;
|
||||||
|
mintedRequestIds.add(requestId);
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const binary = atob(mintMsg.dataBase64 ?? '');
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let k = 0; k < binary.length; k++) bytes[k] = binary.charCodeAt(k);
|
||||||
|
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'DOWNLOAD_URL', requestId, url }),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (stub.downloads.download.mock.calls.length > 0) break;
|
if (stub.downloads.download.mock.calls.length > 0) break;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
@@ -580,6 +666,24 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
|
|||||||
|
|
||||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Plan 02-02 (D-P2-01): the real offscreen handler in
|
||||||
|
// src/offscreen/recorder.ts calls URL.revokeObjectURL on receipt of
|
||||||
|
// REVOKE_DOWNLOAD_URL. The test stub does not load recorder.ts, so
|
||||||
|
// we route the SW-posted REVOKE_DOWNLOAD_URL through revokeSpy here
|
||||||
|
// to model the offscreen-side behaviour faithfully.
|
||||||
|
port.postMessage.mockImplementation((msg: unknown) => {
|
||||||
|
if (
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
(msg as { type?: unknown }).type === 'REVOKE_DOWNLOAD_URL'
|
||||||
|
) {
|
||||||
|
const url = (msg as { url?: unknown }).url;
|
||||||
|
if (typeof url === 'string' && url.length > 0) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
|
const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
|
||||||
expect(
|
expect(
|
||||||
@@ -590,9 +694,10 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
|
|||||||
const mintedUrl = downloadArg!.url;
|
const mintedUrl = downloadArg!.url;
|
||||||
const downloadId = (await stub.downloads.download.mock.results[0].value) as number;
|
const downloadId = (await stub.downloads.download.mock.results[0].value) as number;
|
||||||
|
|
||||||
// RED today: chrome.downloads.onChanged._callbacks is empty (no
|
// GREEN under Plan 02-02: downloadArchive registers a chrome.downloads
|
||||||
// listener registered by current downloadArchive); the forEach
|
// .onChanged listener at module init that posts REVOKE_DOWNLOAD_URL
|
||||||
// is a no-op; revokeSpy stays uncalled; assertion below fails.
|
// back to the offscreen port; our port.postMessage shim above mirrors
|
||||||
|
// that into revokeSpy.
|
||||||
stub.downloads.onChanged._callbacks.forEach((cb) =>
|
stub.downloads.onChanged._callbacks.forEach((cb) =>
|
||||||
cb({ id: downloadId, state: { current: 'complete' } }),
|
cb({ id: downloadId, state: { current: 'complete' } }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -366,6 +366,53 @@ async function runSaveAndCaptureArchiveBlob(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
|
||||||
|
// handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
|
||||||
|
// base64; we mint a Node-native blob: URL via URL.createObjectURL and
|
||||||
|
// reply DOWNLOAD_URL{requestId,url}. The archive bytes are captured
|
||||||
|
// here BEFORE minting so meta.json extraction works regardless of
|
||||||
|
// whether the SW ever calls chrome.downloads.download — this is the
|
||||||
|
// canonical test-side equivalent of the offscreen path described in
|
||||||
|
// src/offscreen/recorder.ts handleCreateDownloadUrl.
|
||||||
|
const mintedRequestIds = new Set<string>();
|
||||||
|
let capturedArchiveBytes: Uint8Array | null = null;
|
||||||
|
const tryFireDownloadUrl = () => {
|
||||||
|
const mintCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
|
||||||
|
);
|
||||||
|
for (const call of mintCalls) {
|
||||||
|
const mintMsg = call[0] as {
|
||||||
|
requestId?: string;
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
const requestId = mintMsg.requestId;
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) continue;
|
||||||
|
if (mintedRequestIds.has(requestId)) continue;
|
||||||
|
mintedRequestIds.add(requestId);
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const binary = atob(mintMsg.dataBase64 ?? '');
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
// Capture the archive bytes for downstream meta.json extraction.
|
||||||
|
if (capturedArchiveBytes === null) {
|
||||||
|
capturedArchiveBytes = bytes;
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'DOWNLOAD_URL', requestId, url }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onMsg = stub.runtime.onMessage._callbacks[0];
|
const onMsg = stub.runtime.onMessage._callbacks[0];
|
||||||
onMsg(
|
onMsg(
|
||||||
{ type: 'SAVE_ARCHIVE' },
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
@@ -376,24 +423,27 @@ async function runSaveAndCaptureArchiveBlob(
|
|||||||
const DRAIN_ITERATIONS = 1_500;
|
const DRAIN_ITERATIONS = 1_500;
|
||||||
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
||||||
tryFireBuffer();
|
tryFireBuffer();
|
||||||
|
tryFireDownloadUrl();
|
||||||
if (stub.downloads.download.mock.calls.length > 0) break;
|
if (stub.downloads.download.mock.calls.length > 0) break;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stub.downloads.download.mock.calls.length === 0) return undefined;
|
if (stub.downloads.download.mock.calls.length === 0) return undefined;
|
||||||
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
|
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
|
||||||
// Current HEAD: data:application/zip;base64,<base64>. Base64-decode.
|
// Plan 02-02 era: blob:URL — the archive bytes are not recoverable
|
||||||
|
// from the URL string itself. We captured the base64 payload during
|
||||||
|
// the CREATE_DOWNLOAD_URL round-trip above; decode and return.
|
||||||
|
if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
|
||||||
|
return new Blob([capturedArchiveBytes], { type: 'application/zip' });
|
||||||
|
}
|
||||||
|
// Pre-Plan-02-02 path (kept for forward-compat; current HEAD on the
|
||||||
|
// post-Plan-02-02 branch never takes this branch).
|
||||||
const DATA_PREFIX = 'data:application/zip;base64,';
|
const DATA_PREFIX = 'data:application/zip;base64,';
|
||||||
if (arg.url.startsWith(DATA_PREFIX)) {
|
if (arg.url.startsWith(DATA_PREFIX)) {
|
||||||
const b64 = arg.url.substring(DATA_PREFIX.length);
|
const b64 = arg.url.substring(DATA_PREFIX.length);
|
||||||
const bytes = Buffer.from(b64, 'base64');
|
const bytes = Buffer.from(b64, 'base64');
|
||||||
return new Blob([bytes], { type: 'application/zip' });
|
return new Blob([bytes], { type: 'application/zip' });
|
||||||
}
|
}
|
||||||
// Plan 02-02 era: blob: URL. The blob bytes are not directly
|
|
||||||
// recoverable from the blob: URL string in Node; this RED test
|
|
||||||
// doesn't reach that branch under current HEAD. The Plan 02-03 GREEN
|
|
||||||
// implementer will likely need a different helper (e.g. spy on
|
|
||||||
// URL.createObjectURL to capture the underlying Blob reference).
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,50 @@ async function runAndParseMetaJson(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
|
||||||
|
// handler and capture the archive bytes from the port message itself
|
||||||
|
// (the SW posts the base64-encoded payload before the URL is minted).
|
||||||
|
// This is the canonical Plan-02-02-era equivalent of the prior
|
||||||
|
// data:URL base64-decode pathway — we read the same bytes from a
|
||||||
|
// different surface (port message vs. download URL).
|
||||||
|
const mintedRequestIds = new Set<string>();
|
||||||
|
let capturedArchiveBytes: Uint8Array | null = null;
|
||||||
|
const tryFireDownloadUrl = () => {
|
||||||
|
const mintCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
|
||||||
|
);
|
||||||
|
for (const call of mintCalls) {
|
||||||
|
const mintMsg = call[0] as {
|
||||||
|
requestId?: string;
|
||||||
|
dataBase64?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
const requestId = mintMsg.requestId;
|
||||||
|
if (typeof requestId !== 'string' || requestId.length === 0) continue;
|
||||||
|
if (mintedRequestIds.has(requestId)) continue;
|
||||||
|
mintedRequestIds.add(requestId);
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const binary = atob(mintMsg.dataBase64 ?? '');
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
if (capturedArchiveBytes === null) {
|
||||||
|
capturedArchiveBytes = bytes;
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
url = '';
|
||||||
|
}
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'DOWNLOAD_URL', requestId, url }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onMsg = stub.runtime.onMessage._callbacks[0];
|
const onMsg = stub.runtime.onMessage._callbacks[0];
|
||||||
onMsg(
|
onMsg(
|
||||||
{ type: 'SAVE_ARCHIVE' },
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
@@ -385,23 +429,27 @@ async function runAndParseMetaJson(
|
|||||||
const DRAIN_ITERATIONS = 1_500;
|
const DRAIN_ITERATIONS = 1_500;
|
||||||
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
|
||||||
tryFireBuffer();
|
tryFireBuffer();
|
||||||
|
tryFireDownloadUrl();
|
||||||
if (stub.downloads.download.mock.calls.length > 0) break;
|
if (stub.downloads.download.mock.calls.length > 0) break;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stub.downloads.download.mock.calls.length === 0) return undefined;
|
if (stub.downloads.download.mock.calls.length === 0) return undefined;
|
||||||
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
|
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
|
||||||
|
// Plan 02-02 era: extract archive bytes from the captured port
|
||||||
|
// payload, not from the blob: URL string (which is opaque in Node).
|
||||||
|
let archiveBytes: Uint8Array | null = null;
|
||||||
|
if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
|
||||||
|
archiveBytes = capturedArchiveBytes;
|
||||||
|
} else {
|
||||||
const DATA_PREFIX = 'data:application/zip;base64,';
|
const DATA_PREFIX = 'data:application/zip;base64,';
|
||||||
if (!arg.url.startsWith(DATA_PREFIX)) {
|
if (arg.url.startsWith(DATA_PREFIX)) {
|
||||||
// Plan 02-02 era: blob: URL. Plan 02-03 implementer will need a
|
|
||||||
// different harness (e.g. spy on URL.createObjectURL to capture
|
|
||||||
// the underlying Blob reference). This RED test runs against the
|
|
||||||
// current data: URL path.
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const b64 = arg.url.substring(DATA_PREFIX.length);
|
const b64 = arg.url.substring(DATA_PREFIX.length);
|
||||||
const bytes = Buffer.from(b64, 'base64');
|
archiveBytes = Buffer.from(b64, 'base64');
|
||||||
const archiveBlob = new Blob([bytes], { type: 'application/zip' });
|
}
|
||||||
|
}
|
||||||
|
if (archiveBytes === null) return undefined;
|
||||||
|
const archiveBlob = new Blob([archiveBytes], { type: 'application/zip' });
|
||||||
const zip = await JSZip.loadAsync(archiveBlob);
|
const zip = await JSZip.loadAsync(archiveBlob);
|
||||||
const metaEntry = zip.file('meta.json');
|
const metaEntry = zip.file('meta.json');
|
||||||
if (!metaEntry) return undefined;
|
if (!metaEntry) return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user