ext: context-menus.ts — link/media menu items + grab-tab command

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
2026-09-10 15:41:54 +04:00
co-authored by Claude Sonnet 5
parent 175185bbfc
commit ec288db5ea
4 changed files with 283 additions and 2 deletions
+8 -1
View File
@@ -30,5 +30,12 @@
"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"
}
}
}
+123
View File
@@ -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);
}
+18 -1
View File
@@ -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<void> {
try {
rules = await mustTransport().call('capture.getRules', {});
@@ -66,6 +82,7 @@ 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.onStateChange(onTransportState);
+134
View File
@@ -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();
});
});