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:
parent
501c7cf273
commit
decf118c88
2 changed files with 148 additions and 2 deletions
|
|
@ -49,6 +49,16 @@ const KNOWN_CAPABILITIES = new Set([
|
||||||
// via window.silentmode.invoke().
|
// via window.silentmode.invoke().
|
||||||
"vault-derive", "page-inject", "approval-modal", "capture-tab",
|
"vault-derive", "page-inject", "approval-modal", "capture-tab",
|
||||||
"toolbar-menu", "open-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
|
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
|
||||||
|
|
@ -136,6 +146,34 @@ function validateManifest(raw, folderName) {
|
||||||
items: cleanItems,
|
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
|
// `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
|
// 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
|
// 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.
|
// back to plain-extension rendering.
|
||||||
const category = m.category && ["plugin"].includes(String(m.category))
|
const category = m.category && ["plugin"].includes(String(m.category))
|
||||||
? String(m.category) : null;
|
? 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
|
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
||||||
// of the app queries via `getActive()` / `getInstalled()`.
|
// of the app queries via `getActive()` / `getInstalled()`.
|
||||||
class AddonHost {
|
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.addonsDir = addonsDir;
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.isDisabled = isDisabled || (() => false);
|
this.isDisabled = isDisabled || (() => false);
|
||||||
|
|
@ -203,6 +241,10 @@ class AddonHost {
|
||||||
// add-on ever gets to hand-write into its own installed folder.
|
// add-on ever gets to hand-write into its own installed folder.
|
||||||
this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null;
|
this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null;
|
||||||
this._restartApp = typeof restartApp === "function" ? restartApp : 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-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).
|
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
||||||
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
||||||
|
|
@ -387,6 +429,19 @@ class AddonHost {
|
||||||
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
|
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
|
||||||
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
|
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
|
// Swap the default session's proxy. Rules follow Chromium's proxy
|
||||||
// format ("socks5://1.2.3.4:1080" for a single SOCKS server,
|
// 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.).
|
// "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;
|
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(); }
|
getInstalled() { return this._installed.slice(); }
|
||||||
isActive(id) { return this._active.has(id); }
|
isActive(id) { return this._active.has(id); }
|
||||||
// Absolute folder of an active add-on, or null. Public so main can resolve
|
// Absolute folder of an active add-on, or null. Public so main can resolve
|
||||||
|
|
|
||||||
65
main.js
65
main.js
|
|
@ -2208,6 +2208,14 @@ function initAddons() {
|
||||||
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
|
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
|
||||||
return { savePath: 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();
|
addonHost.discoverAndActivate();
|
||||||
const snap = addonHost.snapshot();
|
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(
|
items.push(
|
||||||
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
||||||
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
|
{ 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" });
|
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
|
||||||
else if (p.selectionText) items.push({ role: "copy" }, { 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(
|
items.push(
|
||||||
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
||||||
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
|
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue