feat(theseus/addons): context-menu-item capability + api.revealSidebar

Adds a new "context-menu-item" capability. Add-ons declare a
"context-menu-items" array in their manifest:

  {
    "capabilities": ["context-menu-item", ...],
    "context-menu-items": [
      { "id": "translate-selection", "label": "Translate selection",
        "when": "selectionText", "icon": "🌐" }
    ]
  }

The `when` filter is one of selectionText | linkURL | editable | image
| always. Right-click on a page, and items whose `when` matches the
current context get merged into the native menu after the built-in
Search-for entry, before Back/Forward/Reload. Both context-menu
handlers (main tab area + detached link windows) share the same
merging logic.

Picking an item dispatches "context-menu" to the add-on's onMessage
handler with the full context (selectionText, linkURL, mediaType,
srcURL, pageURL, host). The add-on decides what to do — the
translate add-on stashes the selection to storage and calls
api.revealSidebar("main") which surfaces its own sidebar panel.

api.revealSidebar(panelId) is the paired hook. Ownership is enforced
by the host — an add-on can only reveal panels it registered —
before routing to main's setSidebar path.

Unknown capabilities were already silently dropped by
validateManifest, so older Theseus builds that don't understand
"context-menu-item" just ignore it, and the manifest still loads.
Add-ons that also declare "sidebar-panel" keep working; the new
capability doesn't require it.

This is the wiring that pairs with the translate/ add-on landed in
4498fbb — right-click "Translate selection" is live once this ships.
This commit is contained in:
Local Dev 2026-09-20 18:27:10 +02:00
parent 501c7cf273
commit decf118c88
2 changed files with 148 additions and 2 deletions

View file

@ -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. "<scheme>://<host>/<path>" 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<void>
@ -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

65
main.js
View file

@ -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() },