diff --git a/addons-host.js b/addons-host.js index 465d28f..9eeae5f 100644 --- a/addons-host.js +++ b/addons-host.js @@ -49,6 +49,16 @@ const KNOWN_CAPABILITIES = new Set([ // via window.silentmode.invoke(). "vault-derive", "page-inject", "approval-modal", "capture-tab", "toolbar-menu", "open-tab", + // context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}] + // — chrome merges these into every tab's right-click + // menu, filtered by `when` (selectionText | linkURL | + // editable | image | always). Picking one dispatches + // "context-menu" with the item id + the surrounding + // context (selection text, link URL, media info, host) + // to the add-on's onMessage handler. Add-ons that also + // declare "sidebar-panel" typically follow up with + // api.revealSidebar(panelId) to surface the result. + "context-menu-item", ]); // Chrome-style match pattern → predicate. ":///" where @@ -136,6 +146,34 @@ function validateManifest(raw, folderName) { items: cleanItems, }; } + const contextMenuItems = []; + if (capabilities.includes("context-menu-item")) { + const rawItems = m["context-menu-items"]; + if (!Array.isArray(rawItems) || !rawItems.length) { + throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`); + } + const seen = new Set(); + const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]); + for (let idx = 0; idx < rawItems.length; idx++) { + const it = rawItems[idx]; + const iid = String(it && it.id || "").trim(); + if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { + throw new Error(`addon "${id}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`); + } + if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`); + seen.add(iid); + const when = String(it.when || "always"); + if (!ALLOWED_WHEN.has(when)) { + throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`); + } + contextMenuItems.push({ + id: iid, + label: String(it.label || iid), + when, + icon: it.icon == null ? "" : String(it.icon), + }); + } + } // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so // funds derived under the old id's paths stay reachable through the new @@ -154,13 +192,13 @@ function validateManifest(raw, folderName) { // back to plain-extension rendering. const category = m.category && ["plugin"].includes(String(m.category)) ? String(m.category) : null; - return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs, category }; + return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category }; } // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // of the app queries via `getActive()` / `getInstalled()`. class AddonHost { - constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, checkAndStageUpdates, restartApp }) { + constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, checkAndStageUpdates, restartApp, revealSidebar }) { this.addonsDir = addonsDir; this.dataDir = dataDir; this.isDisabled = isDisabled || (() => false); @@ -203,6 +241,10 @@ class AddonHost { // add-on ever gets to hand-write into its own installed folder. this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null; this._restartApp = typeof restartApp === "function" ? restartApp : null; + // revealSidebar: opens the sidebar and switches to the given panel id. + // Signature: (addonId, panelId) => void. Add-ons use this from their + // context-menu handler to surface a result in their sidebar UI. + this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : null; // Session-proxy hook — injected by main so add-ons can swap the default // session's proxy rules (e.g. a "route everything through my VPS" add-on). // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise @@ -387,6 +429,19 @@ class AddonHost { active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id }); this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); }, + // Programmatically open the sidebar and switch to one of THIS add-on's + // panels. `panelId` is the un-namespaced id passed to registerSidebarPanel + // (e.g. "main"); host prefixes with the add-on id under the hood. Used + // by context-menu handlers to surface a result in the sidebar. No-op if + // the add-on doesn't own that panel or the host isn't wired. + revealSidebar: (panelId) => { + if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; } + const pid = String(panelId || "").trim(); + const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`; + const owned = active.sidebarPanels.some((p) => p.panelId === full); + if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; } + this._revealSidebar(manifest.id, full); + }, // Swap the default session's proxy. Rules follow Chromium's proxy // format ("socks5://1.2.3.4:1080" for a single SOCKS server, // "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.). @@ -650,6 +705,32 @@ class AddonHost { } return out; } + // Right-click menu items declared by add-ons, filtered by the current + // context. `ctx` is what Electron's context-menu event carries: + // { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL } + // Returns [{ addonId, id, label, icon, when }]. `when` filters: + // selectionText — non-empty text is selected + // linkURL — right-clicked on a link + // editable — right-clicked inside a form control / contenteditable + // image — mediaType === "image" and srcURL is present + // always — every menu + getContextMenuItems(ctx = {}) { + const has = { + selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()), + linkURL: !!ctx.linkURL, + editable: !!ctx.isEditable, + image: ctx.mediaType === "image" && !!ctx.srcURL, + }; + const out = []; + for (const active of this._active.values()) { + const items = active.manifest.contextMenuItems || []; + for (const it of items) { + if (it.when !== "always" && !has[it.when]) continue; + out.push({ addonId: active.manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when }); + } + } + return out; + } getInstalled() { return this._installed.slice(); } isActive(id) { return this._active.has(id); } // Absolute folder of an active add-on, or null. Public so main can resolve diff --git a/main.js b/main.js index af310bc..624ee8a 100644 --- a/main.js +++ b/main.js @@ -2208,6 +2208,14 @@ function initAddons() { console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`); return { savePath: target }; }, + // revealSidebar hook — used by add-ons declaring "context-menu-item" so + // a right-click handler can pull the sidebar open to its own panel. + // AddonHost has already gated by ownership before calling us; here we + // just route through the existing setSidebar path. + revealSidebar: (addonId, panelId) => { + try { setSidebar(true, panelId); } + catch (e) { console.warn(`[addons] [${addonId}] revealSidebar failed:`, e?.message); } + }, }); addonHost.discoverAndActivate(); const snap = addonHost.snapshot(); @@ -3027,6 +3035,36 @@ function createTab(initial, opts = {}) { ); } } + // Add-on context-menu items. Add-ons that declare "context-menu-item" + // filter by `when` (selectionText / linkURL / editable / image / + // always) matched against Electron's params. Click dispatches the + // "context-menu" message to the add-on with the full context. + try { + const addonItems = addonHost ? addonHost.getContextMenuItems({ + selectionText: p.selectionText, linkURL: p.linkURL, + mediaType: p.mediaType, srcURL: p.srcURL, isEditable: p.isEditable, + pageURL: p.pageURL, + }) : []; + if (addonItems.length) { + for (const it of addonItems) { + const label = (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id); + items.push({ label, click: () => { + const payload = { + itemId: it.id, + selectionText: p.selectionText || "", + linkURL: p.linkURL || "", + mediaType: p.mediaType || "", + srcURL: p.srcURL || "", + pageURL: p.pageURL || (wc && wc.getURL()) || "", + host: (() => { try { return new URL(p.pageURL || wc.getURL()).host; } catch { return ""; } })(), + }; + addonHost.dispatch(it.addonId, "context-menu", payload, { from: "context-menu" }) + .catch((err) => console.warn(`[addons] context-menu ${it.addonId}.${it.id} failed:`, err?.message || err)); + }}); + } + items.push({ type: "separator" }); + } + } catch (err) { console.warn("context-menu addon merge failed:", err?.message || err); } items.push( { label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() }, { label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() }, @@ -5291,6 +5329,33 @@ async function openLinkWindow(input) { } if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" }); else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" }); + // Add-on context-menu items (same shape as the main-window handler). + try { + const addonItems = addonHost ? addonHost.getContextMenuItems({ + selectionText: p.selectionText, linkURL: p.linkURL, + mediaType: p.mediaType, srcURL: p.srcURL, isEditable: p.isEditable, + pageURL: p.pageURL, + }) : []; + if (addonItems.length) { + for (const it of addonItems) { + const label = (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id); + items.push({ label, click: () => { + const payload = { + itemId: it.id, + selectionText: p.selectionText || "", + linkURL: p.linkURL || "", + mediaType: p.mediaType || "", + srcURL: p.srcURL || "", + pageURL: p.pageURL || (wc && wc.getURL()) || "", + host: (() => { try { return new URL(p.pageURL || wc.getURL()).host; } catch { return ""; } })(), + }; + addonHost.dispatch(it.addonId, "context-menu", payload, { from: "context-menu" }) + .catch((err) => console.warn(`[addons] context-menu ${it.addonId}.${it.id} failed:`, err?.message || err)); + }}); + } + items.push({ type: "separator" }); + } + } catch (err) { console.warn("context-menu addon merge failed:", err?.message || err); } items.push( { label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() }, { label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },