Files
vdm/docs/05-extension-spec.md
T
samiandClaude Opus 5 8bb683b09d scaffold: project structure, wire contract, roadmap and agent briefs
Lays out Velox Download Manager (IDM-class download manager for Ubuntu
26.04) as a monorepo ready for parallel lane development. No implementation
code by design.

- docs/: architecture, roadmap M0-M7, IDM-parity GUI spec, engine design,
  Firefox extension spec, risks/spikes, packaging
- contracts/: wire-contract skeleton (JSON Schema + fixture templates) —
  the single synchronization point between lanes
- docs/agents/: one brief per lane (PROTO, CORE, DAEMON, GUI, EXT, PKG/QA)
  with owned directories, build order and definition of done
- CLAUDE.md: rules of engagement — lane ownership, layering, non-negotiables
- CMake scaffolding with dev/tsan/release/ci presets

Two environment findings shape the design: Firefox here is the Mozilla snap
(native-messaging risk, so the extension carries a loopback-WebSocket
fallback), and Wayland forbids passive clipboard monitoring (so clipboard
capture is explicit-action-first).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 18:21:11 +04:00

161 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
- `~/snap/firefox/common/.mozilla/native-messaging-hosts/com.velox.host.json` (snap)
- `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` (system-wide)
- `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/` (flatpak)
### 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.