diff --git a/bcnr-preload.js b/bcnr-preload.js index 6fcceae..2861363 100644 --- a/bcnr-preload.js +++ b/bcnr-preload.js @@ -29,4 +29,11 @@ contextBridge.exposeInMainWorld("bcnr", { // dApp devs use this to see how their subdomains bucket under one grant. // Returns null for opaque origins (data:, blob:) which never hold grants. 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 || "")), }); diff --git a/main.js b/main.js index 7ec2d91..49508a4 100644 --- a/main.js +++ b/main.js @@ -2965,6 +2965,13 @@ function createTab(initial, opts = {}) { // isBnsHost() would send us right back into navigateTab, canceling the // fallback (blank-page bug 2026-08-02). 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); // Intercept the collision-choose posted by the in-tab "Open with…" page, // 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. 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" }; }); // Right-click context menu. @@ -3250,6 +3261,9 @@ async function navigateTab(id, input) { const t = tabById(id); if (!t) return; let q = String(input).trim(); if (!q) return; + // theseus://extensions/install/ 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. const fileUrl = localFileUrl(q); 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: [] }; } }); -ipcMain.handle("addons-install-community", async (_e, id) => { - if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,63}$/.test(id)) return { ok: false, error: "bad id" }; +const COMMUNITY_ID_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/; +// 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/ 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 { const card = (await fetchCommunityCatalog()).find((e) => e.id === id); 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(); return r; } 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/. 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 () => { const stagedDir = addonsStagedDir();