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