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]>
161 lines
8.2 KiB
Markdown
161 lines
8.2 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)
|
||
- `~/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
|
||
`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.
|