feat(theseus): one-click "Install in Theseus" from theseus.x/extensions

The extensions page could only hand out tarballs; installing meant going
to Settings › Extensions › Community and finding the entry again. Pages
now get window.bcnr.installExtension(id) and Theseus intercepts
theseus://extensions/install/<id> links (page clicks, target=_blank and
the address bar). The page only names a catalog id: Theseus fetches the
catalog and package itself, asks in a native dialog the page cannot draw
over, verifies the publisher signature against the name's current owner
and activates the add-on — the same path a Settings install takes. One
prompt at a time; an already-installed version says so instead of
offering a no-op update.

The site shows the button inside Theseus (feature-detected on the
bridge), a "update Theseus" hint on older builds and a download hint in
other browsers.
This commit is contained in:
Local Dev 2026-09-21 02:40:05 +02:00
parent bd6aab02fe
commit b791871390
2 changed files with 96 additions and 3 deletions

View file

@ -29,4 +29,11 @@ contextBridge.exposeInMainWorld("bcnr", {
// dApp devs use this to see how their subdomains bucket under one grant. // dApp devs use this to see how their subdomains bucket under one grant.
// Returns null for opaque origins (data:, blob:) which never hold grants. // Returns null for opaque origins (data:, blob:) which never hold grants.
getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"), getOrigin: () => ipcRenderer.invoke("bcnr:getOrigin"),
// One-click install of a community extension by catalog id (what
// theseus.x/extensions' Install button calls). The page names an id only;
// Theseus fetches the catalog itself, asks the user in a native dialog,
// verifies the publisher signature against the name's owner and installs.
// Resolves `{ ok, version, publisher }` or `{ ok:false, error }` (also
// "cancelled"). Pages can feature-detect it: absent on older builds.
installExtension: (id) => ipcRenderer.invoke("bcnr:installExtension", String(id || "")),
}); });

92
main.js
View file

