merge: lane/ext

This commit is contained in:
2026-09-11 16:50:41 +04:00
39 changed files with 3524 additions and 178 deletions
+6 -1
View File
@@ -105,11 +105,16 @@ jobs:
if: steps.check.outputs.present == 'true'
with:
node-version: '22'
- name: web-ext lint
- name: eslint (no-download-logic gate + general rules)
if: steps.check.outputs.present == 'true'
working-directory: extension
run: |
npm ci
npx eslint .
- name: web-ext lint
if: steps.check.outputs.present == 'true'
working-directory: extension
run: |
npx web-ext lint --source-dir .
# --- build + test matrix ----------------------------------------------------------
+31 -1
View File
@@ -162,4 +162,34 @@ extension/
`<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.
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.
+49
View File
@@ -0,0 +1,49 @@
# AMO permission justification
Submitted with every version bump in the AMO "Notes to Reviewer" field. Kept here so the
justification is reviewed and versioned alongside the permission list itself
(`manifest.json`), instead of living only in a web form. docs/05 §7 is the design-time
version of this; this file is the submission-ready copy.
## What Velox is
A download manager. It intercepts a response Firefox is about to download, hands the
URL, request headers, and cookies to a companion native application (`veloxd`), and lets
that application fetch the file with resumable, multi-connection transfers. The
extension itself never stores or transfers file bytes — see `CLAUDE.md` §3 and the
`no-download-logic` ESLint gate (`eslint.config.mjs`) that fails CI if it ever does.
## Permissions requested
| Permission | Why | Narrowest alternative considered |
|---|---|---|
| `webRequest` + `webRequestBlocking` | The whole feature: inspect response headers on `onHeadersReceived` to decide whether to intercept a download, and `{cancel: true}` before Firefox starts its own download. This is the one thing MV3 Chrome removed and MV3 Firefox kept — it's why the extension can exist as designed (docs/05 §1). | None. Without blocking `webRequest` there is no way to stop Firefox's own download before it starts; polling `downloads.onCreated` alone (which we also use, see below) only catches what already started. |
| `downloads` | Belt-and-braces safety net (`capture/downloads-api.ts`): some downloads (form POSTs, service-worker blobs) never reach `onHeadersReceived` in a way we can act on and only surface via `downloads.onCreated`. Also used to `cancel`/`erase` a download we're taking over so Firefox doesn't keep two copies. | Drop the safety net and accept that those cases silently bypass Velox. Rejected — docs/05 §2 calls this out explicitly as a known gap the safety net exists to close. |
| `cookies` | A file behind a login (private CDN links, forum attachments) needs its session cookies handed to `veloxd`, or the daemon's fetch gets a 403 the browser's own request wouldn't have. Read via `cookies.getAll(url)` only for a URL we are about to offer to the daemon — never harvested in bulk or logged. | Skip cookies and only support anonymous URLs. Rejected — it's a top user-facing IDM-parity feature and the reason people leave Chrome download managers behind. |
| `contextMenus` | "Download with Velox" on a link/image/video, and "Download all links…" (docs/05 §3). Table-stakes UI for a download manager extension. | None smaller — there's no partial grant for context menus. |
| `storage` | `browser.storage.local` holds only extension-local state: the WebSocket pairing token, the manual transport override, the last-good WS port, and the user's default-category preference (`transport/storage.ts`, `options/prefs.ts`). No browsing data. | None — some persistence is required for pairing to survive a restart (M1 DoD), which is the point of the token existing at all. |
| `notifications` | Tells the user when the daemon can't be reached for a download that fell back to Firefox, and (native-messaging path) surfaces pairing prompts if the GUI isn't running. | Silent failure. Rejected — capture fails open by design (CLAUDE.md §4) and a silent fallback with no notification would look like a bug. |
| `nativeMessaging` | Opportunistic transport to `veloxd` over a Unix socket, for installs where it works (docs/adr/0003). Not the default path — WebSocket is — but shipped because it avoids the WebSocket port-scan on installs where the native host manifest is reachable. | Drop native messaging and use WebSocket exclusively. Considered and rejected in ADR 0003: keeping both means the extension keeps working across deb/snap/flatpak Firefox without per-flavour capture-logic forks. |
| `<all_urls>` (host permission) | Downloads happen from every site on the web; `webRequest`'s header inspection and `cookies.getAll` both need to run against whatever site the user is on. This is the item AMO reviewers push back on hardest for extensions of this shape. | A fixed list of "known download sites" — unworkable for a general-purpose download manager, and defeats the point of an IDM-style interceptor. **Mitigation, not a narrower permission:** the exclusion list in Options is front-and-center (`options.html` → "Capture policy") so a user can scope capture down to nothing on sites they don't want Velox touching, and the bypass modifier (default Alt) lets a single click skip capture without changing settings. |
## What is explicitly *not* requested
- No `<all_urls>` XHR/fetch use — `webRequest`/`cookies` read metadata about a request
Firefox is already making; the extension never issues its own network request for
file bytes (enforced by the ESLint gate above).
- No `identity`, `history`, `bookmarks`, `tabs` beyond what `contextMenus`/`commands`
already imply, `management`, or any permission unrelated to capturing and handing off
a download.
- No remote code: the manifest ships no CDN scripts and no `eval`; `web-ext lint` fails
the build otherwise (CI's `extension-lint` job).
## Data handling
- Cookies and headers are held in memory only long enough to answer one
`capture.offer` call to the local daemon (`capture/headers.ts`'s ring buffer, 5-minute
TTL) — never written to disk by the extension and never sent anywhere but
`127.0.0.1`.
- The daemon connection is local-only: `WebSocketTransport` connects to
`ws://127.0.0.1:<port>`, never a remote host (docs/05 §4, conformance-tested).
- Nothing is sent to Anthropic, Mozilla, or any third party beyond the user's own local
`veloxd` process.
+90
View File
@@ -0,0 +1,90 @@
// ESLint config for the extension.
//
// The "no download logic in extension/" rule (CLAUDE.md §3) used to be prose only.
// GUI turned its half into a ctest (gui/tests/no_download_logic.cmake); this is EXT's
// equivalent — a build-failing gate instead of something a reviewer has to remember to
// look for. See gui/docs/ext-requests-m1.md for the request this answers.
//
// The extension's whole job is: collect URL + headers + cookies, hand them to veloxd,
// render what comes back. It must never fetch bytes, assemble a Range request, or read
// a response body itself — that is download logic, and it belongs in core/daemon only.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
const noDownloadLogic = {
name: 'velox/no-download-logic',
files: ['src/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "NewExpression[callee.name='XMLHttpRequest']",
message:
'No XMLHttpRequest in extension/ — the extension hands URLs to veloxd, it never fetches bytes itself (CLAUDE.md §3).',
},
{
selector: "NewExpression[callee.name='Request']",
message:
'No `new Request(...)` in extension/ — that is download-side plumbing. Hand the URL to veloxd instead (CLAUDE.md §3).',
},
{
selector: "CallExpression[callee.name='fetch']",
message:
'No fetch() in extension/ — the extension never retrieves download bytes itself (CLAUDE.md §3). Talking to veloxd goes through transport/, not fetch.',
},
{
selector: "MemberExpression[property.name='getReader']",
message:
'No ReadableStream reader in extension/ — reading a response body here is download logic (CLAUDE.md §3); the daemon owns transfer bytes.',
},
{
selector:
"Property[key.name='Range'], Property[key.name='range'], Property[key.name='Content-Range'], Property[key.name='content-range']",
message:
'No Range/Content-Range header construction in extension/ — resumption is the daemon\'s job (CLAUDE.md §3, docs/05).',
},
{
selector: "NewExpression[callee.object.name='indexedDB'], CallExpression[callee.object.name='indexedDB']",
message: 'No IndexedDB in extension/ for moving bytes — hand off to veloxd instead (CLAUDE.md §3).',
},
],
'no-restricted-globals': [
'error',
{ name: 'fetch', message: 'No fetch() in extension/ — see CLAUDE.md §3.' },
{ name: 'XMLHttpRequest', message: 'No XMLHttpRequest in extension/ — see CLAUDE.md §3.' },
{ name: 'indexedDB', message: 'No IndexedDB in extension/ — see CLAUDE.md §3.' },
],
},
};
export default tseslint.config(
{
// src/shared/protocol/** is generated (contracts/codegen/gen_ts.py) and must never
// be hand-edited — linting it as if we could fix a finding would be a lie.
ignores: ['dist/**', 'node_modules/**', 'scripts/**', 'src/shared/protocol/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
parserOptions: {
project: false,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
noDownloadLogic,
{
// Tests legitimately construct fake Requests/fetch mocks to exercise transport code
// against a fake server; the rule protects src/, not the harness that pokes at it.
files: ['tests/**/*.ts'],
rules: {
'no-restricted-syntax': 'off',
'no-restricted-globals': 'off',
},
},
);
+19
View File
@@ -19,6 +19,25 @@
"type": "module"
},
"action": {
"default_popup": "dist/popup.html",
"default_title": "Velox"
},
"options_ui": {
"page": "dist/options.html",
"open_in_tab": true
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["dist/content.js"],
"run_at": "document_idle",
"all_frames": true
}
],
"permissions": [
"webRequest",
"webRequestBlocking",
+920 -160
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -10,13 +10,18 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"lint": "web-ext lint --source-dir ."
"lint": "npm run lint:eslint && npm run lint:webext",
"lint:eslint": "eslint .",
"lint:webext": "web-ext lint --source-dir ."
},
"devDependencies": {
"@types/firefox-webext-browser": "^120.0.4",
"@types/ws": "^8.5.12",
"esbuild": "^0.24.0",
"eslint": "^9.39.5",
"happy-dom": "^15.11.7",
"typescript": "^5.6.0",
"typescript-eslint": "^8.70.0",
"vitest": "^2.1.0",
"web-ext": "^8.3.0",
"ws": "^8.18.0"
+32 -9
View File
@@ -5,29 +5,46 @@
// build step. Firefox-only: no polyfill, native ESM, `browser.*` is a global.
import { build } from 'esbuild';
import { rm, mkdir } from 'node:fs/promises';
import { rm, mkdir, copyFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outdir = resolve(root, 'dist');
// One entry per manifest surface. Add popup/options/content here as they land.
const entryPoints = {
// The background page and the popup/options documents load as native ESM (manifest.json
// declares "type": "module" for the background script; the popup/options HTML load
// their script with type="module"). Content scripts registered via manifest.json's
// content_scripts have no such declaration and run as classic scripts, so content.js is
// built as an IIFE instead — an `export` left in by the esm build would be a syntax
// error there.
const esmEntryPoints = {
background: resolve(root, 'src/background/index.ts'),
popup: resolve(root, 'src/popup/popup.ts'),
options: resolve(root, 'src/options/options.ts'),
};
const iifeEntryPoints = {
content: resolve(root, 'src/content/index.ts'),
};
// Static HTML/CSS esbuild doesn't touch — copied straight to dist/ alongside their JS.
const staticFiles = [
['src/popup/popup.html', 'popup.html'],
['src/popup/popup.css', 'popup.css'],
['src/options/options.html', 'options.html'],
['src/options/options.css', 'options.css'],
];
await rm(outdir, { recursive: true, force: true });
await mkdir(outdir, { recursive: true });
await Promise.all(staticFiles.map(([src, dest]) => copyFile(resolve(root, src), resolve(outdir, dest))));
const watch = process.argv.includes('--watch');
const dev = watch || process.argv.includes('--dev');
const options = {
entryPoints,
const shared = {
outdir,
bundle: true,
format: 'esm',
target: ['firefox128'],
platform: 'browser',
sourcemap: dev ? 'inline' : 'linked',
@@ -37,10 +54,16 @@ const options = {
external: [],
};
const buildConfigs = [
{ ...shared, entryPoints: esmEntryPoints, format: 'esm' },
{ ...shared, entryPoints: iifeEntryPoints, format: 'iife' },
];
if (watch) {
const ctx = await (await import('esbuild')).context(options);
await ctx.watch();
const { context } = await import('esbuild');
const contexts = await Promise.all(buildConfigs.map((cfg) => context(cfg)));
await Promise.all(contexts.map((ctx) => ctx.watch()));
console.log('esbuild: watching');
} else {
await build(options);
await Promise.all(buildConfigs.map((cfg) => build(cfg)));
}
+196
View File
@@ -0,0 +1,196 @@
// Relays the background page's one VeloxTransport to the popup and options documents,
// which run as separate contexts and cannot import background/index.ts directly.
//
// Wire protocol over a `browser.runtime.connect` port (see docs/05 §3: "live progress
// via event.task.progress relayed over the transport"):
// client -> bg { type: 'call', id, method, params }
// client -> bg { type: 'subscribe', events: string[] } (replaces prior selection)
// client -> bg { type: 'getStatus' }
// bg -> client { type: 'result', id, ok: true, result } | { type: 'result', id, ok: false, error }
// bg -> client { type: 'event', event, payload }
// bg -> client { type: 'status', status }
//
// This is plain message relaying, not download logic: no bytes, no URLs fetched here,
// just RPC forwarding to the transport the background page already owns.
import type { EventName, MethodName } from '../shared/protocol/index.js';
import type { TransportKind, TransportStatus, VeloxTransport } from './transport/index.js';
/** TransportStatus plus which implementation is live Options needs `kind` to know
* whether pairing controls and privileged settings.set are meaningful right now. */
export type PanelStatus = TransportStatus & { kind: TransportKind | null };
export type PanelRequest =
| { type: 'call'; id: number; method: MethodName; params: unknown }
| { type: 'subscribe'; events: EventName[] }
| { type: 'getStatus' }
| { type: 'reconnect' }
| { type: 'pair'; code: string }
| { type: 'unpair' }
| { type: 'setOverride'; override: 'auto' | 'ws' | 'uds' };
export type PanelResponse =
| { type: 'result'; id: number; ok: true; result: unknown }
| { type: 'result'; id: number; ok: false; error: { code?: number; message: string } }
| { type: 'event'; event: string; payload: unknown }
| { type: 'status'; status: PanelStatus }
| { type: 'pairError'; error: { code?: number; message: string } };
export interface PortLike {
name: string;
postMessage(message: PanelResponse): void;
onMessage: { addListener(cb: (msg: PanelRequest) => void): void };
onDisconnect: { addListener(cb: () => void): void };
}
export interface RuntimeOnConnectLike {
addListener(cb: (port: PortLike) => void): void;
}
const DISCONNECTED_STATUS: PanelStatus = {
state: 'disconnected',
needsPairing: false,
fatal: null,
retryAfterSec: null,
sessionId: null,
daemonVersion: null,
capabilities: [],
kind: null,
};
function panelStatus(t: VeloxTransport | undefined): PanelStatus {
return t ? { ...t.status, kind: t.kind } : DISCONNECTED_STATUS;
}
function errorOf(e: unknown): { code?: number; message: string } {
if (e && typeof e === 'object') {
const code = 'code' in e && typeof (e as { code: unknown }).code === 'number' ? (e as { code: number }).code : undefined;
const message = e instanceof Error ? e.message : String(e);
return code === undefined ? { message } : { code, message };
}
return { message: String(e) };
}
export interface PanelBridgeDeps {
getTransport(): VeloxTransport | undefined;
/** Rebuilds the transport for a new manual override ('auto' lets the runtime picker
* decide again) and swaps it in. Needed because switching kind means constructing a
* different Transport implementation, not a method on the existing one. */
setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void>;
}
export class PanelBridge {
constructor(private readonly deps: PanelBridgeDeps) {}
private getTransport(): VeloxTransport | undefined {
return this.deps.getTransport();
}
attach(onConnect: RuntimeOnConnectLike): void {
onConnect.addListener((port) => this.handleConnect(port));
}
private handleConnect(port: PortLike): void {
const unsubscribe: Array<() => void> = [];
let disposed = false;
const post = (msg: PanelResponse): void => {
if (!disposed) port.postMessage(msg);
};
// The transport may not exist yet (background just woke up). Retry briefly rather
// than leaving the panel stuck on "connecting" forever.
const attachStatus = (attemptsLeft: number): void => {
const t = this.getTransport();
if (t) {
post({ type: 'status', status: panelStatus(t) });
const off = t.onStateChange(() => post({ type: 'status', status: panelStatus(this.getTransport()) }));
unsubscribe.push(off);
return;
}
post({ type: 'status', status: DISCONNECTED_STATUS });
if (attemptsLeft > 0 && !disposed) {
const timer = setTimeout(() => attachStatus(attemptsLeft - 1), 300);
unsubscribe.push(() => clearTimeout(timer));
}
};
attachStatus(10);
port.onMessage.addListener((msg) => void this.handleMessage(msg, post, unsubscribe));
port.onDisconnect.addListener(() => {
disposed = true;
for (const u of unsubscribe) u();
unsubscribe.length = 0;
});
}
private async handleMessage(
msg: PanelRequest,
post: (m: PanelResponse) => void,
unsubscribe: Array<() => void>,
): Promise<void> {
if (msg.type === 'getStatus') {
post({ type: 'status', status: panelStatus(this.getTransport()) });
return;
}
if (msg.type === 'reconnect') {
// "Start it" in the popup (docs/05 §5) — never a fresh call the panel builds
// params for itself; it just asks the transport it already owns to try again.
this.getTransport()
?.connect()
.catch(() => undefined);
return;
}
if (msg.type === 'pair') {
const t = this.getTransport();
try {
if (!t?.pairWithCode) throw new Error('pairing by code is only available on the WebSocket transport');
await t.pairWithCode(msg.code);
post({ type: 'status', status: panelStatus(t) });
} catch (e) {
post({ type: 'status', status: panelStatus(t) });
post({ type: 'pairError', error: errorOf(e) });
}
return;
}
if (msg.type === 'unpair') {
const t = this.getTransport();
await t?.unpair?.();
post({ type: 'status', status: panelStatus(this.getTransport()) });
return;
}
if (msg.type === 'setOverride') {
await this.deps.setOverride(msg.override);
post({ type: 'status', status: panelStatus(this.getTransport()) });
return;
}
if (msg.type === 'subscribe') {
const t = this.getTransport();
if (!t) return;
for (const event of msg.events) {
const cb = (payload: unknown) => post({ type: 'event', event, payload });
t.on(event, cb);
unsubscribe.push(() => t.off(event, cb));
}
return;
}
// msg.type === 'call'
const t = this.getTransport();
if (!t) {
post({ type: 'result', id: msg.id, ok: false, error: { message: 'transport not ready' } });
return;
}
try {
const result = await t.call(msg.method, msg.params as never);
post({ type: 'result', id: msg.id, ok: true, result });
} catch (e) {
post({ type: 'result', id: msg.id, ok: false, error: errorOf(e) });
}
}
}
+87
View File
@@ -0,0 +1,87 @@
// Streaming-media detection, background half. docs/05 §3: sniff `.m3u8`/`.mpd` and the
// HLS/DASH content types on the wire, then tell the tab's content script a manifest was
// seen. The extension never parses the manifest itself — media.listVariants (daemon-side)
// does that; this module only recognizes "this response is probably a manifest".
const MANIFEST_EXTENSIONS = ['.m3u8', '.mpd'];
const MANIFEST_CONTENT_TYPES = [
'application/vnd.apple.mpegurl',
'application/x-mpegurl',
'audio/mpegurl',
'application/dash+xml',
];
/** Extension check ignores the query string — `manifest.m3u8?token=…` still counts. */
export function isManifestUrl(url: string): boolean {
let pathname: string;
try {
pathname = new URL(url).pathname.toLowerCase();
} catch {
return false;
}
return MANIFEST_EXTENSIONS.some((ext) => pathname.endsWith(ext));
}
export function isManifestContentType(contentType: string | null | undefined): boolean {
if (!contentType) return false;
const base = contentType.split(';')[0]!.trim().toLowerCase();
return MANIFEST_CONTENT_TYPES.includes(base);
}
export function looksLikeManifest(url: string, contentType: string | null | undefined): boolean {
return isManifestUrl(url) || isManifestContentType(contentType);
}
export interface DetectedManifest {
tabId: number;
url: string;
headers: Array<{ name: string; value: string }>;
}
interface HeadersReceivedDetails {
tabId: number;
url: string;
responseHeaders?: Array<{ name: string; value: string }>;
}
export interface MediaWebRequest {
onHeadersReceived: {
addListener(
cb: (details: HeadersReceivedDetails) => void,
filter: { urls: string[]; types?: string[] },
extraInfoSpec?: string[],
): void;
};
}
/**
* Watches responses for HLS/DASH manifests and reports each hit once per (tab, url)
* a page can request the same manifest repeatedly (HLS live-refresh) and the content
* script only needs to hear about it once to show the panel.
*/
export class MediaWatcher {
private readonly seen = new Set<string>();
constructor(private readonly onDetected: (m: DetectedManifest) => void) {}
attach(webRequest: MediaWebRequest): void {
webRequest.onHeadersReceived.addListener(
(details) => this.handle(details),
{ urls: ['<all_urls>'] },
['responseHeaders'],
);
}
private handle(details: HeadersReceivedDetails): void {
if (details.tabId < 0) return; // not a request associated with any tab
const headers = details.responseHeaders ?? [];
const contentType = headers.find((h) => h.name.toLowerCase() === 'content-type')?.value;
if (!looksLikeManifest(details.url, contentType)) return;
const key = `${details.tabId}:${details.url}`;
if (this.seen.has(key)) return;
this.seen.add(key);
this.onDetected({ tabId: details.tabId, url: details.url, headers });
}
}
+26 -5
View File
@@ -4,9 +4,11 @@
// paths: the blocking onHeadersReceived hook and the downloads.onCreated safety net. The
// popup relay and context menus attach here in later steps of the build order.
import { PanelBridge } from './bridge.js';
import { DownloadsSafetyNet, type DownloadsApiLike } from './capture/downloads-api.js';
import { HeaderStash, type WebRequestLike } from './capture/headers.js';
import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js';
import { MediaWatcher, type MediaWebRequest } from './capture/media.js';
import { OfferedUrls } from './capture/offered-urls.js';
import { DEFAULT_CAPTURE_RULES } from './capture/rules.js';
import {
@@ -15,7 +17,8 @@ import {
type MenusLike,
type TabsLike,
} from './context-menus.js';
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
import { MediaBridge, notifyTab } from './media-bridge.js';
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js';
import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js';
let transport: VeloxTransport | undefined;
@@ -78,18 +81,36 @@ function onTransportState(status: TransportStatus): void {
if (status.state === 'connected') void refreshRules();
}
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
await transportStorage.setOverride(override);
transport?.disconnect();
transport = await createTransport({ override });
transport.onStateChange(onTransportState);
transport.on('event.settings.changed', onSettingsChanged);
onTransportState(transport.status);
}
const bridge = new PanelBridge({ getTransport: () => transport, setOverride });
const mediaBridge = new MediaBridge(() => transport);
const mediaWatcher = new MediaWatcher((detected) => notifyTab(browser.tabs, detected));
function onSettingsChanged(payload: unknown): void {
const keys = (payload as { keys?: string[] }).keys ?? [];
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
}
async function start(): Promise<void> {
stash.attach(browser.webRequest as unknown as WebRequestLike);
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
void contextMenus.register();
bridge.attach(browser.runtime.onConnect);
mediaBridge.attach(browser.runtime.onMessage);
mediaWatcher.attach(browser.webRequest as unknown as MediaWebRequest);
transport = await createTransport();
transport.onStateChange(onTransportState);
transport.on('event.settings.changed', (payload) => {
const keys = (payload as { keys?: string[] }).keys ?? [];
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
});
transport.on('event.settings.changed', onSettingsChanged);
onTransportState(transport.status);
}
+55
View File
@@ -0,0 +1,55 @@
// Wires capture/media.ts's manifest detection to the content scripts, and answers the
// two calls the in-page video panel needs (media.listVariants, media.addVariant). A
// separate, simpler channel from bridge.ts's port because content scripts are per-tab
// and use one-shot runtime.sendMessage rather than a long-lived port.
import type { MediaAddVariantParams, MediaListVariantsParams } from '../shared/protocol/index.js';
import type { VeloxTransport } from './transport/index.js';
import type { DetectedManifest } from './capture/media.js';
export type ContentMessage =
| { type: 'velox-list-variants'; params: MediaListVariantsParams }
| { type: 'velox-add-variant'; params: MediaAddVariantParams };
export type ManifestDetectedMessage = { type: 'velox-manifest-detected'; url: string; headers: DetectedManifest['headers'] };
export interface TabsLike {
sendMessage(tabId: number, message: unknown): Promise<unknown>;
}
export interface RuntimeOnMessageLike {
addListener(cb: (msg: ContentMessage, sender: unknown, sendResponse: (r: unknown) => void) => boolean | void): void;
}
/** Tells the tab's content script a manifest was seen, swallowing the "no content
* script listening" error a tab without one (or the daemon's own tab) throws. */
export function notifyTab(tabs: TabsLike, manifest: DetectedManifest): void {
const message: ManifestDetectedMessage = { type: 'velox-manifest-detected', url: manifest.url, headers: manifest.headers };
tabs.sendMessage(manifest.tabId, message).catch(() => undefined);
}
export class MediaBridge {
constructor(private readonly getTransport: () => VeloxTransport | undefined) {}
attach(onMessage: RuntimeOnMessageLike): void {
onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type !== 'velox-list-variants' && msg.type !== 'velox-add-variant') return undefined;
void this.handle(msg).then(sendResponse);
return true; // sendResponse is called asynchronously
});
}
private async handle(msg: ContentMessage): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
const t = this.getTransport();
if (!t) return { ok: false, error: 'transport not ready' };
try {
const result =
msg.type === 'velox-list-variants'
? await t.call('media.listVariants', msg.params)
: await t.call('media.addVariant', msg.params);
return { ok: true, result };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
}
@@ -62,6 +62,11 @@ export interface VeloxTransport {
/** Fires on every state change. Returns an unsubscribe. */
onStateChange(cb: (status: TransportStatus) => void): () => void;
/** WebSocket transport only (pairing has no meaning over native messaging's uds
* socket, which has no token). Options renders these controls only when present. */
pairWithCode?(code: string): Promise<void>;
unpair?(): Promise<void>;
}
// --- errors ---------------------------------------------------------------------------
@@ -97,6 +97,9 @@ export class WebSocketTransport implements VeloxTransport {
private stopped = false;
private connectPromise: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Set only for the duration of pairWithCode(); consumed by pair(). docs/05 §4: "the
* user clicks Allow (or types the code in the extension options)." */
private pendingPairCode: string | null = null;
private readonly listeners = new Map<string, Set<EventListener>>();
private readonly stateListeners = new Set<(status: TransportStatus) => void>();
@@ -139,6 +142,29 @@ export class WebSocketTransport implements VeloxTransport {
return this.connectPromise;
}
/**
* Options "Pair" with a code typed from the daemon's dialog, for when the GUI isn't
* running to click Allow (docs/05 §4). Drops any stored token first so the handshake
* takes the pairing branch, then reconnects with the code attached.
*/
async pairWithCode(code: string): Promise<void> {
this.disconnect();
await this.deps.setToken(null); // drop any stale token so the handshake takes the pairing branch
this.pendingPairCode = code;
try {
await this.connect();
} finally {
this.pendingPairCode = null;
}
}
/** Options "Unpair": revoke the local token. The daemon's own record of it is
* cleaned up on its side; this only ever forgets our copy. */
async unpair(): Promise<void> {
await this.deps.setToken(null);
this.disconnect();
}
disconnect(): void {
this.stopped = true;
if (this.reconnectTimer) {
@@ -322,6 +348,7 @@ export class WebSocketTransport implements VeloxTransport {
const params: SessionPairParams = {
clientName: this.clientName,
extensionId: this.deps.extensionId,
code: this.pendingPairCode,
};
try {
const res = (await rpc.request(
+54
View File
@@ -0,0 +1,54 @@
// Content script entry: registered for <all_urls> in manifest.json. Watches for
// streaming media (docs/05 §3) and shows the "Download this video ▾" panel when either
// half of detection fires — the background webRequest sniffer (capture/media.ts, relayed
// via media-bridge.ts) or this page's own <video> elements (media-observer.ts).
//
// No download logic here either: this only asks the background page to call
// media.listVariants / media.addVariant and renders what comes back.
import type { MediaAddVariantResult, MediaListVariantsResult } from '../shared/protocol/index.js';
import { VideoSrcObserver } from './media-observer.js';
import { VideoPanel, type VariantsPort } from './video-panel.js';
interface ManifestDetectedMessage {
type: 'velox-manifest-detected';
url: string;
headers: Array<{ name: string; value: string }>;
}
const headersByManifest = new Map<string, Array<{ name: string; value: string }>>();
const port: VariantsPort = {
async listVariants(manifestUrl) {
const headers = headersByManifest.get(manifestUrl);
const response = (await browser.runtime.sendMessage({
type: 'velox-list-variants',
params: { manifestUrl, headers: headers?.length ? headers : null },
})) as { ok: true; result: MediaListVariantsResult } | { ok: false; error: string };
return response.ok ? response.result : { error: response.error };
},
async addVariant(manifestUrl, variantId) {
const response = (await browser.runtime.sendMessage({
type: 'velox-add-variant',
params: { manifestUrl, variantId },
})) as { ok: true; result: MediaAddVariantResult } | { ok: false; error: string };
return response.ok ? { taskId: response.result.taskId } : { error: response.error };
},
};
const panel = new VideoPanel(port);
function onManifestSeen(url: string): void {
panel.show(url);
}
browser.runtime.onMessage.addListener((msg: unknown) => {
const m = msg as Partial<ManifestDetectedMessage>;
if (m.type !== 'velox-manifest-detected' || typeof m.url !== 'string') return undefined;
headersByManifest.set(m.url, m.headers ?? []);
onManifestSeen(m.url);
return undefined;
});
const observer = new VideoSrcObserver((url) => onManifestSeen(url));
observer.start();
+65
View File
@@ -0,0 +1,65 @@
// DOM half of streaming-media detection (docs/05 §3: "a content script observing
// MediaSource.addSourceBuffer and <video> src changes"). Pure DOM watching, no network:
// the background half (capture/media.ts) is what actually inspects responses. This
// module only recognizes "a <video> on this page points at something that smells like
// an HLS/DASH manifest" and reports the URL up to whoever is watching (content/index.ts).
const MANIFEST_EXTENSIONS = ['.m3u8', '.mpd'];
function looksLikeManifestUrl(url: string): boolean {
try {
const pathname = new URL(url, document.baseURI).pathname.toLowerCase();
return MANIFEST_EXTENSIONS.some((ext) => pathname.endsWith(ext));
} catch {
return false;
}
}
/** Watches every <video> on the page (present now or added/changed later) for a src
* that looks like a manifest URL, and reports each distinct URL once. */
export class VideoSrcObserver {
private readonly seen = new Set<string>();
private mutationObserver: MutationObserver | null = null;
constructor(private readonly onCandidate: (url: string) => void) {}
start(root: ParentNode = document): void {
this.scan(root);
this.mutationObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.target instanceof HTMLVideoElement) {
this.check(m.target);
}
for (const node of m.addedNodes) {
if (node instanceof HTMLVideoElement) this.check(node);
else if (node instanceof Element) this.scan(node);
}
}
});
this.mutationObserver.observe(document.documentElement ?? document, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['src'],
});
}
stop(): void {
this.mutationObserver?.disconnect();
this.mutationObserver = null;
}
private scan(root: ParentNode): void {
for (const video of root.querySelectorAll('video')) this.check(video);
if (root instanceof HTMLVideoElement) this.check(root);
}
private check(video: HTMLVideoElement): void {
const candidates = [video.currentSrc, video.src].filter(Boolean);
for (const url of candidates) {
if (this.seen.has(url) || !looksLikeManifestUrl(url)) continue;
this.seen.add(url);
this.onCandidate(url);
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// In-page "Download this video ▾" panel (docs/05 §3). Lists variants the daemon
// enumerated via media.listVariants — the extension never parses the manifest itself.
// DRM-protected variants (and a wholly DRM-protected manifest) are shown greyed out
// with "Protected content" rather than attempted.
import type { MediaVariant } from '../shared/protocol/index.js';
export interface VariantsPort {
listVariants(manifestUrl: string): Promise<{ variants: MediaVariant[]; drmProtected: boolean } | { error: string }>;
addVariant(manifestUrl: string, variantId: string): Promise<{ taskId: string } | { error: string }>;
}
function variantLabel(v: MediaVariant): string {
const bits = [v.resolution, v.codec, v.bitrateBps ? `${Math.round(v.bitrateBps / 1000)} kbps` : null].filter(Boolean);
return bits.length > 0 ? bits.join(' · ') : v.variantId;
}
const PANEL_ID = 'velox-video-panel';
export class VideoPanel {
private root: HTMLElement | null = null;
constructor(
private readonly port: VariantsPort,
private readonly doc: Document = document,
) {}
/** Shows (or replaces) the floating panel for one detected manifest URL. */
show(manifestUrl: string): void {
this.remove();
const root = this.doc.createElement('div');
root.id = PANEL_ID;
Object.assign(root.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: '2147483647',
font: '13px sans-serif',
} satisfies Partial<CSSStyleDeclaration>);
const button = this.doc.createElement('button');
button.textContent = 'Download this video ▾';
root.appendChild(button);
const menu = this.doc.createElement('ul');
menu.hidden = true;
Object.assign(menu.style, { listStyle: 'none', margin: '4px 0 0', padding: '4px' } satisfies Partial<CSSStyleDeclaration>);
root.appendChild(menu);
button.addEventListener('click', () => {
menu.hidden = !menu.hidden;
if (!menu.hidden) void this.populate(menu, manifestUrl);
});
this.doc.body.appendChild(root);
this.root = root;
}
remove(): void {
this.root?.remove();
this.root = null;
}
private async populate(menu: HTMLUListElement, manifestUrl: string): Promise<void> {
menu.replaceChildren(this.loadingItem());
const result = await this.port.listVariants(manifestUrl);
if ('error' in result) {
menu.replaceChildren(this.messageItem(`Could not read variants: ${result.error}`));
return;
}
if (result.drmProtected || result.variants.length === 0) {
menu.replaceChildren(this.messageItem(result.drmProtected ? 'Protected content' : 'No downloadable variants found'));
return;
}
menu.replaceChildren(
...result.variants.map((v) => {
const li = this.doc.createElement('li');
const item = this.doc.createElement('button');
item.textContent = v.drm ? `${variantLabel(v)} — Protected content` : variantLabel(v);
item.disabled = v.drm;
if (!v.drm) {
item.addEventListener('click', () => void this.port.addVariant(manifestUrl, v.variantId));
}
li.appendChild(item);
return li;
}),
);
}
private loadingItem(): HTMLLIElement {
return this.messageItem('Loading…');
}
private messageItem(text: string): HTMLLIElement {
const li = this.doc.createElement('li');
li.textContent = text;
return li;
}
}
+56
View File
@@ -0,0 +1,56 @@
body {
max-width: 560px;
margin: 0 auto;
padding: 24px 16px;
font: 14px -apple-system, system-ui, sans-serif;
color: #1a1a1a;
background: #fff;
}
h1 { margin: 0 0 16px; }
section {
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
section:last-of-type { border-bottom: none; }
h2 {
font-size: 15px;
margin: 0 0 10px;
}
label {
display: block;
margin: 8px 0;
font-weight: 600;
font-size: 13px;
}
select, input, textarea {
display: block;
margin-top: 4px;
font: inherit;
padding: 5px 7px;
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
button {
font: inherit;
padding: 6px 12px;
margin-top: 8px;
margin-right: 8px;
}
#rules-locked {
color: #888;
font-style: italic;
}
#pair-message {
min-height: 1.2em;
color: #555;
}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Velox Options</title>
<link rel="stylesheet" href="options.css" />
</head>
<body>
<h1>Velox</h1>
<section>
<h2>Transport</h2>
<label>Connection
<select id="transport-override">
<option value="auto">Automatic (recommended)</option>
<option value="ws">WebSocket only</option>
<option value="uds">Native messaging only</option>
</select>
</label>
<p id="transport-status">Connecting…</p>
</section>
<section>
<h2>Pairing</h2>
<p>If the Velox app isn't running to show an Allow prompt, type the 4-digit code it displayed:</p>
<input id="pair-code" type="text" inputmode="numeric" maxlength="8" placeholder="Pairing code" />
<button id="pair-button" type="button">Pair</button>
<button id="unpair-button" type="button">Unpair</button>
<p id="pair-message"></p>
</section>
<section>
<h2>Capture policy</h2>
<p id="rules-summary">Loading…</p>
<p id="rules-locked">These are set by the Velox app or CLI. Connect via native messaging (or edit them there) to change them from here.</p>
<form id="rules-form" hidden>
<label>Minimum size to capture (bytes)
<input id="min-size" type="number" min="0" />
</label>
<label>Excluded hosts (one per line)
<textarea id="excluded-hosts" rows="4"></textarea>
</label>
<label>Bypass modifier
<select id="bypass-modifier">
<option value="alt">Alt</option>
<option value="ctrl">Ctrl</option>
<option value="shift">Shift</option>
<option value="none">None</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
<section>
<h2>Default category</h2>
<p>Applied to downloads sent from the right-click menu and the keyboard shortcut.</p>
<select id="default-category"><option value="">Loading…</option></select>
</section>
<script type="module" src="options.js"></script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
// Options page: transport & pairing, the daemon's capture policy (mirrored, editable
// only over the privileged uds transport), min size / exclusions / bypass modifier
// (same privileged path), and the one setting that is genuinely the extension's own —
// the default category for extension-initiated downloads (prefs.ts). docs/05 §3.
import { PanelClient } from '../shared/panel-client.js';
import type { PanelStatus } from '../background/bridge.js';
import type { Category, CaptureRules } from '../shared/protocol/index.js';
import { getDefaultCategoryId, setDefaultCategoryId } from './prefs.js';
import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from './view.js';
const client = new PanelClient();
const $transportSelect = document.querySelector<HTMLSelectElement>('#transport-override')!;
const $statusText = document.querySelector<HTMLElement>('#transport-status')!;
const $pairCode = document.querySelector<HTMLInputElement>('#pair-code')!;
const $pairButton = document.querySelector<HTMLButtonElement>('#pair-button')!;
const $unpairButton = document.querySelector<HTMLButtonElement>('#unpair-button')!;
const $pairMessage = document.querySelector<HTMLElement>('#pair-message')!;
const $rulesSummary = document.querySelector<HTMLElement>('#rules-summary')!;
const $rulesForm = document.querySelector<HTMLFormElement>('#rules-form')!;
const $rulesLocked = document.querySelector<HTMLElement>('#rules-locked')!;
const $minSize = document.querySelector<HTMLInputElement>('#min-size')!;
const $excludedHosts = document.querySelector<HTMLTextAreaElement>('#excluded-hosts')!;
const $bypassModifier = document.querySelector<HTMLSelectElement>('#bypass-modifier')!;
const $defaultCategory = document.querySelector<HTMLSelectElement>('#default-category')!;
let latestRules: CaptureRules | null = null;
function renderStatus(status: PanelStatus): void {
$transportSelect.value = status.kind ?? 'auto';
$statusText.textContent = statusLine(status);
$pairButton.disabled = !pairingAvailable(status);
$pairCode.disabled = !pairingAvailable(status);
$unpairButton.disabled = !unpairAvailable(status);
const canEdit = captureRulesEditable(status);
$rulesForm.hidden = !canEdit;
$rulesLocked.hidden = canEdit;
}
function renderRulesSummary(rules: CaptureRules): void {
latestRules = rules;
$rulesSummary.textContent = rules.enabled
? `Capturing ${rules.monitoredExtensions.length} extension(s) and ${rules.monitoredMimeTypes.length} MIME type(s), min size ${rules.minSizeBytes} bytes.`
: 'Capture is disabled on the daemon.';
$minSize.value = String(rules.minSizeBytes);
$excludedHosts.value = rules.excludedHosts.join('\n');
$bypassModifier.value = rules.bypassModifier ?? 'alt';
}
async function refreshRules(): Promise<void> {
try {
const rules = await client.call('capture.getRules', {});
renderRulesSummary(rules);
} catch {
$rulesSummary.textContent = 'Could not read the daemons capture policy.';
}
}
function makeOption(value: string, label: string): HTMLOptionElement {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
return opt;
}
async function refreshCategories(): Promise<void> {
try {
const { items } = await client.call('category.list', {});
const current = await getDefaultCategoryId();
$defaultCategory.replaceChildren(
makeOption('', '(none — daemon default)'),
...items.map((c: Category) => makeOption(c.categoryId, c.name)),
);
$defaultCategory.value = current ?? '';
} catch {
$defaultCategory.replaceChildren(makeOption('', '(unavailable — not connected)'));
}
}
$transportSelect.addEventListener('change', () => {
client.setOverride($transportSelect.value as 'auto' | 'ws' | 'uds');
});
$pairButton.addEventListener('click', () => {
const code = $pairCode.value.trim();
if (!code) return;
$pairMessage.textContent = 'Pairing…';
client.pair(code);
});
$unpairButton.addEventListener('click', () => {
client.unpair();
$pairMessage.textContent = 'Unpaired.';
});
$defaultCategory.addEventListener('change', () => {
void setDefaultCategoryId($defaultCategory.value || null);
});
$rulesForm.addEventListener('submit', (e) => {
e.preventDefault();
if (!latestRules) return;
const values = {
'capture.minSizeBytes': Number($minSize.value) || 0,
'capture.excludedHosts': $excludedHosts.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
'capture.bypassModifier': $bypassModifier.value as CaptureRules['bypassModifier'],
};
client
.call('settings.set', { values })
.then(() => refreshRules())
.catch(() => {
$rulesSummary.textContent = 'Failed to save — is Velox still connected via native messaging?';
});
});
async function start(): Promise<void> {
client.onStatus(renderStatus);
client.onPairError((err) => {
$pairMessage.textContent = `Pairing failed: ${err.message}`;
});
client.on('event.settings.changed', (payload) => {
if (payload.keys.some((k) => k.startsWith('capture.'))) void refreshRules();
});
await Promise.all([refreshRules(), refreshCategories()]);
}
void start();
+25
View File
@@ -0,0 +1,25 @@
// Extension-local preferences: state that belongs to this browser install, not to the
// daemon's Settings bag. The protocol draws a hard line here — settings.set and
// rules.upsert are privileged, uds-only methods (METHODS in shared/protocol) — so an
// Options page reachable only over the WebSocket transport cannot write the daemon's
// capture policy no matter what the UI looks like. Rather than inventing a protocol
// field to work around that (CLAUDE.md §2 forbids exactly this), the extension:
// - mirrors the daemon's capture policy read-only via capture.getRules (works on
// both transports, and is what shouldCapture() itself already trusts), and
// - keeps the one piece of "options" state that genuinely is the extension's own —
// which category new captures/context-menu downloads default to — in
// browser.storage.local, exactly like the pairing token and transport override.
// Editing the mirrored capture policy is only offered when connected via NativeTransport,
// where settings.set is allowed; see options.ts.
const KEY = { defaultCategoryId: 'velox.defaultCategoryId' } as const;
export async function getDefaultCategoryId(): Promise<string | null> {
const bag = await browser.storage.local.get(KEY.defaultCategoryId);
return (bag[KEY.defaultCategoryId] as string | undefined) ?? null;
}
export async function setDefaultCategoryId(categoryId: string | null): Promise<void> {
if (categoryId === null) await browser.storage.local.remove(KEY.defaultCategoryId);
else await browser.storage.local.set({ [KEY.defaultCategoryId]: categoryId });
}
+39
View File
@@ -0,0 +1,39 @@
// Pure view-model helpers for options.ts, kept separate from the DOM so the decisions
// that matter (when pairing controls are enabled, when the capture-policy form is
// editable, what the status line says) are unit-testable without a document.
import type { PanelStatus } from '../background/bridge.js';
export function statusLine(status: PanelStatus): string {
const parts: string[] = [status.state];
if (status.daemonVersion) parts.push(`v${status.daemonVersion}`);
if (status.kind) parts.push(`via ${status.kind === 'uds' ? 'native messaging' : 'WebSocket'}`);
if (status.needsPairing) {
parts.push(status.retryAfterSec ? `pairing locked (${status.retryAfterSec}s)` : 'needs pairing');
}
if (status.fatal) parts.push(status.fatal);
return parts.join(' · ');
}
/** Pairing is a WebSocket-transport concept; native messaging has no token. Also true
* before the transport kind is known yet, so the control isn't stuck disabled forever
* on first paint. */
export function pairingAvailable(status: PanelStatus): boolean {
return status.kind === 'ws' || status.kind === null;
}
export function unpairAvailable(status: PanelStatus): boolean {
return status.kind === 'ws';
}
/**
* settings.set and rules.upsert are privileged, uds-only methods (shared/protocol
* METHODS) the daemon refuses them over WebSocket with -32003 regardless of what this
* page renders. So the capture-policy form is only ever offered as editable when the
* active transport is native messaging; on WebSocket it is a read-only mirror, per
* CLAUDE.md §2 ("working around a wrong contract locally" is not an option here this
* boundary is deliberate, not wrong).
*/
export function captureRulesEditable(status: PanelStatus): boolean {
return status.kind === 'uds';
}
+89
View File
@@ -0,0 +1,89 @@
body {
width: 320px;
margin: 0;
font: 13px -apple-system, system-ui, sans-serif;
color: #1a1a1a;
background: #fff;
}
.topbar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #e2e2e2;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
background: #999;
}
.dot-connected { background: #2ea043; }
.dot-connecting { background: #d4a72c; }
.dot-disconnected { background: #d1242f; }
#status-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#start-daemon {
font-size: 12px;
}
#task-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 360px;
overflow-y: auto;
}
.task {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.task-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.task-bar {
height: 4px;
background: #eee;
border-radius: 2px;
margin: 4px 0;
overflow: hidden;
}
.task-bar-fill {
height: 100%;
background: #2f6fed;
}
.task-meta {
display: flex;
gap: 8px;
align-items: center;
color: #666;
font-size: 12px;
}
.task-state { text-transform: capitalize; }
.task-actions { margin-left: auto; }
.task-actions button {
font-size: 11px;
padding: 2px 6px;
}
#empty {
padding: 24px 12px;
text-align: center;
color: #888;
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Velox</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<header class="topbar">
<span id="status-dot" class="dot dot-disconnected" aria-hidden="true"></span>
<span id="status-text">Connecting…</span>
<button id="start-daemon" hidden>Start it</button>
</header>
<ul id="task-list"></ul>
<p id="empty">No active downloads.</p>
<script type="module" src="popup.js"></script>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
// Toolbar popup: daemon status dot, active downloads with live progress, pause/resume.
// docs/05 §3. No download logic — this only renders state and forwards pause/resume
// intent over the panel bridge (background/bridge.ts) to the transport the background
// page owns.
import { PanelClient } from '../shared/panel-client.js';
import type { PanelStatus } from '../background/bridge.js';
import { formatEta, formatSpeed, PopupStore, progressPercent, type TaskRow } from './store.js';
const client = new PanelClient();
const store = new PopupStore();
const $status = document.querySelector<HTMLElement>('#status-dot')!;
const $statusText = document.querySelector<HTMLElement>('#status-text')!;
const $list = document.querySelector<HTMLElement>('#task-list')!;
const $empty = document.querySelector<HTMLElement>('#empty')!;
const $startButton = document.querySelector<HTMLButtonElement>('#start-daemon')!;
function renderStatus(status: PanelStatus): void {
$status.className = `dot dot-${status.state}`;
if (status.fatal) {
$statusText.textContent = status.fatal;
} else if (status.needsPairing) {
$statusText.textContent = status.retryAfterSec
? `Pairing locked — retry in ${status.retryAfterSec}s`
: 'Velox needs pairing — open Options';
} else if (status.state === 'connected') {
$statusText.textContent = status.daemonVersion ? `Connected · v${status.daemonVersion}` : 'Connected';
} else if (status.state === 'connecting') {
$statusText.textContent = 'Connecting…';
} else {
$statusText.textContent = "Velox isn't running.";
}
$startButton.hidden = status.state === 'connected';
}
// Built with createElement rather than innerHTML: AMO's linter flags dynamic innerHTML
// on sight, and building nodes directly means a filename can never be parsed as markup.
function buildRow(row: TaskRow): HTMLLIElement {
const pct = progressPercent(row);
const canPause = row.state === 'downloading' || row.state === 'connecting' || row.state === 'queued';
const canResume = row.state === 'paused' || row.state === 'retry_wait' || row.state === 'failed';
const li = document.createElement('li');
li.className = 'task';
li.dataset['taskId'] = row.taskId;
const name = document.createElement('div');
name.className = 'task-name';
name.title = row.filename;
name.textContent = row.filename;
const bar = document.createElement('div');
bar.className = 'task-bar';
const barFill = document.createElement('div');
barFill.className = 'task-bar-fill';
barFill.style.width = `${pct ?? 0}%`;
bar.appendChild(barFill);
const meta = document.createElement('div');
meta.className = 'task-meta';
const state = document.createElement('span');
state.className = 'task-state';
state.textContent = row.state;
const speed = document.createElement('span');
speed.className = 'task-speed';
speed.textContent = formatSpeed(row.speedBps);
const eta = document.createElement('span');
eta.className = 'task-eta';
eta.textContent = formatEta(row.etaSeconds);
const actions = document.createElement('span');
actions.className = 'task-actions';
if (canPause) actions.appendChild(makeActionButton('pause', row.taskId, 'Pause'));
if (canResume) actions.appendChild(makeActionButton('resume', row.taskId, 'Resume'));
meta.append(state, speed, eta, actions);
li.append(name, bar, meta);
return li;
}
function makeActionButton(action: 'pause' | 'resume', taskId: string, label: string): HTMLButtonElement {
const button = document.createElement('button');
button.dataset['action'] = action;
button.dataset['taskId'] = taskId;
button.textContent = label;
return button;
}
function render(): void {
const rows = store.rows();
$empty.hidden = rows.length > 0;
$list.replaceChildren(...rows.map(buildRow));
}
$list.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.dataset['action'];
const taskId = target.dataset['taskId'];
if (!action || !taskId) return;
if (action === 'pause') void client.call('download.pause', { taskIds: [taskId] });
else if (action === 'resume') void client.call('download.resume', { taskIds: [taskId] });
});
$startButton.addEventListener('click', () => {
// The transport reconnects on its own with backoff; this just gives the user
// something to click rather than staring at a red dot (docs/05 §5).
client.reconnect();
});
async function start(): Promise<void> {
client.onStatus(renderStatus);
client.on('event.task.added', (evt) => {
store.onAdded(evt);
render();
});
// event.task.progress arrives at up to 4 Hz (EVENTS contract) — this repaint rides
// that rate directly rather than adding a second timer, so the popup never exceeds it.
client.on('event.task.progress', (evt) => {
store.onProgress(evt);
render();
});
client.on('event.task.state', (evt) => {
store.onState(evt);
render();
});
client.on('event.task.removed', (evt) => {
store.onRemoved(evt);
render();
});
try {
const list = await client.call('download.list', {
filter: { states: ['queued', 'connecting', 'downloading', 'paused', 'retry_wait', 'assembling', 'verifying'] },
limit: 100,
});
store.setInitial(list.items);
} catch {
// Daemon unreachable — status dot already shows red; the list just stays empty.
}
render();
}
void start();
+115
View File
@@ -0,0 +1,115 @@
// Pure state for the popup's task list — no DOM, so it is unit-testable without a
// browser. Fed by download.list (initial) and event.task.{added,progress,state,removed}
// relayed over the panel bridge (docs/05 §3: "live progress via event.task.progress").
import type {
TaskAddedEvent,
TaskProgressEvent,
TaskRemovedEvent,
TaskState,
TaskStateEvent,
TaskSummary,
} from '../shared/protocol/index.js';
export interface TaskRow {
taskId: string;
filename: string;
state: TaskState;
downloadedBytes: number;
sizeBytes: number | null;
speedBps: number;
etaSeconds: number | null;
}
const ACTIVE_STATES: readonly TaskState[] = ['connecting', 'downloading', 'assembling', 'verifying', 'probing', 'queued'];
function fromSummary(s: TaskSummary): TaskRow {
return {
taskId: s.taskId,
filename: s.filename,
state: s.state,
downloadedBytes: s.downloadedBytes,
sizeBytes: s.sizeBytes ?? null,
speedBps: s.speedBps,
etaSeconds: s.etaSeconds ?? null,
};
}
export class PopupStore {
private rowsById = new Map<string, TaskRow>();
setInitial(items: TaskSummary[]): void {
this.rowsById.clear();
for (const s of items) this.rowsById.set(s.taskId, fromSummary(s));
}
onAdded(evt: TaskAddedEvent): void {
this.rowsById.set(evt.taskId, fromSummary(evt.summary));
}
/** event.task.progress is a patch, never a rebuild (docs/05 §3 / EVENTS contract). */
onProgress(evt: TaskProgressEvent): void {
for (const t of evt.tasks) {
const row = this.rowsById.get(t.taskId);
if (!row) continue; // a progress tick for a task we haven't seen added yet — ignore
row.downloadedBytes = t.downloadedBytes;
row.speedBps = t.speedBps;
row.etaSeconds = t.etaSeconds ?? null;
}
}
onState(evt: TaskStateEvent): void {
const row = this.rowsById.get(evt.taskId);
if (evt.summary) {
this.rowsById.set(evt.taskId, fromSummary(evt.summary));
} else if (row) {
row.state = evt.state;
}
}
onRemoved(evt: TaskRemovedEvent): void {
this.rowsById.delete(evt.taskId);
}
/** Active tasks first (what the user opened the popup to watch), then by filename. */
rows(): TaskRow[] {
const isActive = (r: TaskRow): boolean => ACTIVE_STATES.includes(r.state) || r.state === 'paused';
return [...this.rowsById.values()].sort((a, b) => {
const activeDiff = Number(isActive(b)) - Number(isActive(a));
if (activeDiff !== 0) return activeDiff;
return a.filename.localeCompare(b.filename);
});
}
get size(): number {
return this.rowsById.size;
}
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
export function formatSpeed(bps: number): string {
return bps > 0 ? `${formatBytes(bps)}/s` : '';
}
export function formatEta(seconds: number | null): string {
if (seconds === null || seconds < 0 || !Number.isFinite(seconds)) return '';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
export function progressPercent(row: TaskRow): number | null {
if (!row.sizeBytes || row.sizeBytes <= 0) return null;
return Math.min(100, Math.round((row.downloadedBytes / row.sizeBytes) * 100));
}
+117
View File
@@ -0,0 +1,117 @@
// Client side of background/bridge.ts, used by popup/ and options/ — the two documents
// that cannot import the background page's live VeloxTransport directly and instead
// talk to it over a `browser.runtime.connect` port.
import type { EventName, EventPayload, MethodName, Params, Result } from '../shared/protocol/index.js';
import type { PanelStatus } from '../background/bridge.js';
const PORT_NAME = 'velox-panel';
export class RpcCallError extends Error {
constructor(
readonly code: number | undefined,
message: string,
) {
super(message);
this.name = 'RpcCallError';
}
}
type Pending = { resolve: (v: unknown) => void; reject: (e: unknown) => void };
/** Thin promise-based RPC client plus event fan-out, over one long-lived port. */
export class PanelClient {
private port: browser.runtime.Port;
private nextId = 1;
private pending = new Map<number, Pending>();
private eventListeners = new Map<string, Set<(payload: unknown) => void>>();
private statusListeners = new Set<(status: PanelStatus) => void>();
private pairErrorListeners = new Set<(error: { code?: number; message: string }) => void>();
private subscribed = new Set<EventName>();
constructor(connect: () => browser.runtime.Port = () => browser.runtime.connect({ name: PORT_NAME })) {
this.port = connect();
this.port.onMessage.addListener((raw) => this.onMessage(raw as Record<string, unknown>));
}
private onMessage(msg: Record<string, unknown>): void {
if (msg['type'] === 'result') {
const id = msg['id'] as number;
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
if (msg['ok']) p.resolve(msg['result']);
else {
const err = msg['error'] as { code?: number; message: string };
p.reject(new RpcCallError(err.code, err.message));
}
} else if (msg['type'] === 'event') {
const listeners = this.eventListeners.get(msg['event'] as string);
if (listeners) for (const cb of listeners) cb(msg['payload']);
} else if (msg['type'] === 'status') {
for (const cb of this.statusListeners) cb(msg['status'] as PanelStatus);
} else if (msg['type'] === 'pairError') {
for (const cb of this.pairErrorListeners) cb(msg['error'] as { code?: number; message: string });
}
}
call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>> {
const id = this.nextId++;
return new Promise<Result<M>>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.port.postMessage({ type: 'call', id, method, params });
});
}
/** Adds `event` to the set this panel receives. Safe to call repeatedly. */
private ensureSubscribed(event: EventName): void {
if (this.subscribed.has(event)) return;
this.subscribed.add(event);
this.port.postMessage({ type: 'subscribe', events: [...this.subscribed] });
}
on<E extends EventName>(event: E, cb: (payload: EventPayload<E>) => void): () => void {
let set = this.eventListeners.get(event);
if (!set) {
set = new Set();
this.eventListeners.set(event, set);
}
set.add(cb as (payload: unknown) => void);
this.ensureSubscribed(event);
return () => set!.delete(cb as (payload: unknown) => void);
}
/** Asks the background page's transport to retry now, instead of waiting on backoff. */
reconnect(): void {
this.port.postMessage({ type: 'reconnect' });
}
onStatus(cb: (status: PanelStatus) => void): () => void {
this.statusListeners.add(cb);
this.port.postMessage({ type: 'getStatus' });
return () => this.statusListeners.delete(cb);
}
onPairError(cb: (error: { code?: number; message: string }) => void): () => void {
this.pairErrorListeners.add(cb);
return () => this.pairErrorListeners.delete(cb);
}
/** Options → "Pair" with a code typed from the daemon's dialog. */
pair(code: string): void {
this.port.postMessage({ type: 'pair', code });
}
/** Options → "Unpair": revoke the locally stored token. */
unpair(): void {
this.port.postMessage({ type: 'unpair' });
}
setOverride(override: 'auto' | 'ws' | 'uds'): void {
this.port.postMessage({ type: 'setOverride', override });
}
dispose(): void {
this.port.disconnect();
}
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from 'vitest';
import { PanelBridge, type PanelRequest, type PanelResponse, type PortLike } from '../../src/background/bridge.js';
import type { TransportStatus, VeloxTransport } from '../../src/background/transport/index.js';
function fakePort(): PortLike & { received: PanelResponse[]; emit(msg: PanelRequest): void; close(): void } {
const msgListeners = new Set<(msg: PanelRequest) => void>();
const discListeners = new Set<() => void>();
const received: PanelResponse[] = [];
return {
name: 'velox-panel',
received,
postMessage: (m) => received.push(m),
onMessage: { addListener: (cb) => msgListeners.add(cb) },
onDisconnect: { addListener: (cb) => discListeners.add(cb) },
emit: (msg) => msgListeners.forEach((cb) => cb(msg)),
close: () => discListeners.forEach((cb) => cb()),
};
}
function fakeTransport(status: TransportStatus): VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] } {
const stateListeners = new Set<(s: TransportStatus) => void>();
const eventListeners = new Map<string, Set<(p: unknown) => void>>();
const calls: unknown[] = [];
return {
kind: 'ws',
state: status.state,
status,
calls,
connect: async () => undefined,
disconnect: () => undefined,
call: (async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === 'boom') throw Object.assign(new Error('nope'), { code: -32010 });
return { echoed: params };
}) as VeloxTransport['call'],
on: ((event: string, cb: (p: unknown) => void) => {
let s = eventListeners.get(event);
if (!s) eventListeners.set(event, (s = new Set()));
s.add(cb);
}) as VeloxTransport['on'],
off: (event: string, cb: (p: unknown) => void) => {
eventListeners.get(event)?.delete(cb);
},
onStateChange: (cb: (s: TransportStatus) => void) => {
stateListeners.add(cb);
return () => stateListeners.delete(cb);
},
fireStatus: (s: TransportStatus) => stateListeners.forEach((cb) => cb(s)),
} as unknown as VeloxTransport & { fireStatus(s: TransportStatus): void; calls: unknown[] };
}
const CONNECTED: TransportStatus = {
state: 'connected',
needsPairing: false,
fatal: null,
retryAfterSec: null,
sessionId: 's1',
daemonVersion: '1.0.0',
capabilities: [],
};
describe('PanelBridge', () => {
it('sends the current status on connect', () => {
const t = fakeTransport(CONNECTED);
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
expect(port.received[0]).toEqual({ type: 'status', status: { ...CONNECTED, kind: 'ws' } });
});
it('forwards a call and relays the result', async () => {
const t = fakeTransport(CONNECTED);
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'call', id: 7, method: 'download.list', params: {} });
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
const result = port.received.find((m) => m.type === 'result');
expect(result).toEqual({ type: 'result', id: 7, ok: true, result: { echoed: {} } });
});
it('relays a call error with its code', async () => {
const t = fakeTransport(CONNECTED);
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'call', id: 1, method: 'boom' as never, params: {} });
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
const result = port.received.find((m) => m.type === 'result');
expect(result).toEqual({ type: 'result', id: 1, ok: false, error: { code: -32010, message: 'nope' } });
});
it('relays a call error when there is no transport yet', async () => {
const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'call', id: 2, method: 'download.list', params: {} });
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'result')).toBe(true));
const result = port.received.find((m) => m.type === 'result');
expect(result).toEqual({ type: 'result', id: 2, ok: false, error: { message: 'transport not ready' } });
});
it('a disconnected port stops receiving after disconnect', () => {
const t = fakeTransport(CONNECTED);
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.close();
expect(() => port.emit({ type: 'getStatus' })).not.toThrow();
});
it('relays event.speed.global to the port after subscribing', () => {
const stateListeners = new Set<(s: TransportStatus) => void>();
const eventCbs = new Map<string, (p: unknown) => void>();
const t: VeloxTransport = {
kind: 'ws',
state: 'connected',
status: CONNECTED,
connect: async () => undefined,
disconnect: () => undefined,
call: (async () => ({})) as VeloxTransport['call'],
on: ((event: string, cb: (p: unknown) => void) => {
eventCbs.set(event, cb);
}) as VeloxTransport['on'],
off: () => undefined,
onStateChange: (cb) => {
stateListeners.add(cb);
return () => stateListeners.delete(cb);
},
};
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'subscribe', events: ['event.speed.global'] });
eventCbs.get('event.speed.global')?.({ bytesPerSec: 4096 });
expect(port.received).toContainEqual({ type: 'event', event: 'event.speed.global', payload: { bytesPerSec: 4096 } });
});
it('pairs with a code and reports the resulting status', async () => {
const pairWithCode = vi.fn(async () => undefined);
const t = { ...fakeTransport(CONNECTED), pairWithCode };
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'pair', code: '4821' });
await vi.waitFor(() => expect(pairWithCode).toHaveBeenCalledWith('4821'));
});
it('reports a pairError when the transport has no pairWithCode (e.g. native transport)', async () => {
const t = fakeTransport(CONNECTED); // no pairWithCode
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'pair', code: '4821' });
await vi.waitFor(() => expect(port.received.some((m) => m.type === 'pairError')).toBe(true));
});
it('unpairs via the transport and reports status', async () => {
const unpair = vi.fn(async () => undefined);
const t = { ...fakeTransport(CONNECTED), unpair };
const bridge = new PanelBridge({ getTransport: () => t, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'unpair' });
await vi.waitFor(() => expect(unpair).toHaveBeenCalled());
});
it('delegates setOverride to the deps and reports the new status', async () => {
const setOverride = vi.fn(async () => undefined);
const t = fakeTransport(CONNECTED);
const bridge = new PanelBridge({ getTransport: () => t, setOverride });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.emit({ type: 'setOverride', override: 'uds' });
await vi.waitFor(() => expect(setOverride).toHaveBeenCalledWith('uds'));
});
it('getStatus answers with the disconnected sentinel when there is no transport', () => {
const bridge = new PanelBridge({ getTransport: () => undefined, setOverride: async () => undefined });
const port = fakePort();
bridge.attach({ addListener: (cb) => cb(port) });
port.received.length = 0;
port.emit({ type: 'getStatus' });
expect(port.received[0]).toEqual({
type: 'status',
status: { state: 'disconnected', needsPairing: false, fatal: null, retryAfterSec: null, sessionId: null, daemonVersion: null, capabilities: [], kind: null },
});
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';
import { MediaBridge, notifyTab } from '../../src/background/media-bridge.js';
import type { ContentMessage } from '../../src/background/media-bridge.js';
import type { VeloxTransport } from '../../src/background/transport/index.js';
function fakeOnMessage() {
let listener: ((msg: ContentMessage, sender: unknown, sendResponse: (r: unknown) => void) => boolean | void) | undefined;
return {
addListener: (cb: typeof listener) => {
listener = cb;
},
fire: (msg: ContentMessage) =>
new Promise((resolve) => {
listener?.(msg, {}, resolve);
}),
};
}
function fakeTransport(call: VeloxTransport['call']): VeloxTransport {
return {
kind: 'ws',
state: 'connected',
status: {
state: 'connected',
needsPairing: false,
fatal: null,
retryAfterSec: null,
sessionId: null,
daemonVersion: null,
capabilities: [],
},
connect: async () => undefined,
disconnect: () => undefined,
call,
on: () => undefined,
off: () => undefined,
onStateChange: () => () => undefined,
};
}
describe('MediaBridge', () => {
it('answers velox-list-variants by calling media.listVariants', async () => {
const call = vi.fn(async () => ({ variants: [], manifestType: 'hls', drmProtected: false }));
const bridge = new MediaBridge(() => fakeTransport(call as never));
const onMessage = fakeOnMessage();
bridge.attach(onMessage);
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
expect(call).toHaveBeenCalledWith('media.listVariants', { manifestUrl: 'https://x/m.m3u8' });
expect(response).toEqual({ ok: true, result: { variants: [], manifestType: 'hls', drmProtected: false } });
});
it('answers velox-add-variant by calling media.addVariant', async () => {
const call = vi.fn(async () => ({ taskId: 't1', state: 'queued' }));
const bridge = new MediaBridge(() => fakeTransport(call as never));
const onMessage = fakeOnMessage();
bridge.attach(onMessage);
const response = await onMessage.fire({
type: 'velox-add-variant',
params: { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' },
});
expect(call).toHaveBeenCalledWith('media.addVariant', { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' });
expect(response).toEqual({ ok: true, result: { taskId: 't1', state: 'queued' } });
});
it('answers with an error when the transport is not ready', async () => {
const bridge = new MediaBridge(() => undefined);
const onMessage = fakeOnMessage();
bridge.attach(onMessage);
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
expect(response).toEqual({ ok: false, error: 'transport not ready' });
});
it('answers with an error when the call rejects', async () => {
const call = vi.fn(async () => {
throw new Error('refused: drm protected');
});
const bridge = new MediaBridge(() => fakeTransport(call as never));
const onMessage = fakeOnMessage();
bridge.attach(onMessage);
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
expect(response).toEqual({ ok: false, error: 'refused: drm protected' });
});
});
describe('notifyTab', () => {
it('sends a velox-manifest-detected message to the tab', () => {
const sent: unknown[] = [];
const tabs = { sendMessage: (tabId: number, message: unknown) => { sent.push({ tabId, message }); return Promise.resolve(); } };
notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] });
expect(sent).toEqual([{ tabId: 3, message: { type: 'velox-manifest-detected', url: 'https://x/m.m3u8', headers: [] } }]);
});
it('swallows a rejection (tab has no content script listening)', () => {
const tabs = { sendMessage: () => Promise.reject(new Error('no receiving end')) };
expect(() => notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] })).not.toThrow();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { isManifestContentType, isManifestUrl, looksLikeManifest, MediaWatcher } from '../../src/background/capture/media.js';
describe('isManifestUrl', () => {
it('matches .m3u8 and .mpd, query string included', () => {
expect(isManifestUrl('https://cdn.example/video/master.m3u8')).toBe(true);
expect(isManifestUrl('https://cdn.example/video/master.m3u8?token=abc')).toBe(true);
expect(isManifestUrl('https://cdn.example/video/stream.mpd')).toBe(true);
});
it('does not match an ordinary page or a segment file', () => {
expect(isManifestUrl('https://example.com/watch?v=1')).toBe(false);
expect(isManifestUrl('https://cdn.example/video/segment-001.ts')).toBe(false);
});
it('is false on an unparseable URL rather than throwing', () => {
expect(isManifestUrl('not a url')).toBe(false);
});
});
describe('isManifestContentType', () => {
it('matches HLS and DASH content types, ignoring a charset suffix', () => {
expect(isManifestContentType('application/vnd.apple.mpegurl')).toBe(true);
expect(isManifestContentType('application/dash+xml; charset=utf-8')).toBe(true);
expect(isManifestContentType('application/x-mpegurl')).toBe(true);
});
it('does not match ordinary types or missing headers', () => {
expect(isManifestContentType('text/html')).toBe(false);
expect(isManifestContentType(null)).toBe(false);
expect(isManifestContentType(undefined)).toBe(false);
});
});
describe('looksLikeManifest', () => {
it('is true on either signal alone', () => {
expect(looksLikeManifest('https://cdn.example/x.m3u8', 'text/plain')).toBe(true);
expect(looksLikeManifest('https://cdn.example/x', 'application/dash+xml')).toBe(true);
expect(looksLikeManifest('https://cdn.example/x', 'text/html')).toBe(false);
});
});
describe('MediaWatcher', () => {
function fakeWebRequest() {
let listener: ((d: unknown) => void) | undefined;
return {
onHeadersReceived: {
addListener: (cb: (d: unknown) => void) => {
listener = cb;
},
},
fire: (d: unknown) => listener?.(d),
};
}
it('reports a detected manifest with its response headers', () => {
const wr = fakeWebRequest();
const detected: unknown[] = [];
const watcher = new MediaWatcher((m) => detected.push(m));
watcher.attach(wr as never);
wr.fire({
tabId: 7,
url: 'https://cdn.example/master.m3u8',
responseHeaders: [{ name: 'Content-Type', value: 'application/vnd.apple.mpegurl' }],
});
expect(detected).toEqual([
{
tabId: 7,
url: 'https://cdn.example/master.m3u8',
headers: [{ name: 'Content-Type', value: 'application/vnd.apple.mpegurl' }],
},
]);
});
it('ignores a response that is not a manifest', () => {
const wr = fakeWebRequest();
const detected: unknown[] = [];
const watcher = new MediaWatcher((m) => detected.push(m));
watcher.attach(wr as never);
wr.fire({ tabId: 7, url: 'https://example.com/page.html', responseHeaders: [{ name: 'Content-Type', value: 'text/html' }] });
expect(detected).toHaveLength(0);
});
it('ignores requests with no associated tab', () => {
const wr = fakeWebRequest();
const detected: unknown[] = [];
const watcher = new MediaWatcher((m) => detected.push(m));
watcher.attach(wr as never);
wr.fire({ tabId: -1, url: 'https://cdn.example/master.m3u8', responseHeaders: [] });
expect(detected).toHaveLength(0);
});
it('reports the same (tab, url) manifest only once', () => {
const wr = fakeWebRequest();
const detected: unknown[] = [];
const watcher = new MediaWatcher((m) => detected.push(m));
watcher.attach(wr as never);
const details = { tabId: 7, url: 'https://cdn.example/live.m3u8', responseHeaders: [] };
wr.fire(details);
wr.fire(details); // HLS live-refresh re-requests the same manifest
expect(detected).toHaveLength(1);
});
});
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import { VideoSrcObserver } from '../../src/content/media-observer.js';
describe('VideoSrcObserver', () => {
it('reports a <video src> already present when start() is called', () => {
document.body.innerHTML = '<video src="https://cdn.example/master.m3u8"></video>';
const found: string[] = [];
const observer = new VideoSrcObserver((url) => found.push(url));
observer.start();
expect(found).toEqual(['https://cdn.example/master.m3u8']);
observer.stop();
});
it('ignores a <video> whose src is not a manifest', () => {
document.body.innerHTML = '<video src="https://cdn.example/movie.mp4"></video>';
const found: string[] = [];
const observer = new VideoSrcObserver((url) => found.push(url));
observer.start();
expect(found).toHaveLength(0);
observer.stop();
});
it('reports a <video> added to the DOM after start()', async () => {
document.body.innerHTML = '';
const found: string[] = [];
const observer = new VideoSrcObserver((url) => found.push(url));
observer.start();
const video = document.createElement('video');
video.src = 'https://cdn.example/live.mpd';
document.body.appendChild(video);
await new Promise((r) => setTimeout(r, 0)); // MutationObserver callbacks are microtask-queued
expect(found).toEqual(['https://cdn.example/live.mpd']);
observer.stop();
});
it('reports a src attribute change on an existing <video>', async () => {
document.body.innerHTML = '<video></video>';
const found: string[] = [];
const observer = new VideoSrcObserver((url) => found.push(url));
observer.start();
document.querySelector('video')!.setAttribute('src', 'https://cdn.example/switched.m3u8');
await new Promise((r) => setTimeout(r, 0));
expect(found).toEqual(['https://cdn.example/switched.m3u8']);
observer.stop();
});
it('reports the same URL only once', () => {
document.body.innerHTML = '<video src="https://cdn.example/master.m3u8"></video><video src="https://cdn.example/master.m3u8"></video>';
const found: string[] = [];
const observer = new VideoSrcObserver((url) => found.push(url));
observer.start();
expect(found).toEqual(['https://cdn.example/master.m3u8']);
observer.stop();
});
});
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VideoPanel, type VariantsPort } from '../../src/content/video-panel.js';
import type { MediaVariant } from '../../src/shared/protocol/index.js';
function variant(over: Partial<MediaVariant> = {}): MediaVariant {
return { variantId: 'v1', kind: 'muxed', drm: false, ...over };
}
describe('VideoPanel', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('renders a button and an initially-hidden menu', () => {
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/m.m3u8');
const button = document.querySelector('#velox-video-panel button')!;
expect(button.textContent).toBe('Download this video ▾');
expect((document.querySelector('#velox-video-panel ul') as HTMLUListElement).hidden).toBe(true);
});
it('lists variants on click, skipping the daemon\'s manifest parsing entirely', async () => {
const listVariants = vi.fn(async () => ({
variants: [variant({ variantId: 'v-1080', resolution: '1920x1080' })],
drmProtected: false,
}));
const port: VariantsPort = { listVariants, addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await vi.waitFor(() => expect(listVariants).toHaveBeenCalledWith('https://cdn.example/m.m3u8'));
await new Promise((r) => setTimeout(r, 0));
const items = document.querySelectorAll('#velox-video-panel li');
expect(items.length).toBe(1);
expect(items[0]!.textContent).toContain('1920x1080');
});
it('greys out a DRM variant with "Protected content" and does not wire a click handler', async () => {
const listVariants = vi.fn(async () => ({
variants: [variant({ variantId: 'v-drm', drm: true, resolution: '1920x1080' })],
drmProtected: false,
}));
const addVariant = vi.fn();
const panel = new VideoPanel({ listVariants, addVariant });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
const item = document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!;
expect(item.disabled).toBe(true);
expect(item.textContent).toContain('Protected content');
item.dispatchEvent(new Event('click'));
expect(addVariant).not.toHaveBeenCalled();
});
it('shows "Protected content" for a wholly DRM-protected manifest', async () => {
const listVariants = vi.fn(async () => ({ variants: [variant()], drmProtected: true }));
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
expect(document.querySelector('#velox-video-panel li')!.textContent).toBe('Protected content');
});
it('calls addVariant when a non-DRM item is clicked', async () => {
const listVariants = vi.fn(async () => ({ variants: [variant({ variantId: 'v-720' })], drmProtected: false }));
const addVariant = vi.fn(async () => ({ taskId: 't1' }));
const panel = new VideoPanel({ listVariants, addVariant });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!.dispatchEvent(new Event('click'));
expect(addVariant).toHaveBeenCalledWith('https://cdn.example/m.m3u8', 'v-720');
});
it('surfaces an error from listVariants instead of an empty menu', async () => {
const listVariants = vi.fn(async () => ({ error: 'refused: not a manifest' }));
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
expect(document.querySelector('#velox-video-panel li')!.textContent).toContain('refused: not a manifest');
});
it('show() replaces a previous panel rather than stacking them', () => {
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/a.m3u8');
panel.show('https://cdn.example/b.m3u8');
expect(document.querySelectorAll('#velox-video-panel')).toHaveLength(1);
});
});
@@ -0,0 +1,40 @@
// Regression test for the "no download logic in extension/" ESLint gate
// (eslint.config.mjs, answering gui/docs/ext-requests-m1.md). Runs ESLint's Node API
// directly against fixture source so a future edit to the rule set can't silently stop
// catching the patterns it was written for.
import { ESLint } from 'eslint';
import { describe, expect, it } from 'vitest';
async function lint(code: string): Promise<number> {
const eslint = new ESLint({ cwd: new URL('../..', import.meta.url).pathname });
// Path only needs to match the `files: ['src/**/*.ts']` glob in eslint.config.mjs.
const [result] = await eslint.lintText(code, { filePath: 'src/background/__fixture.ts' });
return result.messages.filter((m) => m.severity === 2).length;
}
describe('no-download-logic ESLint gate', () => {
it('goes red on fetch() + a hand-built Range header + a stream reader', async () => {
const errors = await lint(`
export async function grabBytes(url: string) {
const res = await fetch(url, { headers: { Range: 'bytes=0-1023' } });
const reader = res.body!.getReader();
return reader.read();
}
`);
expect(errors).toBeGreaterThan(0);
});
it('goes red on XMLHttpRequest', async () => {
const errors = await lint(`const x = new XMLHttpRequest();`);
expect(errors).toBeGreaterThan(0);
});
it('stays green on ordinary transport/RPC code', async () => {
const errors = await lint(`
export function greet(name: string): string {
return \`hello \${name}\`;
}
`);
expect(errors).toBe(0);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { getDefaultCategoryId, setDefaultCategoryId } from '../../src/options/prefs.js';
describe('options/prefs', () => {
beforeEach(async () => {
await browser.storage.local.clear();
});
it('defaults to null (no preference set)', async () => {
expect(await getDefaultCategoryId()).toBeNull();
});
it('round-trips a chosen category', async () => {
await setDefaultCategoryId('cat-videos');
expect(await getDefaultCategoryId()).toBe('cat-videos');
});
it('clears back to null', async () => {
await setDefaultCategoryId('cat-videos');
await setDefaultCategoryId(null);
expect(await getDefaultCategoryId()).toBeNull();
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from '../../src/options/view.js';
import type { PanelStatus } from '../../src/background/bridge.js';
function status(over: Partial<PanelStatus> = {}): PanelStatus {
return {
state: 'connected',
needsPairing: false,
fatal: null,
retryAfterSec: null,
sessionId: null,
daemonVersion: null,
capabilities: [],
kind: 'ws',
...over,
};
}
describe('statusLine', () => {
it('names the state, version, and transport kind', () => {
expect(statusLine(status({ daemonVersion: '1.2.3' }))).toBe('connected · v1.2.3 · via WebSocket');
});
it('calls out native messaging', () => {
expect(statusLine(status({ kind: 'uds' }))).toBe('connected · via native messaging');
});
it('surfaces "needs pairing" with no retry hint', () => {
expect(statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: null, kind: null }))).toBe(
'disconnected · needs pairing',
);
});
it('surfaces a pairing lockout with the retry hint', () => {
expect(
statusLine(status({ state: 'disconnected', needsPairing: true, retryAfterSec: 45, kind: 'ws' })),
).toBe('disconnected · via WebSocket · pairing locked (45s)');
});
it('surfaces a fatal condition', () => {
expect(statusLine(status({ fatal: 'protocol mismatch' }))).toContain('protocol mismatch');
});
});
describe('pairingAvailable', () => {
it('is available on ws and before the kind is known', () => {
expect(pairingAvailable(status({ kind: 'ws' }))).toBe(true);
expect(pairingAvailable(status({ kind: null }))).toBe(true);
});
it('is not available on native messaging (no token concept there)', () => {
expect(pairingAvailable(status({ kind: 'uds' }))).toBe(false);
});
});
describe('unpairAvailable', () => {
it('only on ws, never before the kind is known (nothing to revoke yet)', () => {
expect(unpairAvailable(status({ kind: 'ws' }))).toBe(true);
expect(unpairAvailable(status({ kind: 'uds' }))).toBe(false);
expect(unpairAvailable(status({ kind: null }))).toBe(false);
});
});
describe('captureRulesEditable', () => {
it('only true over native messaging, matching METHODS privileged/uds-only', () => {
expect(captureRulesEditable(status({ kind: 'uds' }))).toBe(true);
expect(captureRulesEditable(status({ kind: 'ws' }))).toBe(false);
expect(captureRulesEditable(status({ kind: null }))).toBe(false);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { formatBytes, formatEta, formatSpeed, PopupStore, progressPercent } from '../../src/popup/store.js';
import type { TaskSummary } from '../../src/shared/protocol/index.js';
function summary(over: Partial<TaskSummary> = {}): TaskSummary {
return {
taskId: 't1',
filename: 'file.zip',
saveDir: '/home/u/Downloads',
url: 'https://example.com/file.zip',
downloadedBytes: 0,
state: 'downloading',
speedBps: 0,
resumable: true,
segments: 1,
createdAt: '2026-01-01T00:00:00Z',
...over,
};
}
describe('PopupStore', () => {
it('starts empty', () => {
const s = new PopupStore();
expect(s.rows()).toEqual([]);
expect(s.size).toBe(0);
});
it('seeds from download.list', () => {
const s = new PopupStore();
s.setInitial([summary({ taskId: 'a' }), summary({ taskId: 'b', filename: 'b.zip' })]);
expect(s.size).toBe(2);
});
it('adds a row on event.task.added', () => {
const s = new PopupStore();
s.onAdded({ taskId: 't1', summary: summary() });
expect(s.rows()).toHaveLength(1);
expect(s.rows()[0]!.filename).toBe('file.zip');
});
it('applies event.task.progress as a patch, not a rebuild', () => {
const s = new PopupStore();
s.setInitial([summary({ taskId: 't1', sizeBytes: 1000 })]);
s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 't1', downloadedBytes: 500, speedBps: 2048, etaSeconds: 4 }] });
const row = s.rows()[0]!;
expect(row.downloadedBytes).toBe(500);
expect(row.speedBps).toBe(2048);
expect(row.etaSeconds).toBe(4);
expect(row.filename).toBe('file.zip'); // untouched by the patch
});
it('ignores a progress tick for an unknown task rather than inserting a partial row', () => {
const s = new PopupStore();
s.onProgress({ at: '2026-01-01T00:00:00Z', tasks: [{ taskId: 'ghost', downloadedBytes: 1, speedBps: 1 }] });
expect(s.size).toBe(0);
});
it('replaces the row on event.task.state when a summary is attached', () => {
const s = new PopupStore();
s.setInitial([summary({ taskId: 't1', state: 'downloading' })]);
s.onState({ taskId: 't1', state: 'paused', summary: summary({ taskId: 't1', state: 'paused' }) });
expect(s.rows()[0]!.state).toBe('paused');
});
it('updates just the state when event.task.state has no summary', () => {
const s = new PopupStore();
s.setInitial([summary({ taskId: 't1', state: 'downloading' })]);
s.onState({ taskId: 't1', state: 'failed' });
expect(s.rows()[0]!.state).toBe('failed');
});
it('drops the row on event.task.removed', () => {
const s = new PopupStore();
s.setInitial([summary({ taskId: 't1' })]);
s.onRemoved({ taskId: 't1', deletedFile: false });
expect(s.size).toBe(0);
});
it('sorts active tasks before paused/finished ones, then by filename', () => {
const s = new PopupStore();
s.setInitial([
summary({ taskId: 'z', filename: 'z-paused.zip', state: 'paused' }),
summary({ taskId: 'b', filename: 'b-active.zip', state: 'downloading' }),
summary({ taskId: 'a', filename: 'a-active.zip', state: 'downloading' }),
]);
expect(s.rows().map((r) => r.taskId)).toEqual(['a', 'b', 'z']);
});
});
describe('format helpers', () => {
it('formats bytes', () => {
expect(formatBytes(500)).toBe('500 B');
expect(formatBytes(2048)).toBe('2.0 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
});
it('formats speed, blank when idle', () => {
expect(formatSpeed(0)).toBe('');
expect(formatSpeed(1024)).toBe('1.0 KB/s');
});
it('formats eta, blank when unknown', () => {
expect(formatEta(null)).toBe('');
expect(formatEta(-1)).toBe('');
expect(formatEta(45)).toBe('45s');
expect(formatEta(125)).toBe('2m 5s');
});
it('computes a progress percent, null when size is unknown', () => {
expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: 100, speedBps: 0, etaSeconds: null })).toBe(50);
expect(progressPercent({ taskId: 't', filename: 'f', state: 'downloading', downloadedBytes: 50, sizeBytes: null, speedBps: 0, etaSeconds: null })).toBeNull();
});
});
+56
View File
@@ -0,0 +1,56 @@
// browser.storage.local is backed by the real add-on profile on disk, so anything
// written through transport/storage.ts is what "survives a browser restart" means in
// practice — this covers the storage half of that DoD item; websocket.test.ts's
// "survives a browser restart" case covers the transport half (a fresh transport
// instance reusing a persisted token with no re-pairing).
import { beforeEach, describe, expect, it } from 'vitest';
import * as storage from '../../src/background/transport/storage.js';
describe('transport/storage', () => {
beforeEach(async () => {
await browser.storage.local.clear();
});
it('round-trips the pairing token', async () => {
expect(await storage.getToken()).toBeNull();
await storage.setToken('tok-abc');
expect(await storage.getToken()).toBe('tok-abc');
});
it('clears the token on unpair (null)', async () => {
await storage.setToken('tok-abc');
await storage.setToken(null);
expect(await storage.getToken()).toBeNull();
});
it('a fresh read after a simulated restart still sees the persisted token', async () => {
await storage.setToken('tok-survives');
// Nothing here recreates browser.storage.local — that's the point: it is the one
// thing in the extension that outlives the background page's lifetime, restart
// included. A second, independent read call stands in for "the page reloaded".
expect(await storage.getToken()).toBe('tok-survives');
expect(await storage.getToken()).toBe('tok-survives');
});
it('round-trips the transport override, defaulting to auto', async () => {
expect(await storage.getOverride()).toBe('auto');
await storage.setOverride('uds');
expect(await storage.getOverride()).toBe('uds');
await storage.setOverride('ws');
expect(await storage.getOverride()).toBe('ws');
});
it('ignores a corrupted override value and falls back to auto', async () => {
await browser.storage.local.set({ 'velox.transportOverride': 'not-a-transport' });
expect(await storage.getOverride()).toBe('auto');
});
it('round-trips the cached WebSocket port', async () => {
expect(await storage.getCachedWsPort()).toBeNull();
await storage.setCachedWsPort(52003);
expect(await storage.getCachedWsPort()).toBe(52003);
await storage.setCachedWsPort(null);
expect(await storage.getCachedWsPort()).toBeNull();
});
});
@@ -93,6 +93,59 @@ describe('WebSocketTransport', () => {
expect(lastHello?.token).toBe('tok-issued-1');
});
it('the pairing token survives a browser restart: a fresh transport instance over the same storage reuses it, no re-pairing', async () => {
const port = nextPort();
daemon = await FakeDaemon.start({ port, acceptToken: null });
const h = memDeps(); // stands in for browser.storage.local, which outlives the page
transport = makeTransport(port, h);
await transport.connect();
expect(daemon.pairCount).toBe(1);
expect(h.store.token).toBe('tok-issued-1');
transport.disconnect();
// Simulate "the browser restarted": a brand new transport instance, same backing
// store (in reality, the same on-disk profile), no in-memory state carried over.
const restarted = makeTransport(port, h);
await restarted.connect();
try {
expect(restarted.state).toBe('connected');
expect(daemon.pairCount).toBe(1); // still just the one pairing, ever
const lastHello = [...daemon.seen].reverse().find((s) => s.method === 'session.hello');
expect(lastHello?.token).toBe('tok-issued-1');
} finally {
restarted.disconnect();
}
});
it('pairWithCode drops any stale token and pairs fresh with the typed code', async () => {
const port = nextPort();
daemon = await FakeDaemon.start({ port, acceptToken: 'stale' });
const h = memDeps({ token: 'stale' });
transport = makeTransport(port, h);
await transport.pairWithCode('4821');
expect(transport.state).toBe('connected');
expect(daemon.pairCount).toBe(1);
const pairCall = daemon.seen.find((s) => s.method === 'session.pair');
expect((pairCall?.params as { code?: string }).code).toBe('4821');
expect(h.store.token).toBe('tok-issued-1');
});
it('unpair clears the stored token and disconnects', async () => {
const port = nextPort();
daemon = await FakeDaemon.start({ port, acceptToken: 'good-token' });
const h = memDeps({ token: 'good-token' });
transport = makeTransport(port, h);
await transport.connect();
await transport.unpair();
expect(h.store.token).toBeNull();
expect(transport.state).toBe('disconnected');
});
it('with autoPair off, a wrong token surfaces needsPairing and does NOT retry', async () => {
const port = nextPort();
daemon = await FakeDaemon.start({ port, acceptToken: 'the-real-one' });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,