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:
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -9,8 +9,14 @@ import { HeaderStash, type WebRequestLike } from './capture/headers.js';
|
|||||||
import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js';
|
import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js';
|
||||||
import { OfferedUrls } from './capture/offered-urls.js';
|
import { OfferedUrls } from './capture/offered-urls.js';
|
||||||
import { DEFAULT_CAPTURE_RULES } from './capture/rules.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 } from '../shared/protocol/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;
|
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
||||||
@@ -48,6 +54,16 @@ const safetyNet = new DownloadsSafetyNet({
|
|||||||
origin: EXTENSION_ORIGIN,
|
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> {
|
async function refreshRules(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
rules = await mustTransport().call('capture.getRules', {});
|
rules = await mustTransport().call('capture.getRules', {});
|
||||||
@@ -66,6 +82,7 @@ async function start(): Promise<void> {
|
|||||||
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
||||||
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
||||||
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
|
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
|
||||||
|
void contextMenus.register();
|
||||||
|
|
||||||
transport = await createTransport();
|
transport = await createTransport();
|
||||||
transport.onStateChange(onTransportState);
|
transport.onStateChange(onTransportState);
|
||||||
|
|||||||
@@ -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