From 23407974d2b1061f4efbd645242c83a7210dec19 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:35:29 +0400 Subject: [PATCH 1/3] =?UTF-8?q?ext:=20capture/index.ts=20=E2=80=94=20block?= =?UTF-8?q?ing=20onHeadersReceived=20hook,=20fail-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a response shouldCapture() likes: gather cookies, offer to the daemon under a hard 750 ms budget, and return {cancel:true} ONLY on an explicit {action:"take"}. Every other outcome — shouldCapture says no, daemon down / slow / erroring, cookies fail, anything throws — resolves to {} and Firefox downloads normally. Fail-open tests written first (tests/capture/hook.test.ts): offer() rejects, offer() never settles (resolves within budget), timeout-shaped rejection, getRules() throws, malformed details. Plus the happy paths and a check that the capture.offer payload is well-formed from details + stash + headers. background/index.ts: wire the stash + hook onto browser.webRequest, mirror capture rules via capture.getRules on connect and on a capture.* settings change; DEFAULT_CAPTURE_RULES (enabled:false) until the first mirror lands. 84 tests green; web-ext lint clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk --- extension/src/background/capture/index.ts | 228 ++++++++++++++++++++++ extension/src/background/capture/rules.ts | 13 ++ extension/src/background/index.ts | 42 +++- extension/tests/capture/hook.test.ts | 199 +++++++++++++++++++ 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 extension/src/background/capture/index.ts create mode 100644 extension/tests/capture/hook.test.ts diff --git a/extension/src/background/capture/index.ts b/extension/src/background/capture/index.ts new file mode 100644 index 0000000..cb48757 --- /dev/null +++ b/extension/src/background/capture/index.ts @@ -0,0 +1,228 @@ +// The blocking onHeadersReceived hook. +// +// For a response that shouldCapture() likes: gather cookies, offer it to the daemon with +// a hard 750 ms budget, and cancel the browser's own download ONLY on an explicit +// {action:"take"}. Everything else — shouldCapture says no, the daemon is down / slow / +// erroring, cookies fail, anything throws — resolves to {} and Firefox downloads +// normally. Fail-open is not a fallback path here; it is the default every branch returns +// to. tests/capture/hook.test.ts pins that. + +import type { + CaptureOfferParams, + CaptureOfferResult, + CaptureRules, + Cookie, +} from '../../shared/protocol/index.js'; + +import { normalizeHeaders } from './headers.js'; +import type { HeaderStash } from './headers.js'; +import { filenameExtension, shouldCapture, type CaptureCandidate, type ResourceType } from './rules.js'; + +const BUDGET_MS = 750; // docs/05 §2 and METHODS['capture.offer'].deadlineMs +const PROCEED: BlockingResponse = {}; +const CANCEL: BlockingResponse = { cancel: true }; + +export interface BlockingResponse { + cancel?: boolean; +} + +export interface OnHeadersReceivedDetails { + requestId: string; + url: string; + method: string; + type: ResourceType; + statusCode: number; + tabId: number; + responseHeaders?: { name: string; value?: string }[]; + documentUrl?: string | null; + originUrl?: string | null; +} + +interface WebRequestEvent void> { + addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void; + removeListener(listener: L): void; +} + +export interface HeadersReceivedWebRequest { + onHeadersReceived: WebRequestEvent< + (d: OnHeadersReceivedDetails) => BlockingResponse | Promise + >; +} + +/** A browser cookie, as `browser.cookies.getAll` returns it (superset of the wire type). */ +export interface CookieRecord { + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; +} + +export interface CaptureHookDeps { + /** Usually `(p, o) => transport.call('capture.offer', p, o)`. */ + offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise; + stash: Pick; + getCookies: (url: string) => Promise; + /** A synchronous snapshot of the daemon's capture policy (mirrored via capture.getRules). */ + getRules: () => CaptureRules; + /** Whether the user held the bypass modifier for the click behind this request. */ + bypassHeld?: (details: OnHeadersReceivedDetails) => boolean; + /** moz-extension://, for CaptureOfferParams.origin. */ + origin?: string; + budgetMs?: number; +} + +const ALL_URLS = ''; + +export class CaptureHook { + private readonly budgetMs: number; + private bound?: (d: OnHeadersReceivedDetails) => Promise; + private attachedTo?: HeadersReceivedWebRequest; + + constructor(private readonly deps: CaptureHookDeps) { + this.budgetMs = deps.budgetMs ?? BUDGET_MS; + } + + /** The listener. Always resolves within the budget to {} or {cancel:true}; never rejects. */ + handle = (details: OnHeadersReceivedDetails): Promise => { + return this.withinBudget(this.decide(details)); + }; + + attach(webRequest: HeadersReceivedWebRequest): void { + if (this.attachedTo) throw new Error('CaptureHook is already attached'); + this.attachedTo = webRequest; + this.bound = this.handle; + webRequest.onHeadersReceived.addListener(this.bound, { urls: [ALL_URLS] }, [ + 'blocking', + 'responseHeaders', + ]); + } + + detach(): void { + if (this.attachedTo && this.bound) { + this.attachedTo.onHeadersReceived.removeListener(this.bound); + } + this.attachedTo = undefined; + this.bound = undefined; + } + + private withinBudget(work: Promise): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: BlockingResponse): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => done(PROCEED), this.budgetMs); + (timer as { unref?: () => void }).unref?.(); + work.then(done, () => done(PROCEED)); + }); + } + + private async decide(details: OnHeadersReceivedDetails): Promise { + try { + const rules = this.deps.getRules(); + const responseHeaders = normalizeHeaders(details.responseHeaders); + const stashed = this.stashPeek(details.requestId); + + const candidate: CaptureCandidate = { + url: details.url, + method: details.method, + statusCode: details.statusCode, + type: details.type, + responseHeaders, + requestHeaders: stashed?.headers ?? {}, + documentUrl: details.documentUrl ?? details.originUrl ?? null, + bypassHeld: this.deps.bypassHeld?.(details) ?? false, + }; + + if (!shouldCapture(candidate, rules).capture) return PROCEED; + + // We are acting on this request now — take the stash entry. + const taken = this.deps.stash.take(details.requestId); + const requestHeaders = taken?.headers ?? candidate.requestHeaders; + + let cookies: Cookie[] = []; + try { + cookies = toWireCookies(await this.deps.getCookies(details.url)); + } catch { + cookies = []; // best-effort; still worth offering + } + + const params: CaptureOfferParams = { + url: details.url, + method: candidate.method.toUpperCase() === 'POST' ? 'POST' : 'GET', + tabUrl: candidate.documentUrl ?? '', + headers: requestHeaders, + cookies, + contentType: responseHeaders['content-type'] ?? null, + contentLength: intOrNull(responseHeaders['content-length']), + contentDisposition: responseHeaders['content-disposition'] ?? null, + filename: guessFilename(details.url, responseHeaders['content-disposition']), + referrer: requestHeaders['referer'] ?? null, + origin: this.deps.origin ?? null, + requestId: details.requestId, + }; + + const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); + return result.action === 'take' ? CANCEL : PROCEED; + } catch { + return PROCEED; // fail open on anything at all + } + } + + private stashPeek(requestId: string) { + try { + return this.deps.stash.peek(requestId); + } catch { + return undefined; + } + } +} + +// --- helpers ---------------------------------------------------------------------- + +function toWireCookies(records: CookieRecord[]): Cookie[] { + return records.map((c) => { + const out: Cookie = { name: c.name, value: c.value }; + if (c.domain !== undefined) out.domain = c.domain; + if (c.path !== undefined) out.path = c.path; + if (c.secure !== undefined) out.secure = c.secure; + if (c.httpOnly !== undefined) out.httpOnly = c.httpOnly; + return out; + }); +} + +function intOrNull(value: string | undefined): number | null { + if (value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** RFC 6266 filename, falling back to the URL's last path segment. */ +export function guessFilename(url: string, disposition: string | undefined): string | null { + if (disposition) { + const star = /filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)/i.exec(disposition); + if (star?.[1]) { + try { + return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')) || null; + } catch { + /* fall through */ + } + } + const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition); + if (plain?.[1]) return plain[1].trim() || null; + } + try { + const path = new URL(url).pathname; + const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1)); + if (base) return base; + } catch { + /* not a parseable URL */ + } + const ext = filenameExtension(url); + return ext ? `download.${ext}` : null; +} diff --git a/extension/src/background/capture/rules.ts b/extension/src/background/capture/rules.ts index 339e091..96b7bef 100644 --- a/extension/src/background/capture/rules.ts +++ b/extension/src/background/capture/rules.ts @@ -63,6 +63,19 @@ export interface CaptureDecision { reason: CaptureReason; } +/** + * What shouldCapture() runs against before the first capture.getRules has been mirrored + * from the daemon. `enabled: false` — we intercept nothing until we know the real policy. + */ +export const DEFAULT_CAPTURE_RULES: CaptureRules = { + enabled: false, + monitoredExtensions: [], + monitoredMimeTypes: [], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 0, +}; + const RENDERABLE_TYPES = new Set([ 'text/html', 'application/xhtml+xml', diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index eb846c0..ad2be03 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,20 +1,58 @@ // Background event-page entry. // -// Brings the transport up and holds the single shared reference to it. Capture, the -// popup relay, and context menus attach here as they land (docs/05 §6 build order). +// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture +// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety +// net. The popup relay and context menus attach here in later steps of the build order. +import { CaptureHook } from './capture/index.js'; +import { HeaderStash } from './capture/headers.js'; +import type { WebRequestLike } from './capture/headers.js'; +import type { HeadersReceivedWebRequest } from './capture/index.js'; +import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; +import type { CaptureRules } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; +let rules: CaptureRules = DEFAULT_CAPTURE_RULES; + +const stash = new HeaderStash(); +const hook = new CaptureHook({ + offer: (params, opts) => mustTransport().call('capture.offer', params, opts), + stash, + getCookies: (url) => browser.cookies.getAll({ url }), + getRules: () => rules, + origin: new URL(browser.runtime.getURL('/')).origin, +}); + +function mustTransport(): VeloxTransport { + if (!transport) throw new Error('transport not ready'); + return transport; +} + +async function refreshRules(): Promise { + try { + rules = await mustTransport().call('capture.getRules', {}); + } catch { + // Keep the last known rules; capture stays fail-open regardless (docs/05 §2). + } +} function onTransportState(status: TransportStatus): void { const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`); + if (status.state === 'connected') void refreshRules(); } async function start(): Promise { + stash.attach(browser.webRequest as unknown as WebRequestLike); + hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest); + 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(); + }); onTransportState(transport.status); } diff --git a/extension/tests/capture/hook.test.ts b/extension/tests/capture/hook.test.ts new file mode 100644 index 0000000..f1d7621 --- /dev/null +++ b/extension/tests/capture/hook.test.ts @@ -0,0 +1,199 @@ +// The blocking onHeadersReceived hook. +// +// Fail-open is the hard requirement (docs/05 §2, AGENT-EXT M1 DoD): daemon down, slow, or +// erroring must never cost the user a download. Those cases are written first below. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; +import { + CaptureHook, + type CaptureHookDeps, + type HeadersReceivedWebRequest, + type OnHeadersReceivedDetails, +} from '../../src/background/capture/index.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso'], + monitoredMimeTypes: ['application/octet-stream'], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 1, +}; + +function detailsFor(over: Partial = {}): OnHeadersReceivedDetails { + return { + requestId: '42', + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + type: 'other', + statusCode: 200, + tabId: 3, + responseHeaders: [ + { name: 'Content-Type', value: 'application/octet-stream' }, + { name: 'Content-Length', value: String(900 * 1024 * 1024) }, + ], + documentUrl: 'https://example.com/releases', + ...over, + }; +} + +interface Harness { + hook: CaptureHook; + offer: ReturnType; + stash: { take: ReturnType; peek: ReturnType }; + getCookies: ReturnType; +} + +function makeHook(over: Partial = {}): Harness { + const { offer: offerOver, getCookies: cookiesOver, stash: stashOver, ...rest } = over; + const stashEntry = { requestId: '42', url: '', method: 'GET', tabId: 3, stashedAt: 0, headers: { referer: 'https://example.com/releases' } }; + const stash = (stashOver as unknown as Harness['stash']) ?? { + take: vi.fn(() => stashEntry), + peek: vi.fn(() => stashEntry), + }; + const offer = + (offerOver as unknown as ReturnType) ?? + vi.fn(async (): Promise => ({ action: 'ignore', reason: 'type_not_monitored' })); + const getCookies = + (cookiesOver as unknown as ReturnType) ?? + vi.fn(async () => [{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + const deps: CaptureHookDeps = { + offer: offer as unknown as CaptureHookDeps['offer'], + stash: stash as unknown as CaptureHookDeps['stash'], + getCookies: getCookies as unknown as CaptureHookDeps['getCookies'], + getRules: () => RULES, + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + budgetMs: 40, + ...rest, + }; + return { hook: new CaptureHook(deps), offer, stash, getCookies }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CaptureHook — fail-open (written before the feature)', () => { + it('daemon down: offer() rejects → Firefox downloads normally', async () => { + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(new Error('transport is not connected')) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('daemon slow: offer() never settles → resolves to {} within the budget', async () => { + const { hook } = makeHook({ offer: vi.fn(() => new Promise(() => {})), budgetMs: 30 }); + const started = Date.now(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(Date.now() - started).toBeGreaterThanOrEqual(20); + expect(Date.now() - started).toBeLessThan(300); + }); + + it('daemon erroring: offer() rejects with a timeout-like error → {}', async () => { + const err = Object.assign(new Error('capture.offer timed out after 750 ms'), { name: 'RpcTimeoutError' }); + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(err) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('getRules() throws → {} and no offer attempted', async () => { + const offer = vi.fn(); + const { hook } = makeHook({ + offer: offer as unknown as CaptureHookDeps['offer'], + getRules: () => { + throw new Error('rules not loaded'); + }, + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + }); + + it('the listener never rejects, whatever the details', async () => { + const { hook } = makeHook(); + // @ts-expect-error deliberately malformed + await expect(hook.handle({})).resolves.toEqual({}); + }); +}); + +describe('CaptureHook — the happy paths', () => { + it('shouldCapture says no → {} and the offer is not made, stash not consumed', async () => { + const { hook, offer, stash } = makeHook({ getRules: () => ({ ...RULES, enabled: false }) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + expect(stash.take).not.toHaveBeenCalled(); + }); + + it('offer → {action:"take"} cancels the browser download', async () => { + const { hook, offer, stash } = makeHook({ + offer: vi.fn(async () => ({ action: 'take', taskId: 't1' })) as unknown as CaptureHookDeps['offer'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(offer).toHaveBeenCalledOnce(); + expect(stash.take).toHaveBeenCalledWith('42'); + }); + + it('offer → {action:"ignore"} lets Firefox proceed', async () => { + const { hook } = makeHook(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('builds a well-formed capture.offer from details + stash + response headers', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (vi.fn(async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as unknown) as CaptureHookDeps['offer'], + }); + await hook.handle(detailsFor()); + expect(seen[0]).toMatchObject({ + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + tabUrl: 'https://example.com/releases', + contentType: 'application/octet-stream', + contentLength: 900 * 1024 * 1024, + referrer: 'https://example.com/releases', + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + requestId: '42', + filename: 'ubuntu.iso', + }); + expect(seen[0]!.cookies).toEqual([{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + }); + + it('still offers when cookie collection fails, with an empty cookie list', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as CaptureHookDeps['offer'], + getCookies: (async () => { + throw new Error('no cookies perm'); + }) as CaptureHookDeps['getCookies'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(seen[0]!.cookies).toEqual([]); + }); +}); + +describe('CaptureHook.attach', () => { + class FakeEvent { + listeners: Array<(d: OnHeadersReceivedDetails) => unknown> = []; + addListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => void this.listeners.push(l); + removeListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => { + this.listeners = this.listeners.filter((x) => x !== l); + }; + } + + it('registers a blocking responseHeaders listener and unregisters on detach', () => { + const evt = new FakeEvent(); + const wr = { onHeadersReceived: evt } as unknown as HeadersReceivedWebRequest; + const { hook } = makeHook(); + + hook.attach(wr); + expect(evt.listeners).toHaveLength(1); + expect(() => hook.attach(wr)).toThrow(/already attached/); + + hook.detach(); + expect(evt.listeners).toHaveLength(0); + }); +}); From 175185bbfc48f2a5a793e2f1c641cee0639af1ce Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:39:40 +0400 Subject: [PATCH 2/3] =?UTF-8?q?ext:=20capture/downloads-api.ts=20=E2=80=94?= =?UTF-8?q?=20downloads.onCreated=20safety=20net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads that never reach the header hook (form POST results, service-worker responses, clicks Firefox routes straight to its downloader) surface here. If one looks like the daemon's, offer it FIRST and only cancel + erase Firefox's copy on {action:"take"} — a failed or slow offer can never leave the user with nothing. blob:/data: downloads are left to Firefox (the daemon can't fetch a blob URL). - offered-urls.ts: short-lived, bounded TTL set of URLs the header hook has already offered; the safety net checks it (via wasOffered) so nothing is double-handled. Hook gains an onOffered hook to populate it. - background/index.ts: both paths share one offer(), getCookies, rules mirror, and OfferedUrls instance. 13 new tests incl. fail-open (offer rejects -> 'error', ignore -> 'offer_declined', cancel() throwing after take still returns 'taken'). 101 tests green; web-ext lint clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk --- .../src/background/capture/downloads-api.ts | 186 ++++++++++++++++++ extension/src/background/capture/index.ts | 6 +- .../src/background/capture/offered-urls.ts | 51 +++++ extension/src/background/index.ts | 52 +++-- extension/tests/capture/downloads-api.test.ts | 174 ++++++++++++++++ extension/tests/capture/offered-urls.test.ts | 39 ++++ 6 files changed, 491 insertions(+), 17 deletions(-) create mode 100644 extension/src/background/capture/downloads-api.ts create mode 100644 extension/src/background/capture/offered-urls.ts create mode 100644 extension/tests/capture/downloads-api.test.ts create mode 100644 extension/tests/capture/offered-urls.test.ts diff --git a/extension/src/background/capture/downloads-api.ts b/extension/src/background/capture/downloads-api.ts new file mode 100644 index 0000000..68a8fc6 --- /dev/null +++ b/extension/src/background/capture/downloads-api.ts @@ -0,0 +1,186 @@ +// The downloads.onCreated safety net. +// +// Some downloads never reach the blocking header hook — a form POST's result, a +// service-worker-generated response, a click that Firefox routed straight to its own +// downloader. Those surface here. If one looks like something the daemon should own, +// offer it FIRST and only then cancel + erase Firefox's copy — so a failed or slow offer +// can never leave the user with no download at all. +// +// blob:/data: downloads are left to Firefox: their bytes exist only in the page, and the +// daemon cannot fetch a blob URL. + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules, Cookie } from '../../shared/protocol/index.js'; + +import { guessFilename, toWireCookies, type CookieRecord } from './index.js'; +import { filenameExtension, hostExcluded } from './rules.js'; + +export interface DownloadItem { + id: number; + url: string; + finalUrl?: string; + referrer?: string; + filename?: string; + mime?: string; + /** -1 when unknown. */ + fileSize?: number; + /** -1 when unknown. */ + totalBytes?: number; + state?: 'in_progress' | 'interrupted' | 'complete'; +} + +interface DownloadEvent void> { + addListener(listener: L): void; + removeListener(listener: L): void; +} + +export interface DownloadsApiLike { + onCreated: DownloadEvent<(item: DownloadItem) => void>; + cancel(id: number): Promise; + erase(query: { id: number }): Promise; +} + +export interface DownloadsSafetyNetDeps { + offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise; + downloads: DownloadsApiLike; + getCookies: (url: string) => Promise; + getRules: () => CaptureRules; + /** True when the header hook already offered this URL. */ + wasOffered?: (url: string) => boolean; + onOffered?: (url: string) => void; + origin?: string; + budgetMs?: number; +} + +/** Why the safety net did or did not act on a created download — for the popup diagnostics. */ +export type SafetyNetOutcome = + | 'taken' // offered and the daemon took it; Firefox's copy was cancelled + erased + | 'offer_declined' // offered, daemon replied ignore + | 'error' // fail-open: something threw, Firefox keeps the download + | 'capture_disabled' + | 'blob_or_data' + | 'excluded_host' + | 'already_offered' + | 'already_settled' + | 'no_rule_matched'; + +type VetoOutcome = Exclude; + +const BUDGET_MS = 750; + +export class DownloadsSafetyNet { + private readonly budgetMs: number; + private bound?: (item: DownloadItem) => void; + private attachedTo?: DownloadsApiLike; + + constructor(private readonly deps: DownloadsSafetyNetDeps) { + this.budgetMs = deps.budgetMs ?? BUDGET_MS; + } + + attach(downloads: DownloadsApiLike): void { + if (this.attachedTo) throw new Error('DownloadsSafetyNet is already attached'); + this.attachedTo = downloads; + this.bound = (item) => void this.handle(item); + downloads.onCreated.addListener(this.bound); + } + + detach(): void { + if (this.attachedTo && this.bound) this.attachedTo.onCreated.removeListener(this.bound); + this.attachedTo = undefined; + this.bound = undefined; + } + + /** Never throws. Returns what it did, for diagnostics/tests. */ + async handle(item: DownloadItem): Promise { + try { + const url = effectiveUrl(item); + const decision = this.decide(item, url); + if (!decision.capture) return decision.reason; + + let cookies: Cookie[] = []; + try { + cookies = toWireCookies(await this.deps.getCookies(url)); + } catch { + cookies = []; + } + + const params: CaptureOfferParams = { + url, + method: 'GET', // onCreated does not expose the method, and a POST body can't be replayed + tabUrl: item.referrer ?? '', + cookies, + contentType: item.mime ?? null, + contentLength: sizeOf(item), + filename: basenameOf(item.filename) ?? guessFilename(url, undefined), + referrer: item.referrer ?? null, + origin: this.deps.origin ?? null, + }; + + this.deps.onOffered?.(url); + const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); + if (result.action !== 'take') return 'offer_declined'; + + // The daemon owns it now — retire Firefox's copy. Both best-effort. + try { + await this.deps.downloads.cancel(item.id); + } catch { + /* already complete or gone */ + } + try { + await this.deps.downloads.erase({ id: item.id }); + } catch { + /* nothing to erase */ + } + return 'taken'; + } catch { + return 'error'; // fail open: Firefox keeps the download + } + } + + private decide(item: DownloadItem, url: string): { capture: true } | { capture: false; reason: VetoOutcome } { + const veto = (reason: VetoOutcome) => ({ capture: false as const, reason }); + const rules = this.deps.getRules(); + if (!rules.enabled) return veto('capture_disabled'); + if (/^(blob:|data:)/i.test(url)) return veto('blob_or_data'); + if (item.state === 'complete' || item.state === 'interrupted') return veto('already_settled'); + if (this.deps.wasOffered?.(url)) return veto('already_offered'); + + const host = hostOf(url); + if (host && hostExcluded(host, rules.excludedHosts)) return veto('excluded_host'); + + const ext = filenameExtension(basenameOf(item.filename) ?? url); + if (ext && rules.monitoredExtensions.includes(ext)) return { capture: true }; + if (item.mime && rules.monitoredMimeTypes.includes(item.mime.toLowerCase())) return { capture: true }; + + const size = sizeOf(item); + if (size !== null && size > rules.minSizeBytes) return { capture: true }; + + return veto('no_rule_matched'); + } +} + +// --- helpers ---------------------------------------------------------------------- + +function effectiveUrl(item: DownloadItem): string { + return item.finalUrl && item.finalUrl.length > 0 ? item.finalUrl : item.url; +} + +function sizeOf(item: DownloadItem): number | null { + for (const v of [item.totalBytes, item.fileSize]) { + if (typeof v === 'number' && v > 0) return v; + } + return null; +} + +function basenameOf(filename: string | undefined): string | undefined { + if (!filename) return undefined; + const base = filename.split(/[\\/]/).pop(); + return base && base.length > 0 ? base : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} diff --git a/extension/src/background/capture/index.ts b/extension/src/background/capture/index.ts index cb48757..c2ed05c 100644 --- a/extension/src/background/capture/index.ts +++ b/extension/src/background/capture/index.ts @@ -68,6 +68,9 @@ export interface CaptureHookDeps { getRules: () => CaptureRules; /** Whether the user held the bypass modifier for the click behind this request. */ bypassHeld?: (details: OnHeadersReceivedDetails) => boolean; + /** Called with the URL just before an offer is made, so the downloads.onCreated safety + * net can tell this request has already been handled here. */ + onOffered?: (url: string) => void; /** moz-extension://, for CaptureOfferParams.origin. */ origin?: string; budgetMs?: number; @@ -167,6 +170,7 @@ export class CaptureHook { requestId: details.requestId, }; + this.deps.onOffered?.(details.url); const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); return result.action === 'take' ? CANCEL : PROCEED; } catch { @@ -185,7 +189,7 @@ export class CaptureHook { // --- helpers ---------------------------------------------------------------------- -function toWireCookies(records: CookieRecord[]): Cookie[] { +export function toWireCookies(records: CookieRecord[]): Cookie[] { return records.map((c) => { const out: Cookie = { name: c.name, value: c.value }; if (c.domain !== undefined) out.domain = c.domain; diff --git a/extension/src/background/capture/offered-urls.ts b/extension/src/background/capture/offered-urls.ts new file mode 100644 index 0000000..7c4918a --- /dev/null +++ b/extension/src/background/capture/offered-urls.ts @@ -0,0 +1,51 @@ +// A short-lived record of URLs the header hook has already offered to the daemon, so the +// downloads.onCreated safety net does not offer the same thing a second time. +// +// Bounded and time-capped for the same reason the header stash is (capture/headers.ts): +// this sees a URL for every capture attempt and must never grow without bound. + +export interface OfferedUrlsOptions { + /** Entries older than this stop counting as "recently offered". Default 15 s. */ + ttlMs?: number; + /** Hard cap on tracked URLs; oldest evicted past it. Default 256. */ + maxEntries?: number; + now?: () => number; +} + +export class OfferedUrls { + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + /** url -> timestamp, insertion-ordered so the first key is the oldest. */ + private readonly seen = new Map(); + + constructor(opts: OfferedUrlsOptions = {}) { + this.ttlMs = opts.ttlMs ?? 15_000; + this.maxEntries = opts.maxEntries ?? 256; + this.now = opts.now ?? Date.now; + } + + mark(url: string): void { + this.seen.delete(url); + this.seen.set(url, this.now()); + while (this.seen.size > this.maxEntries) { + const oldest = this.seen.keys().next().value; + if (oldest === undefined) break; + this.seen.delete(oldest); + } + } + + has(url: string): boolean { + const at = this.seen.get(url); + if (at === undefined) return false; + if (this.now() - at > this.ttlMs) { + this.seen.delete(url); + return false; + } + return true; + } + + get size(): number { + return this.seen.size; + } +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index ad2be03..c524bc4 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,34 +1,53 @@ // Background event-page entry. // -// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture -// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety -// net. The popup relay and context menus attach here in later steps of the build order. +// Brings the transport up, mirrors the daemon's capture rules, and wires both capture +// 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 { CaptureHook } from './capture/index.js'; -import { HeaderStash } from './capture/headers.js'; -import type { WebRequestLike } from './capture/headers.js'; -import type { HeadersReceivedWebRequest } from './capture/index.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 { OfferedUrls } from './capture/offered-urls.js'; import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; -import type { CaptureRules } from '../shared/protocol/index.js'; +import type { CaptureOfferParams, CaptureRules } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; let rules: CaptureRules = DEFAULT_CAPTURE_RULES; -const stash = new HeaderStash(); -const hook = new CaptureHook({ - offer: (params, opts) => mustTransport().call('capture.offer', params, opts), - stash, - getCookies: (url) => browser.cookies.getAll({ url }), - getRules: () => rules, - origin: new URL(browser.runtime.getURL('/')).origin, -}); +const EXTENSION_ORIGIN = new URL(browser.runtime.getURL('/')).origin; function mustTransport(): VeloxTransport { if (!transport) throw new Error('transport not ready'); return transport; } +const offer = (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => + mustTransport().call('capture.offer', params, opts); +const getCookies = (url: string) => browser.cookies.getAll({ url }); + +const offered = new OfferedUrls(); +const stash = new HeaderStash(); + +const hook = new CaptureHook({ + offer, + stash, + getCookies, + getRules: () => rules, + onOffered: (url) => offered.mark(url), + origin: EXTENSION_ORIGIN, +}); + +const safetyNet = new DownloadsSafetyNet({ + offer, + downloads: browser.downloads as unknown as DownloadsApiLike, + getCookies, + getRules: () => rules, + wasOffered: (url) => offered.has(url), + onOffered: (url) => offered.mark(url), + origin: EXTENSION_ORIGIN, +}); + async function refreshRules(): Promise { try { rules = await mustTransport().call('capture.getRules', {}); @@ -46,6 +65,7 @@ function onTransportState(status: TransportStatus): void { 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); transport = await createTransport(); transport.onStateChange(onTransportState); diff --git a/extension/tests/capture/downloads-api.test.ts b/extension/tests/capture/downloads-api.test.ts new file mode 100644 index 0000000..5e94a6d --- /dev/null +++ b/extension/tests/capture/downloads-api.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; +import { + DownloadsSafetyNet, + type DownloadItem, + type DownloadsApiLike, + type DownloadsSafetyNetDeps, +} from '../../src/background/capture/downloads-api.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso'], + monitoredMimeTypes: ['application/octet-stream'], + minSizeBytes: 1024 * 1024, + excludedHosts: ['mail.example.com'], + rulesVersion: 1, +}; + +function item(over: Partial = {}): DownloadItem { + return { + id: 5, + url: 'https://cdn.example.com/pkg/tool.zip', + finalUrl: 'https://cdn.example.com/pkg/tool.zip', + referrer: 'https://example.com/get', + filename: '/home/u/Downloads/tool.zip', + mime: 'application/zip', + fileSize: -1, + totalBytes: 50 * 1024 * 1024, + state: 'in_progress', + ...over, + }; +} + +interface Harness { + net: DownloadsSafetyNet; + offer: ReturnType; + cancel: ReturnType; + erase: ReturnType; + seen: CaptureOfferParams[]; +} + +function makeNet(over: Partial = {}): Harness { + const seen: CaptureOfferParams[] = []; + const offer = + (over.offer as ReturnType | undefined) ?? + vi.fn(async (p: CaptureOfferParams): Promise => { + seen.push(p); + return { action: 'take', taskId: 't1' }; + }); + const cancel = vi.fn(async () => undefined); + const erase = vi.fn(async () => [5]); + const downloads = { onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, cancel, erase } as unknown as DownloadsApiLike; + const deps: DownloadsSafetyNetDeps = { + offer: offer as unknown as DownloadsSafetyNetDeps['offer'], + downloads, + getCookies: vi.fn(async () => [{ name: 'sid', value: 'x' }]) as unknown as DownloadsSafetyNetDeps['getCookies'], + getRules: () => RULES, + origin: 'moz-extension://uuid', + ...over, + }; + return { net: new DownloadsSafetyNet(deps), offer: offer as ReturnType, cancel, erase, seen }; +} + +describe('DownloadsSafetyNet.decide', () => { + it('offers a monitored-extension download and, on take, cancels + erases it', async () => { + const { net, cancel, erase, seen } = makeNet(); + await expect(net.handle(item({ mime: 'application/zip' }))).resolves.toBe('taken'); + expect(seen[0]).toMatchObject({ + url: 'https://cdn.example.com/pkg/tool.zip', + method: 'GET', + filename: 'tool.zip', + contentLength: 50 * 1024 * 1024, + referrer: 'https://example.com/get', + origin: 'moz-extension://uuid', + }); + expect(cancel).toHaveBeenCalledWith(5); + expect(erase).toHaveBeenCalledWith({ id: 5 }); + }); + + it('offers on the size threshold alone', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ filename: 'blob.bin', url: 'https://cdn.example.com/x', finalUrl: '', mime: 'application/x-thing', totalBytes: 9 * 1024 * 1024 })), + ).resolves.toBe('taken'); + }); + + it('leaves blob: downloads to Firefox', async () => { + const { net, offer } = makeNet(); + await expect(net.handle(item({ url: 'blob:https://example.com/abc', finalUrl: '' }))).resolves.toBe('blob_or_data'); + expect(offer).not.toHaveBeenCalled(); + }); + + it('skips an excluded host', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ url: 'https://mail.example.com/a.zip', finalUrl: 'https://mail.example.com/a.zip' })), + ).resolves.toBe('excluded_host'); + }); + + it('skips a download that already completed or was interrupted', async () => { + const { net } = makeNet(); + await expect(net.handle(item({ state: 'complete' }))).resolves.toBe('already_settled'); + await expect(net.handle(item({ state: 'interrupted' }))).resolves.toBe('already_settled'); + }); + + it('skips a URL the header hook already offered', async () => { + const { net, offer } = makeNet({ wasOffered: (u) => u === 'https://cdn.example.com/pkg/tool.zip' }); + await expect(net.handle(item())).resolves.toBe('already_offered'); + expect(offer).not.toHaveBeenCalled(); + }); + + it('skips when capture is disabled', async () => { + const { net } = makeNet({ getRules: () => ({ ...RULES, enabled: false }) }); + await expect(net.handle(item())).resolves.toBe('capture_disabled'); + }); + + it('skips a small file that matches no rule', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ filename: 'note.txt', url: 'https://cdn.example.com/note.txt', finalUrl: '', mime: 'text/plain', totalBytes: 200 })), + ).resolves.toBe('no_rule_matched'); + }); +}); + +describe('DownloadsSafetyNet — fail-open', () => { + it('offer() rejects → the Firefox download is left completely alone', async () => { + const { net, cancel, erase } = makeNet({ + offer: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as DownloadsSafetyNetDeps['offer'], + }); + await expect(net.handle(item())).resolves.toBe('error'); + expect(cancel).not.toHaveBeenCalled(); + expect(erase).not.toHaveBeenCalled(); + }); + + it('offer() → ignore leaves the download alone', async () => { + const { net, cancel } = makeNet({ + offer: vi.fn(async () => ({ action: 'ignore', reason: 'below_min_size' })) as unknown as DownloadsSafetyNetDeps['offer'], + }); + await expect(net.handle(item())).resolves.toBe('offer_declined'); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('cancel() throwing after a take does not throw out of handle()', async () => { + const { net, erase } = makeNet(); + (net as unknown as { deps: DownloadsSafetyNetDeps }).deps.downloads.cancel = vi + .fn() + .mockRejectedValue(new Error('already complete')); + await expect(net.handle(item())).resolves.toBe('taken'); + expect(erase).toHaveBeenCalled(); // still tries to erase + }); + + it('still offers with an empty cookie list when cookie collection fails', async () => { + const { net, seen } = makeNet({ + getCookies: vi.fn().mockRejectedValue(new Error('no perm')) as unknown as DownloadsSafetyNetDeps['getCookies'], + }); + await expect(net.handle(item())).resolves.toBe('taken'); + expect(seen[0]!.cookies).toEqual([]); + }); +}); + +describe('DownloadsSafetyNet.attach', () => { + it('registers and unregisters an onCreated listener', () => { + const add = vi.fn(); + const remove = vi.fn(); + const downloads = { onCreated: { addListener: add, removeListener: remove }, cancel: vi.fn(), erase: vi.fn() } as unknown as DownloadsApiLike; + const { net } = makeNet(); + net.attach(downloads); + expect(add).toHaveBeenCalledOnce(); + expect(() => net.attach(downloads)).toThrow(/already attached/); + net.detach(); + expect(remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/extension/tests/capture/offered-urls.test.ts b/extension/tests/capture/offered-urls.test.ts new file mode 100644 index 0000000..dcecf30 --- /dev/null +++ b/extension/tests/capture/offered-urls.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { OfferedUrls } from '../../src/background/capture/offered-urls.js'; + +describe('OfferedUrls', () => { + it('remembers a URL for the TTL, then forgets it', () => { + let now = 0; + const o = new OfferedUrls({ ttlMs: 1000, now: () => now }); + o.mark('https://x/a.zip'); + expect(o.has('https://x/a.zip')).toBe(true); + now = 1001; + expect(o.has('https://x/a.zip')).toBe(false); + expect(o.size).toBe(0); + }); + + it('re-mark refreshes the TTL', () => { + let now = 0; + const o = new OfferedUrls({ ttlMs: 1000, now: () => now }); + o.mark('u'); + now = 900; + o.mark('u'); + now = 1800; // 900 since the last mark + expect(o.has('u')).toBe(true); + }); + + it('evicts the oldest past the size cap', () => { + const o = new OfferedUrls({ maxEntries: 2, ttlMs: 10_000, now: () => 0 }); + o.mark('a'); + o.mark('b'); + o.mark('c'); + expect(o.has('a')).toBe(false); + expect(o.has('b')).toBe(true); + expect(o.has('c')).toBe(true); + }); + + it('is false for a URL it has never seen', () => { + expect(new OfferedUrls().has('nope')).toBe(false); + }); +}); From ec288db5ea5aac3129b7ca2d3ba6327eb2d340b0 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:41:54 +0400 Subject: [PATCH 3/3] =?UTF-8?q?ext:=20context-menus.ts=20=E2=80=94=20link/?= =?UTF-8?q?media=20menu=20items=20+=20grab-tab=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/05 §3: "Download with Velox" on a link (linkUrl) or a media element (srcUrl) hands that one URL to download.add with the page as referrer; a configurable command (Ctrl+Shift+U) does the same for the active tab. These are explicit user requests, so they skip shouldCapture and the fail-open path — a failure is surfaced to the user via notifications instead. The page/selection "Download all links…" item waits on the content-script link harvester (build-order step 7) and will be added there. manifest: commands.velox-grab-current-tab. 9 tests. 110 total, lint clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk --- extension/manifest.json | 9 +- extension/src/background/context-menus.ts | 123 ++++++++++++++++++++ extension/src/background/index.ts | 19 ++- extension/tests/context-menus.test.ts | 134 ++++++++++++++++++++++ 4 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 extension/src/background/context-menus.ts create mode 100644 extension/tests/context-menus.test.ts diff --git a/extension/manifest.json b/extension/manifest.json index 4d165e7..9f9097a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -30,5 +30,12 @@ "nativeMessaging" ], - "host_permissions": [""] + "host_permissions": [""], + + "commands": { + "velox-grab-current-tab": { + "suggested_key": { "default": "Ctrl+Shift+U" }, + "description": "Send the current tab's URL to Velox" + } + } } diff --git a/extension/src/background/context-menus.ts b/extension/src/background/context-menus.ts new file mode 100644 index 0000000..c9d9d73 --- /dev/null +++ b/extension/src/background/context-menus.ts @@ -0,0 +1,123 @@ +// Right-click and keyboard entry points. +// +// docs/05 §3: "Download with Velox" on a link or a media element hands that one URL to +// the daemon; a configurable command grabs the active tab's URL the same way. The +// page/selection "Download all links…" item waits on the content-script link harvester +// (build-order step 7) and is added there. +// +// These are explicit user requests, so they go straight to download.add — no +// shouldCapture, no fail-open dance. + +import type { DownloadSpec } from '../shared/protocol/index.js'; + +export const LINK_MENU_ID = 'velox-download-link'; +export const MEDIA_MENU_ID = 'velox-download-media'; +export const GRAB_TAB_COMMAND = 'velox-grab-current-tab'; + +interface OnClickedInfo { + menuItemId: string | number; + linkUrl?: string; + srcUrl?: string; + pageUrl?: string; +} + +interface Tab { + url?: string; + title?: string; +} + +interface Listenable { + addListener(cb: (...args: A) => void): void; + removeListener(cb: (...args: A) => void): void; +} + +export interface MenusLike { + create(props: { + id: string; + title: string; + contexts: string[]; + }): void; + removeAll(): Promise | void; + onClicked: Listenable<[OnClickedInfo, Tab | undefined]>; +} + +export interface CommandsLike { + onCommand: Listenable<[string, Tab | undefined]>; +} + +export interface TabsLike { + query(q: { active: true; currentWindow: true }): Promise; +} + +export interface ContextMenusDeps { + addDownload: (spec: DownloadSpec) => Promise; + menus: MenusLike; + tabs: TabsLike; + commands?: CommandsLike; + /** Surface a failure to the user (a notification, usually). Optional. */ + onError?: (message: string) => void; +} + +export class ContextMenus { + private onClicked?: (info: OnClickedInfo, tab: Tab | undefined) => void; + private onCommand?: (name: string, tab: Tab | undefined) => void; + + constructor(private readonly deps: ContextMenusDeps) {} + + async register(): Promise { + await this.deps.menus.removeAll(); + this.deps.menus.create({ id: LINK_MENU_ID, title: 'Download with Velox', contexts: ['link'] }); + this.deps.menus.create({ + id: MEDIA_MENU_ID, + title: 'Download with Velox', + contexts: ['image', 'video', 'audio'], + }); + + this.onClicked = (info, tab) => void this.handleClick(info, tab); + this.deps.menus.onClicked.addListener(this.onClicked); + + if (this.deps.commands) { + this.onCommand = (name, tab) => void this.handleCommand(name, tab); + this.deps.commands.onCommand.addListener(this.onCommand); + } + } + + dispose(): void { + if (this.onClicked) this.deps.menus.onClicked.removeListener(this.onClicked); + if (this.onCommand && this.deps.commands) this.deps.commands.onCommand.removeListener(this.onCommand); + this.onClicked = undefined; + this.onCommand = undefined; + } + + private async handleClick(info: OnClickedInfo, _tab: Tab | undefined): Promise { + let url: string | undefined; + if (info.menuItemId === LINK_MENU_ID) url = info.linkUrl; + else if (info.menuItemId === MEDIA_MENU_ID) url = info.srcUrl; + else return; + await this.add(url, info.pageUrl); + } + + private async handleCommand(name: string, tab: Tab | undefined): Promise { + if (name !== GRAB_TAB_COMMAND) return; + const active = tab ?? (await this.deps.tabs.query({ active: true, currentWindow: true }))[0]; + await this.add(active?.url, active?.url); + } + + private async add(url: string | undefined, referrer: string | undefined): Promise { + if (!url || !/^https?:/i.test(url)) { + this.deps.onError?.('Velox can only download http(s) links.'); + return; + } + const spec: DownloadSpec = { url }; + if (referrer) spec.referrer = referrer; + try { + await this.deps.addDownload(spec); + } catch (err) { + this.deps.onError?.(`Velox could not start the download: ${describe(err)}`); + } + } +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index c524bc4..ed7df75 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -9,8 +9,14 @@ import { HeaderStash, type WebRequestLike } from './capture/headers.js'; import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js'; import { OfferedUrls } from './capture/offered-urls.js'; import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; +import { + ContextMenus, + type CommandsLike, + type MenusLike, + type TabsLike, +} from './context-menus.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; -import type { CaptureOfferParams, CaptureRules } from '../shared/protocol/index.js'; +import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; let rules: CaptureRules = DEFAULT_CAPTURE_RULES; @@ -48,6 +54,16 @@ const safetyNet = new DownloadsSafetyNet({ origin: EXTENSION_ORIGIN, }); +const contextMenus = new ContextMenus({ + addDownload: (spec: DownloadSpec) => mustTransport().call('download.add', spec), + menus: browser.contextMenus as unknown as MenusLike, + tabs: browser.tabs as unknown as TabsLike, + commands: browser.commands as unknown as CommandsLike, + onError: (message) => { + void browser.notifications.create({ type: 'basic', title: 'Velox', message }); + }, +}); + async function refreshRules(): Promise { try { rules = await mustTransport().call('capture.getRules', {}); @@ -66,6 +82,7 @@ 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(); transport = await createTransport(); transport.onStateChange(onTransportState); diff --git a/extension/tests/context-menus.test.ts b/extension/tests/context-menus.test.ts new file mode 100644 index 0000000..f1bcf13 --- /dev/null +++ b/extension/tests/context-menus.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DownloadSpec } from '../src/shared/protocol/index.js'; +import { + ContextMenus, + GRAB_TAB_COMMAND, + LINK_MENU_ID, + MEDIA_MENU_ID, + type ContextMenusDeps, +} from '../src/background/context-menus.js'; + +class Event { + fns: Array<(...a: A) => void> = []; + addListener = (f: (...a: A) => void): void => void this.fns.push(f); + removeListener = (f: (...a: A) => void): void => { + this.fns = this.fns.filter((x) => x !== f); + }; + emit(...a: A): void { + for (const f of this.fns) f(...a); + } +} + +function harness(over: Partial = {}) { + const created: Array<{ id: string; contexts: string[] }> = []; + const onClicked = new Event<[{ menuItemId: string | number; linkUrl?: string; srcUrl?: string; pageUrl?: string }, unknown]>(); + const onCommand = new Event<[string, unknown]>(); + const menus = { + create: (p: { id: string; title: string; contexts: string[] }) => void created.push({ id: p.id, contexts: p.contexts }), + removeAll: vi.fn(async () => undefined), + onClicked, + }; + const addDownload = vi.fn(async (_spec: DownloadSpec) => ({ taskId: 't' })); + const tabs = { query: vi.fn(async () => [{ url: 'https://example.com/watch' }]) }; + const onError = vi.fn(); + const deps: ContextMenusDeps = { + addDownload: addDownload as unknown as ContextMenusDeps['addDownload'], + menus: menus as unknown as ContextMenusDeps['menus'], + tabs: tabs as unknown as ContextMenusDeps['tabs'], + commands: { onCommand } as unknown as ContextMenusDeps['commands'], + onError, + ...over, + }; + return { cm: new ContextMenus(deps), created, onClicked, onCommand, addDownload, tabs, onError, menus }; +} + +describe('ContextMenus', () => { + it('registers a link item and a media item after clearing stale ones', async () => { + const { cm, created, menus } = harness(); + await cm.register(); + expect(menus.removeAll).toHaveBeenCalled(); + expect(created).toEqual([ + { id: LINK_MENU_ID, contexts: ['link'] }, + { id: MEDIA_MENU_ID, contexts: ['image', 'video', 'audio'] }, + ]); + }); + + it('a link click sends linkUrl + pageUrl referrer to download.add', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit( + { menuItemId: LINK_MENU_ID, linkUrl: 'https://cdn.example.com/a.zip', pageUrl: 'https://example.com/p' }, + undefined, + ); + await Promise.resolve(); + expect(addDownload).toHaveBeenCalledWith({ + url: 'https://cdn.example.com/a.zip', + referrer: 'https://example.com/p', + } satisfies DownloadSpec); + }); + + it('a media click uses srcUrl', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: MEDIA_MENU_ID, srcUrl: 'https://v.example.com/clip.mp4', pageUrl: 'https://v.example.com' }, undefined); + await Promise.resolve(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://v.example.com/clip.mp4'); + }); + + it('ignores a click on some other extension menu item', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: 'someone-elses-item', linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + }); + + it('rejects a non-http link with a user-facing error, no download', async () => { + const { cm, onClicked, addDownload, onError } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'ftp://legacy/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.stringMatching(/http/)); + }); + + it('the grab-tab command downloads the active tab URL', async () => { + const { cm, onCommand, addDownload, tabs } = harness(); + await cm.register(); + onCommand.emit(GRAB_TAB_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(tabs.query).toHaveBeenCalled(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://example.com/watch'); + }); + + it('the grab-tab command prefers the tab handed to the listener', async () => { + const { cm, onCommand, addDownload, tabs } = harness(); + await cm.register(); + onCommand.emit(GRAB_TAB_COMMAND, { url: 'https://direct.example/page' }); + await Promise.resolve(); + expect(tabs.query).not.toHaveBeenCalled(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://direct.example/page'); + }); + + it('surfaces a download.add failure through onError', async () => { + const { cm, onClicked, onError } = harness({ + addDownload: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as ContextMenusDeps['addDownload'], + }); + await cm.register(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledWith(expect.stringMatching(/daemon down/)); + }); + + it('dispose() detaches listeners', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + cm.dispose(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + }); +});