@ -2965,6 +2965,13 @@ function createTab(initial, opts = {}) {
// isBnsHost() would send us right back into navigateTab, canceling the // isBnsHost() would send us right back into navigateTab, canceling the
// fallback (blank-page bug 2026-08-02). // fallback (blank-page bug 2026-08-02).
if (tab.internalNav) return; if (tab.internalNav) return;
const installId = installLinkId(u);
if (installId) {
e.preventDefault();
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
installExtensionWithConsent(installId, requester);
return;
}
const parsed = new URL(u); const parsed = new URL(u);
// Intercept the collision-choose posted by the in-tab "Open with…" page, // Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns // apply the remember flag, set a one-shot transient override so loadBns
@ -3025,7 +3032,11 @@ function createTab(initial, opts = {}) {
}); });
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click. // Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => { wc.setWindowOpenHandler(({ url, disposition }) => {
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab", after: tab.id }); const installId = installLinkId(url);
if (installId) {
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
installExtensionWithConsent(installId, requester);
} else if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab", after: tab.id });
return { action: "deny" }; return { action: "deny" };
}); });
// Right-click context menu. // Right-click context menu.
@ -3250,6 +3261,9 @@ async function navigateTab(id, input) {
const t = tabById(id); if (!t) return; const t = tabById(id); if (!t) return;
let q = String(input).trim(); let q = String(input).trim();
if (!q) return; if (!q) return;
// theseus://extensions/install/<id> typed or pasted into the address bar.
const installId = installLinkId(q);
if (installId) { installExtensionWithConsent(installId, null); return; }
// Local paths open as files — never BCNR, never a search. // Local paths open as files — never BCNR, never a search.
const fileUrl = localFileUrl(q); const fileUrl = localFileUrl(q);
if (fileUrl) return loadLocalFile(t, id, fileUrl); if (fileUrl) return loadLocalFile(t, id, fileUrl);
@ -3759,8 +3773,12 @@ ipcMain.handle("addons-community-catalog", async () => {
}) }; }) };
} catch (e) { return { ok: false, error: e?.message || String(e), extensions: [] }; } } catch (e) { return { ok: false, error: e?.message || String(e), extensions: [] }; }
}); });
ipcMain.handle("addons-install-community", async (_e, id) => { const COMMUNITY_ID_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/;
if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,63}$/.test(id)) return { ok: false, error: "bad id" }; // Install a catalog extension by id: resolve its channel from the catalog,
// verify the publisher signature against the name's owner, place it under
// extensions/<id> and activate it. Shared by Settings and the page bridge.
async function installCommunityById(id) {
if (typeof id !== "string" || !COMMUNITY_ID_RE.test(id)) return { ok: false, error: "bad id" };
try { try {
const card = (await fetchCommunityCatalog()).find((e) => e.id === id); const card = (await fetchCommunityCatalog()).find((e) => e.id === id);
if (!card) return { ok: false, error: "not in the catalog" }; if (!card) return { ok: false, error: "not in the catalog" };
@ -3773,6 +3791,74 @@ ipcMain.handle("addons-install-community", async (_e, id) => {
if (r.ok && addonHost) addonHost.discoverAndActivate(); if (r.ok && addonHost) addonHost.discoverAndActivate();
return r; return r;
} catch (e) { return { ok: false, error: e?.message || String(e) }; } } catch (e) { return { ok: false, error: e?.message || String(e) }; }
}
ipcMain.handle("addons-install-community", (_e, id) => installCommunityById(id));
// One-click install from a web page (theseus.x/extensions, or any page):
// either `window.bcnr.installExtension(id)` or a link to
// theseus://extensions/install/<id>. The page only names a catalog id — the
// package, its hash and the publisher signature still come from the catalog
// and are verified exactly as a Settings install is. The consent lives in a
// native dialog the page cannot draw over or click, one at a time.
const INSTALL_LINK_RE = /^theseus:\/\/extensions\/install\/([a-z0-9][a-z0-9._-]{1,63})\/?$/i;
function installLinkId(url) {
const m = INSTALL_LINK_RE.exec(String(url || "").trim());
return m ? m[1].toLowerCase() : null;
}
let installPromptOpen = false;
async function installExtensionWithConsent(id, requester) {
if (typeof id !== "string" || !COMMUNITY_ID_RE.test(id)) return { ok: false, error: "bad id" };
if (installPromptOpen) return { ok: false, error: "another install prompt is open" };
installPromptOpen = true;
try {
let card = null;
try { card = (await fetchCommunityCatalog()).find((e) => e.id === id) || null; } catch {}
const parent = win && !win.isDestroyed() ? win : undefined;
if (!card) {
await dialog.showMessageBox(parent, { type: "warning", title: "Extension not found", message: `"${id}" is not in the community catalog.`, buttons: ["OK"] });
return { ok: false, error: "not in the catalog" };
}
const installed = addonHost ? addonHost.snapshot().installed.find((a) => a.id === id) : null;
if (installed && addonUpdater.cmpVer(card.latest, installed.version) <= 0) {
const { response: r0 } = await dialog.showMessageBox(parent, {
type: "info", title: "Already installed",
message: `${card.name || id} ${installed.version} is already installed.`,
detail: "Newer signed versions arrive through the regular update check. Manage it under Settings Extensions.",
buttons: ["OK", "Open Settings"], defaultId: 0, cancelId: 0, noLink: true,
});
if (r0 === 1) openSettingsTab("addons");
return { ok: true, version: installed.version, publisher: installed.publisher || card.publisher, alreadyInstalled: true };
}
const from = requester ? `Requested by ${requester}.\n\n` : "";
const { response } = await dialog.showMessageBox(parent, {
type: "question", title: "Install extension",
message: installed
? `Update ${card.name || id} ${installed.version}${card.latest}?`
: `Install ${card.name || id} ${card.latest}?`,
detail: `${from}Published by ${card.publisher}. Theseus verifies the package signature against ${card.publisher}'s current owner in its own chain index before anything is written.\n\n`
+ `A community extension runs with the same access as any add-on — treat it like a program from that publisher.`,
buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
});
if (response !== 0) return { ok: false, error: "cancelled" };
const r = await installCommunityById(id);
if (r.ok) {
const { response: after } = await dialog.showMessageBox(parent, {
type: "info", title: "Extension installed",
message: `${card.name || id} ${r.version} is installed and active.`,
detail: `Signed by ${r.publisher || card.publisher}. Manage it under Settings Extensions.`,
buttons: ["OK", "Open Settings"], defaultId: 0, cancelId: 0, noLink: true,
});
if (after === 1) openSettingsTab("addons");
} else {
await dialog.showMessageBox(parent, { type: "error", title: "Install failed", message: `${card.name || id} was not installed.`, detail: r.error || "unknown error", buttons: ["OK"] });
}
return r;
} finally { installPromptOpen = false; }
}
ipcMain.handle("bcnr:installExtension", (e, id) => {
let requester = null;
try { requester = new URL(e.sender.getURL()).host || null; } catch {}
return installExtensionWithConsent(id, requester);
}); });
ipcMain.handle("addons-check-updates", async () => { ipcMain.handle("addons-check-updates", async () => {
const stagedDir = addonsStagedDir(); const stagedDir = addonsStagedDir();