diff --git a/bcnr-preload.js b/bcnr-preload.js index 699b1257..4b433356 100644 --- a/bcnr-preload.js +++ b/bcnr-preload.js @@ -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 diff --git a/js-dialog.html b/js-dialog.html index f8cea12e..584eeb9a 100644 --- a/js-dialog.html +++ b/js-dialog.html @@ -118,7 +118,20 @@ ${order.map((i) => ``).join("")} `; - 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"); } diff --git a/main.js b/main.js index 837eac38..1b1b1b89 100644 --- a/main.js +++ b/main.js @@ -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