merge: lane/ext
This commit is contained in:
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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",
|
||||
|
||||
Generated
+920
-160
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 daemon’s 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();
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user