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:
parent
bd6aab02fe
commit
b791871390
2 changed files with 96 additions and 3 deletions
|
|
@ -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
92
main.js
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue