Files
vdm/extension/src/background/context-menus.ts
T
samiandClaude Sonnet 5 ec288db5ea 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
2026-09-10 15:41:54 +04:00

124 lines
3.9 KiB
TypeScript

// 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);
}