Theseus: only theseus.x can ask to install an extension, and the sheet is armed

bcnr.installExtension is in every page's main world, and install links
were honoured from any page and any frame, with no user gesture. Any
site could raise the install sheet timed so that a double-click landed
on Install, whose two buttons are always in the same place; an
installed community extension runs in the main process at once. The
call and the links now work only from theseus.x's top frame, the call
needs a real click (checked in the isolated world), and Theseus's own
sheets ignore every choice except Cancel for 800 ms, like the approval
overlay.
This commit is contained in:
Local Dev 2026-10-04 04:21:05 +02:00
parent 70b35e2520
commit db1acc1417
3 changed files with 44 additions and 7 deletions

View file

@ -35,7 +35,11 @@ contextBridge.exposeInMainWorld("bcnr", {
// 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 || "")),
// Needs a real click: the isolated world reads the page's user activation,
// which page script cannot fake.
installExtension: (id) => (navigator.userActivation && !navigator.userActivation.isActive
? Promise.resolve({ ok: false, error: "installing needs a click on the page" })
: ipcRenderer.invoke("bcnr:installExtension", String(id || ""))),
});
// Install-as-app relay. Theseus fires a synthetic `beforeinstallprompt` in

View file

@ -118,7 +118,20 @@
${order.map((i) => `<button class="pbtn ${i === req.defaultId ? "primary" : "quiet"}" type="button" data-i="${i}">${esc(req.buttons[i])}</button>`).join("")}
</div>
</div></div>`;
document.querySelectorAll("button[data-i]").forEach((b) => b.addEventListener("click", () => finishApp(Number(b.dataset.i))));
// Armed like the approval overlay: for the first moments only the
// cancel choice works, so a click aimed at whatever was under the
// pointer when the sheet appeared (a page timing a double-click) cannot
// land on Install or another affirmative button.
const ARM_MS = 800;
const armAt = Date.now() + ARM_MS;
req.armAt = armAt;
const acts = Array.from(document.querySelectorAll("button[data-i]")).filter((b) => Number(b.dataset.i) !== req.cancelId);
acts.forEach((b) => { b.disabled = true; b.style.opacity = "0.45"; });
setTimeout(() => { if (current === req) acts.forEach((b) => { b.disabled = false; b.style.opacity = ""; }); }, ARM_MS);
document.querySelectorAll("button[data-i]").forEach((b) => b.addEventListener("click", () => {
if (Number(b.dataset.i) !== req.cancelId && Date.now() < armAt) return;
finishApp(Number(b.dataset.i));
}));
document.getElementById("close").addEventListener("click", () => finishApp(req.cancelId));
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finishApp(req.cancelId); });
@ -159,7 +172,11 @@
if (!current) return;
if (current.kind === "app") {
if (e.key === "Escape") { e.preventDefault(); finishApp(current.cancelId); }
else if (e.key === "Enter" && !(e.target && e.target.matches("button.quiet, #close"))) { e.preventDefault(); finishApp(current.defaultId); }
else if (e.key === "Enter" && !(e.target && e.target.matches("button.quiet, #close"))) {
e.preventDefault();
if (current.defaultId !== current.cancelId && Date.now() < (current.armAt || 0)) return;
finishApp(current.defaultId);
}
return;
}
if (e.key === "Escape") { e.preventDefault(); finish(current.kind === "alert"); }

24
main.js
View file

@ -4009,7 +4009,12 @@ function createTab(initial, opts = {}) {
const installId = installLinkId(u);
if (installId) {
e.preventDefault();
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
let requester = null; try { requester = new URL(String(wc.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
const init = e.initiator;
if (!installRequesterOk(requester) || (init && init.frameTreeNodeId !== wc.mainFrame.frameTreeNodeId)) {
console.log("[addons] install link ignored: not from theseus.x's top frame");
return;
}
installExtensionWithConsent(installId, requester);
return;
}
@ -4123,8 +4128,11 @@ function createTab(initial, opts = {}) {
}
const installId = installLinkId(url);
if (installId) {
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
installExtensionWithConsent(installId, requester);
let requester = null; try { requester = new URL(String(wc.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
let refHost = null; try { refHost = details.referrer && details.referrer.url ? new URL(String(details.referrer.url).replace(/^bns:\/\//i, "https://")).host : null; } catch {}
// Same rule as the bcnr call: the catalog site's own top frame only.
if (installRequesterOk(requester) && refHost === requester) installExtensionWithConsent(installId, requester);
else console.log("[addons] install window ignored: not from theseus.x's top frame");
} else if (url && url !== "about:blank") {
// Chromium won't let a web page navigate to file://, chrome:// or
// theseus://, but createTab → loadURL runs from main and would. Web
@ -5647,9 +5655,17 @@ ipcMain.handle("webapp-prompt", async (e) => {
ipcMain.handle("webapps-list", () => webapps.list().map((a) => ({ key: a.key, name: a.name, host: a.host, startUrl: a.startUrl, installedAt: a.installedAt })));
ipcMain.handle("webapps-open", (_e, key) => { const a = webapps.find(String(key || "")); if (a) webapps.open(a); return !!a; });
ipcMain.handle("webapps-remove", (_e, key) => webapps.uninstall(String(key || ""), true).then((ok) => { emitTabs(); return ok; }));
// Only the catalog site may ask, and only from its top frame. Any page used
// to be able to raise the install sheet without a click (bcnr is in every
// page's main world), timed so a double-click landed on Install — and an
// installed extension runs in the main process at once.
const INSTALL_REQUESTERS = new Set(["theseus.x"]);
function installRequesterOk(host) { return !!host && INSTALL_REQUESTERS.has(String(host).toLowerCase()); }
ipcMain.handle("bcnr:installExtension", (e, id) => {
if (!e.senderFrame || e.senderFrame !== e.sender.mainFrame) return { ok: false, error: "only the top frame may install" };
let requester = null;
try { requester = new URL(e.sender.getURL()).host || null; } catch {}
try { requester = new URL(String(e.sender.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
if (!installRequesterOk(requester)) return { ok: false, error: "extensions can only be installed from theseus.x" };
return installExtensionWithConsent(id, requester);
});
// Background / manual add-on update check. Whatever gets staged is pushed to