ext: popup, options, and the panel bridge (build order step 6)
Popup and Options are separate documents from the background page and can't reach its live VeloxTransport directly, so background/bridge.ts relays it over one browser.runtime.connect port per document (call/subscribe/getStatus/reconnect/pair/unpair/setOverride in; result/event/status/pairError out). shared/panel-client.ts is the client side both surfaces use. Popup (src/popup/): status dot + text, active-downloads list driven by event.task.progress/added/state/removed (repaints ride the event's own <=4 Hz cap rather than adding a second timer), pause/resume buttons, "Start it" wired to a reconnect request. State lives in a DOM-free store.ts for unit testing; rendering uses createElement, not innerHTML (web-ext lint flags the latter). Options (src/options/): transport override select, pairing (code entry + pair/unpair, backed by two new WebSocketTransport methods, pairWithCode/unpair), and the daemon's capture policy mirrored via capture.getRules. The capture-policy form is editable only when the active transport is native messaging (uds) -- settings.set and rules.upsert are privileged, uds-only methods per shared/protocol METHODS, so WebSocket can't write them no matter what the page shows; CLAUDE.md section 2 rules out working around that locally. The bridge's status payload adds a kind field (which transport is live) for this to key off. Default category is the one piece of state that's genuinely the extension's own, not the daemon's, and lives in browser.storage.local via options/prefs.ts. Pure decisions (statusLine, pairingAvailable, captureRulesEditable) are split into view.ts for unit testing without a DOM. manifest.json registers the popup action and options_ui page (and, in the same edit, the content_scripts entry the next commit's media detection needs -- split by file, not by manifest line). build.mjs gains popup/options as further esbuild entry points, plus copying their static HTML/CSS into dist/. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user