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
196 lines
11 KiB
Markdown
196 lines
11 KiB
Markdown
# 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 firefox`
|
||
→ `154.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:
|
||
|
||
```ts
|
||
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
|
||
`52000–52016` 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.
|