merge: lane/ext
This commit is contained in:
@@ -30,5 +30,12 @@
|
|||||||
"nativeMessaging"
|
"nativeMessaging"
|
||||||
],
|
],
|
||||||
|
|
||||||
"host_permissions": ["<all_urls>"]
|
"host_permissions": ["<all_urls>"],
|
||||||
|
|
||||||
|
"commands": {
|
||||||
|
"velox-grab-current-tab": {
|
||||||
|
"suggested_key": { "default": "Ctrl+Shift+U" },
|
||||||
|
"description": "Send the current tab's URL to Velox"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<L extends (...a: never[]) => void> {
|
||||||
|
addListener(listener: L): void;
|
||||||
|
removeListener(listener: L): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsApiLike {
|
||||||
|
onCreated: DownloadEvent<(item: DownloadItem) => void>;
|
||||||
|
cancel(id: number): Promise<void>;
|
||||||
|
erase(query: { id: number }): Promise<number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsSafetyNetDeps {
|
||||||
|
offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise<CaptureOfferResult>;
|
||||||
|
downloads: DownloadsApiLike;
|
||||||
|
getCookies: (url: string) => Promise<CookieRecord[]>;
|
||||||
|
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<SafetyNetOutcome, 'taken' | 'offer_declined' | 'error'>;
|
||||||
|
|
||||||
|
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<SafetyNetOutcome> {
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// 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<L extends (...a: never[]) => void> {
|
||||||
|
addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void;
|
||||||
|
removeListener(listener: L): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeadersReceivedWebRequest {
|
||||||
|
onHeadersReceived: WebRequestEvent<
|
||||||
|
(d: OnHeadersReceivedDetails) => BlockingResponse | Promise<BlockingResponse>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<CaptureOfferResult>;
|
||||||
|
stash: Pick<HeaderStash, 'take' | 'peek'>;
|
||||||
|
getCookies: (url: string) => Promise<CookieRecord[]>;
|
||||||
|
/** 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;
|
||||||
|
/** 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://<uuid>, for CaptureOfferParams.origin. */
|
||||||
|
origin?: string;
|
||||||
|
budgetMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_URLS = '<all_urls>';
|
||||||
|
|
||||||
|
export class CaptureHook {
|
||||||
|
private readonly budgetMs: number;
|
||||||
|
private bound?: (d: OnHeadersReceivedDetails) => Promise<BlockingResponse>;
|
||||||
|
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<BlockingResponse> => {
|
||||||
|
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<BlockingResponse>): Promise<BlockingResponse> {
|
||||||
|
return new Promise<BlockingResponse>((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<BlockingResponse> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.deps.onOffered?.(details.url);
|
||||||
|
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 ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, number>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,19 @@ export interface CaptureDecision {
|
|||||||
reason: CaptureReason;
|
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([
|
const RENDERABLE_TYPES = new Set([
|
||||||
'text/html',
|
'text/html',
|
||||||
'application/xhtml+xml',
|
'application/xhtml+xml',
|
||||||
|
|||||||
@@ -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<A extends unknown[]> {
|
||||||
|
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> | void;
|
||||||
|
onClicked: Listenable<[OnClickedInfo, Tab | undefined]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandsLike {
|
||||||
|
onCommand: Listenable<[string, Tab | undefined]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsLike {
|
||||||
|
query(q: { active: true; currentWindow: true }): Promise<Tab[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenusDeps {
|
||||||
|
addDownload: (spec: DownloadSpec) => Promise<unknown>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,20 +1,95 @@
|
|||||||
// Background event-page entry.
|
// Background event-page entry.
|
||||||
//
|
//
|
||||||
// Brings the transport up and holds the single shared reference to it. Capture, the
|
// Brings the transport up, mirrors the daemon's capture rules, and wires both capture
|
||||||
// popup relay, and context menus attach here as they land (docs/05 §6 build order).
|
// 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 { 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 {
|
||||||
|
ContextMenus,
|
||||||
|
type CommandsLike,
|
||||||
|
type MenusLike,
|
||||||
|
type TabsLike,
|
||||||
|
} from './context-menus.js';
|
||||||
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
||||||
|
import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js';
|
||||||
|
|
||||||
let transport: VeloxTransport | undefined;
|
let transport: VeloxTransport | undefined;
|
||||||
|
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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 {
|
function onTransportState(status: TransportStatus): void {
|
||||||
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
||||||
console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`);
|
console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
if (status.state === 'connected') void refreshRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
|
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
||||||
|
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
||||||
|
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
|
||||||
|
void contextMenus.register();
|
||||||
|
|
||||||
transport = await createTransport();
|
transport = await createTransport();
|
||||||
transport.onStateChange(onTransportState);
|
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);
|
onTransportState(transport.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {}): 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<typeof vi.fn>;
|
||||||
|
cancel: ReturnType<typeof vi.fn>;
|
||||||
|
erase: ReturnType<typeof vi.fn>;
|
||||||
|
seen: CaptureOfferParams[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNet(over: Partial<DownloadsSafetyNetDeps> = {}): Harness {
|
||||||
|
const seen: CaptureOfferParams[] = [];
|
||||||
|
const offer =
|
||||||
|
(over.offer as ReturnType<typeof vi.fn> | undefined) ??
|
||||||
|
vi.fn(async (p: CaptureOfferParams): Promise<CaptureOfferResult> => {
|
||||||
|
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<typeof vi.fn>, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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<typeof vi.fn>;
|
||||||
|
stash: { take: ReturnType<typeof vi.fn>; peek: ReturnType<typeof vi.fn> };
|
||||||
|
getCookies: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHook(over: Partial<CaptureHookDeps> = {}): 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<typeof vi.fn>) ??
|
||||||
|
vi.fn(async (): Promise<CaptureOfferResult> => ({ action: 'ignore', reason: 'type_not_monitored' }));
|
||||||
|
const getCookies =
|
||||||
|
(cookiesOver as unknown as ReturnType<typeof vi.fn>) ??
|
||||||
|
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<never>(() => {})), 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<A extends unknown[]> {
|
||||||
|
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<ContextMenusDeps> = {}) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user