Files
vdm/docs/05-extension-spec.md
samiandClaude Sonnet 5 03b6253b5a ext: content/ media detection (build order step 7)
Two independent signals feed one panel, per docs/05 §3:
- capture/media.ts (background): webRequest-based, recognizes .m3u8/.mpd
  URLs and the HLS/DASH content types, deduped per (tab, url) so an
  HLS live-refresh doesn't re-fire. media-bridge.ts relays a hit to the
  tab's content script over runtime.sendMessage, and separately answers
  the content script's media.listVariants/media.addVariant calls by
  forwarding them to the background page's transport.
- content/media-observer.ts: watches the page's own <video> elements
  (present now, added later, or with src changed) for the same
  extension signal, independent of what the network sniffer saw.

Either firing opens content/video-panel.ts's "Download this video ▾"
panel (built with createElement, matching the popup's innerHTML-free
approach), which lists variants from media.listVariants and greys out
any variant.drm or a wholly drmProtected manifest with "Protected
content" rather than attempting it. The extension still never parses a
manifest itself — that stays in the daemon, one language, one place.

content/index.ts is the manifest-registered entry (content_scripts in
manifest.json, added in the previous commit); build.mjs builds it as an
IIFE rather than ESM, since a manifest content script has no "type":
"module" declaration and an emitted top-level export would be a syntax
error there. tsconfig.json adds DOM.Iterable for NodeList iteration.

docs/05-extension-spec.md gets a short addendum (§8) documenting the
popup/options bridge and this media-detection split, since neither was
in the original design write-up.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
2026-09-11 12:26:48 +04:00

11 KiB
Raw Permalink Blame History

05 — Firefox extension specification

Owner: lane EXT. TypeScript, MV3, browser.* promise APIs, built with esbuild, packaged with web-ext.

1. Why Firefox MV3 is actually good news here

Chrome's MV3 removed blocking webRequest, which is why IDM-style "grab the download before the browser starts it" is hard there. Firefox kept blocking webRequest in MV3. That means we can inspect response headers and cancel the browser's own download, which is exactly the interception model IDM uses. Build for Firefox first and do not compromise the design to stay Chrome-portable.

2. Capture pipeline

onBeforeSendHeaders ──► stash request headers by requestId (ring buffer, 5 min TTL)
        │
onHeadersReceived ───► shouldCapture(details, headers, settings)?
        │                       │
        │                    yes│
        │                       ▼
        │              cookies.getAll(url) ──► transport.send("capture.offer", {...})
        │                       │
        │              daemon replies {action:"take", taskId}
        │                       ▼
        └──────────────► return {cancel: true}   ← browser never starts the download

shouldCapture returns true when any of:

  • Content-Disposition: attachment present, or
  • the file extension is in the user's monitored list (Options → File Types, mirrored from the daemon so the two never disagree), or
  • Content-Type is in the monitored MIME list and not text/html, or
  • Content-Length > minSizeBytes (default 1 MiB) and the type is not renderable.

And none of:

  • the tab is a blob:/data: URL we generated,
  • the host is on the user's exclusion list,
  • the response is a navigation to an HTML page,
  • the user held the bypass modifier (default: Alt) on the click.

Fail-open, always. If the daemon is unreachable or the RPC times out (750 ms budget), return {} and let Firefox download it normally. A download manager that eats downloads when its daemon is down is worse than no download manager. This rule is non-negotiable and has a dedicated conformance test.

Belt-and-braces second path: browser.downloads.onCreated → if it slipped past the header hook, downloads.cancel(id) + downloads.erase(id) and offer to the daemon. Some downloads (form POSTs, service-worker-generated blobs) only surface here.

3. Other surfaces

Surface Behaviour
Context menu (link) "Download with Velox"
Context menu (image/video/audio) "Download with Velox"
Context menu (page/selection) "Download all links with Velox…" → opens the batch dialog with the harvested list
Toolbar popup Active downloads with live progress (via event.task.progress relayed over the transport), pause/resume, "Add URL", speed indicator, daemon status dot
Media panel Floating in-page button on a tab where a video/HLS/DASH stream was detected — "Download this video ▾" listing quality variants
Options page Transport & pairing · monitored types · min size · exclusion list · default category/folder · bypass modifier · enable/disable capture
Keyboard Configurable command to grab the current tab's URL

Media detection: webRequest sniffing for .m3u8, .mpd, Content-Type: application/vnd.apple.mpegurl / dash+xml, plus a content script observing MediaSource.addSourceBuffer and <video> src changes. The extension sends the manifest URL + headers to the daemon; the daemon parses the manifest and enumerates variants (media.listVariants). The extension never parses HLS — keep the logic in one language.

DRM-protected streams (Widevine/EME) are explicitly out of scope. Detect them and grey out the button with "Protected content" rather than failing mysteriously.

4. Transports — the reason this design has two

Evidence from this machine: Firefox here is the Mozilla snap (snap list firefox154.0, mozilla**). Snap-confined Firefox has a long history of trouble executing native-messaging host binaries that live outside the snap's world. Meanwhile snap connections firefox shows network and network-bind connected.

So the extension implements a Transport interface with two implementations and picks at runtime:

interface Transport {
  connect(): Promise<void>
  call<M extends Method>(m: M, p: Params<M>): Promise<Result<M>>
  on(event: string, cb: (p: unknown) => void): void
  readonly state: "connected" | "connecting" | "disconnected"
}

