diff --git a/extension/manifest.json b/extension/manifest.json index 4d165e7..9f9097a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -30,5 +30,12 @@ "nativeMessaging" ], - "host_permissions": [""] + "host_permissions": [""], + + "commands": { + "velox-grab-current-tab": { + "suggested_key": { "default": "Ctrl+Shift+U" }, + "description": "Send the current tab's URL to Velox" + } + } } diff --git a/extension/src/background/context-menus.ts b/extension/src/background/context-menus.ts new file mode 100644 index 0000000..c9d9d73 --- /dev/null +++ b/extension/src/background/context-menus.ts @@ -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 { + 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); +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index c524bc4..ed7df75 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -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 { try { rules = await mustTransport().call('capture.getRules', {}); @@ -66,6 +82,7 @@ async function start(): Promise { 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); diff --git a/extension/tests/context-menus.test.ts b/extension/tests/context-menus.test.ts new file mode 100644 index 0000000..f1bcf13 --- /dev/null +++ b/extension/tests/context-menus.test.ts @@ -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 { + 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 = {}) { + 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(); + }); +});