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
124 lines
3.9 KiB
TypeScript
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);
|
|
}
|