A. NativeTransport (preferred when it works)

browser.runtime.connectNative("com.velox.host")velox-nmhost → Unix socket. Manifest installed to all of these, because the right one depends on how Firefox was installed:

  • ~/.mozilla/native-messaging-hosts/com.velox.host.json (deb/tarball and snap — spike S1 proved snap Firefox reads the real home here, not $SNAP_USER_COMMON; see docs/adr/0003-native-messaging-under-snap.md)
  • /usr/lib/mozilla/native-messaging-hosts/com.velox.host.json (system-wide; deb/tarball only — snap Firefox does not read it, per S1)
  • ~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/ (flatpak)

The host binary runs outside the snap sandbox with the real $HOME and real $XDG_RUNTIME_DIR, so it reaches veloxd's Unix socket directly (ADR 0003 §Q2).

B. WebSocketTransport (fallback, guaranteed to work under snap confinement)

ws://127.0.0.1:<port> where the port is discovered by trying a small fixed range and verifying a session.hello handshake — the extension cannot read $XDG_RUNTIME_DIR/velox/ws.port, so the daemon binds the first free port in 5200052016 and the extension probes them.

Pairing (first run only): the extension connects and calls session.pair. The daemon pops a GUI/desktop-notification dialog: "Firefox is requesting to connect to Velox. Pairing code: 4821. [Allow] [Deny]". The user clicks Allow (or types the code in the extension options if the GUI isn't running). The daemon returns a 256-bit token; the extension stores it in browser.storage.local and sends it on every subsequent connect.

Security requirements (non-negotiable, conformance-tested):

  • Bind 127.0.0.1 only — never 0.0.0.0.
  • Verify Origin: moz-extension://… on the WS upgrade, and require the token.
  • Rate-limit failed auth (5/min, then a 60 s lockout) so the token can't be brute-forced by another local process.
  • Token is per-install, revocable from Options → "Unpair", and stored in the daemon's DB as a hash, not plaintext.
  • Never expose a method that can write to an arbitrary path without a task the user approved. The extension can request a download; it cannot ask the daemon to write to ~/.bashrc.

5. Startup UX when the daemon is missing

Popup shows a red dot and: "Velox isn't running. [Start it] [Install]". [Start it] tries session.hello again after asking the native host to spawn the daemon; if the native transport is unavailable, link to install instructions. Capture stays fail-open throughout — Firefox keeps downloading normally.

6. Build & test

extension/
├── manifest.json                 # MV3, permissions listed and justified in a comment
├── src/background/index.ts       # event page entry
│   ├── capture/{headers,rules,downloads-api,media}.ts
│   ├── transport/{index,native,websocket,discovery}.ts
│   ├── context-menus.ts  badge.ts  state.ts
├── src/content/{media-observer,link-harvest,video-panel}.ts
├── src/popup/  src/options/      # plain TS + minimal CSS; no framework needed
└── src/shared/protocol/          # GENERATED from contracts/ — never hand-edit
  • Unit: vitest with webextension-polyfill mocked; every shouldCapture decision gets a table-driven test (this is where the bugs will be).
  • Integration: against tools/mockd over WebSocket.
  • E2E: Playwright with a real Firefox and a real veloxd, asserting a real file lands on disk with the right bytes. Lives in tests/e2e/.
  • Lint: ESLint + web-ext lint (AMO rules) in CI from day one — finding out at submission time that a permission is disallowed costs a week.

7. Permissions (keep this list short; AMO reviews it)

webRequest, webRequestBlocking, downloads, cookies, contextMenus, storage, notifications, nativeMessaging, <all_urls>.

<all_urls> is unavoidable for a download manager but is the main review-friction item: document why in the AMO submission notes and in the source, and make the exclusion list prominent in Options. The submission-ready copy of this table lives in extension/docs/amo-permissions.md, committed alongside the manifest it explains.

8. Popup and Options talk to the background page over a relay, not directly

popup/ and options/ are separate documents (a browser action popup and an options_ui page) — they cannot import background/index.ts's live VeloxTransport. background/bridge.ts relays it over one browser.runtime.connect port per document: call/subscribe/getStatus/reconnect/pair/unpair/setOverride requests in, result/event/status/pairError responses out. shared/panel-client.ts is the client side both surfaces use. The status payload carries kind (which transport implementation is live) alongside the existing TransportStatus, because Options needs it: settings.set and rules.upsert are privileged, uds-only methods (see shared/protocol/methods.ts's METHODS table), so the capture-policy edit form only ever unlocks when the active transport is native messaging. Over WebSocket — the default, guaranteed path per ADR 0003 — Options renders the daemon's policy read-only via capture.getRules rather than pretending it can write settings the protocol refuses over that transport. The one Options setting that genuinely belongs to the extension (not the daemon) — the default category applied to extension-initiated downloads — lives in browser.storage.local via options/prefs.ts, the same place the pairing token and transport override already live.

Streaming-media detection (build step 7) also splits down this line: capture/media.ts (background, webRequest-based) recognizes .m3u8/.mpd URLs and their content types and tells the tab's content script over runtime.sendMessage (background/media-bridge.ts); content/media-observer.ts independently watches the page's own <video> elements for the same signal. Either one showing up opens content/video-panel.ts's "Download this video ▾" panel, which calls media.listVariants/media.addVariant through the background page and greys out any variant (or the whole manifest) flagged drm/drmProtected with "Protected content" — the extension never parses the manifest itself.