diff --git a/extension/manifest.json b/extension/manifest.json index 9f9097a..46feedd 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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": [""], + "js": ["dist/content.js"], + "run_at": "document_idle", + "all_frames": true + } + ], + "permissions": [ "webRequest", "webRequestBlocking", diff --git a/extension/scripts/build.mjs b/extension/scripts/build.mjs index 03c06b3..272c6b3 100644 --- a/extension/scripts/build.mjs +++ b/extension/scripts/build.mjs @@ -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))); } diff --git a/extension/src/background/bridge.ts b/extension/src/background/bridge.ts new file mode 100644 index 0000000..f38a8a1 --- /dev/null +++ b/extension/src/background/bridge.ts @@ -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; +} + +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 { + 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) }); + } + } +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index ed7df75..9820628 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -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 { + 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 { 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); } diff --git a/extension/src/options/options.css b/extension/src/options/options.css new file mode 100644 index 0000000..78fe824 --- /dev/null +++ b/extension/src/options/options.css @@ -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; +} diff --git a/extension/src/options/options.html b/extension/src/options/options.html new file mode 100644 index 0000000..9d429ca --- /dev/null +++ b/extension/src/options/options.html @@ -0,0 +1,63 @@ + + + + + Velox Options + + + +

Velox

+ +
+

Transport

+ +

Connecting…

+
+ +
+

Pairing

+

If the Velox app isn't running to show an Allow prompt, type the 4-digit code it displayed:

+ + + +

+
+ +
+

Capture policy

+

Loading…

+

These are set by the Velox app or CLI. Connect via native messaging (or edit them there) to change them from here.

+ +
+ +
+

Default category

+

Applied to downloads sent from the right-click menu and the keyboard shortcut.

+ +
+ + + + diff --git a/extension/src/options/options.ts b/extension/src/options/options.ts new file mode 100644 index 0000000..335cdfe --- /dev/null +++ b/extension/src/options/options.ts @@ -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('#transport-override')!; +const $statusText = document.querySelector('#transport-status')!; +const $pairCode = document.querySelector('#pair-code')!; +const $pairButton = document.querySelector('#pair-button')!; +const $unpairButton = document.querySelector('#unpair-button')!; +const $pairMessage = document.querySelector('#pair-message')!; +const $rulesSummary = document.querySelector('#rules-summary')!; +const $rulesForm = document.querySelector('#rules-form')!; +const $rulesLocked = document.querySelector('#rules-locked')!; +const $minSize = document.querySelector('#min-size')!; +const $excludedHosts = document.querySelector('#excluded-hosts')!; +const $bypassModifier = document.querySelector('#bypass-modifier')!; +const $defaultCategory = document.querySelector('#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 { + 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 { + 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 { + 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(); diff --git a/extension/src/options/prefs.ts b/extension/src/options/prefs.ts new file mode 100644 index 0000000..25a7031 --- /dev/null +++ b/extension/src/options/prefs.ts @@ -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 { + 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 { + if (categoryId === null) await browser.storage.local.remove(KEY.defaultCategoryId); + else await browser.storage.local.set({ [KEY.defaultCategoryId]: categoryId }); +} diff --git a/extension/src/options/view.ts b/extension/src/options/view.ts new file mode 100644 index 0000000..aa416b6 --- /dev/null +++ b/extension/src/options/view.ts @@ -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'; +} diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css new file mode 100644 index 0000000..34b6138 --- /dev/null +++ b/extension/src/popup/popup.css @@ -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; +} diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html new file mode 100644 index 0000000..e6080c7 --- /dev/null +++ b/extension/src/popup/popup.html @@ -0,0 +1,18 @@ + + + + + Velox + + + +
+ + Connecting… + +
+
    +

    No active downloads.

    + + + diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..1768f1d --- /dev/null +++ b/extension/src/popup/popup.ts @@ -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('#status-dot')!; +const $statusText = document.querySelector('#status-text')!; +const $list = document.querySelector('#task-list')!; +const $empty = document.querySelector('#empty')!; +const $startButton = document.querySelector('#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 { + 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(); diff --git a/extension/src/popup/store.ts b/extension/src/popup/store.ts new file mode 100644 index 0000000..2c97a7b --- /dev/null +++ b/extension/src/popup/store.ts @@ -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(); + + 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)); +} diff --git a/extension/src/shared/panel-client.ts b/extension/src/shared/panel-client.ts new file mode 100644 index 0000000..9b47bf2 --- /dev/null +++ b/extension/src/shared/panel-client.ts @@ -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(); + private eventListeners = new Map void>>(); + private statusListeners = new Set<(status: PanelStatus) => void>(); + private pairErrorListeners = new Set<(error: { code?: number; message: string }) => void>(); + private subscribed = new Set(); + + constructor(connect: () => browser.runtime.Port = () => browser.runtime.connect({ name: PORT_NAME })) { + this.port = connect(); + this.port.onMessage.addListener((raw) => this.onMessage(raw as Record)); + } + + private onMessage(msg: Record): 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(method: M, params: Params): Promise> { + const id = this.nextId++; + return new Promise>((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(event: E, cb: (payload: EventPayload) => 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(); + } +} diff --git a/extension/tests/background/bridge.test.ts b/extension/tests/background/bridge.test.ts new file mode 100644 index 0000000..de072bc --- /dev/null +++ b/extension/tests/background/bridge.test.ts @@ -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 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 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 }, + }); + }); +}); diff --git a/extension/tests/options/prefs.test.ts b/extension/tests/options/prefs.test.ts new file mode 100644 index 0000000..95ca8d4 --- /dev/null +++ b/extension/tests/options/prefs.test.ts @@ -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(); + }); +}); diff --git a/extension/tests/options/view.test.ts b/extension/tests/options/view.test.ts new file mode 100644 index 0000000..c6b4c44 --- /dev/null +++ b/extension/tests/options/view.test.ts @@ -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 { + 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); + }); +}); diff --git a/extension/tests/popup/store.test.ts b/extension/tests/popup/store.test.ts new file mode 100644 index 0000000..d7fad6f --- /dev/null +++ b/extension/tests/popup/store.test.ts @@ -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 { + 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(); + }); +});