diff --git a/addons-host.js b/addons-host.js index 7d7f8788..6dec2b70 100644 --- a/addons-host.js +++ b/addons-host.js @@ -21,6 +21,10 @@ const fs = require("node:fs"); const { storeFor } = require("./lib/addon-store.cjs"); +// Which tab a page message came from, carried through the handler's awaits, +// so an approval it raises is tied to that tab (see approvalModal). +const { AsyncLocalStorage } = require("node:async_hooks"); +const pageCallCtx = new AsyncLocalStorage(); const path = require("node:path"); // How long a call waits for an async activate() to settle before it is @@ -926,7 +930,8 @@ class AddonHost { throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); } if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); - return this._approvalModal(opts || {}, manifest.id); + const call = pageCallCtx.getStore(); + return this._approvalModal(opts || {}, manifest.id, call ? call.tabId : null); }, // capture-tab: snapshot the currently-active tab. // opts.mode "visible" | "full" | "region" (required) @@ -1005,6 +1010,7 @@ class AddonHost { } const handler = active.handlers.get(String(msg)); if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); + if (ctx && ctx.from === "page" && ctx.tabId != null) return pageCallCtx.run({ tabId: ctx.tabId }, () => handler(payload, ctx)); return handler(payload, ctx || {}); } hasHandler(id, msg) { diff --git a/main.js b/main.js index e74e5757..53af0e73 100644 --- a/main.js +++ b/main.js @@ -2634,7 +2634,7 @@ function initAddons() { return v.getImportSigner(importsState, id); }, }, - approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), + approvalModal: (opts, addonId, tabId) => showApprovalModal(opts, addonId, tabId), vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }), // The one PIN, for built-in add-ons that draw their own PIN pad (Aegis). // unlock() opens the vault here and answers only { ok } or why not — the @@ -3724,6 +3724,7 @@ function setActive(id) { if (t?.prov) pushNav(t.prov); chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null); syncJsDialogVisibility(); + syncApprovalVisibility(); notifyTabChange(); emitTabs(); if (t) emitTranslateState(t); @@ -3993,6 +3994,8 @@ function createTab(initial, opts = {}) { }); // A new document means a new (or no) web-app manifest; the chip follows. wc.on("did-navigate", () => { tab.webapp = null; }); + // A page that navigated away no longer stands behind what it asked for. + wc.on("did-navigate", () => cancelApprovalsFor(tab.id)); // A Settings (or add-on) tab the user navigates elsewhere is an ordinary tab // from then on; otherwise openSettingsTab keeps focusing a tab that no // longer shows Settings. @@ -4365,6 +4368,7 @@ function closeTab(id) { const [t] = tabs.splice(i, 1); if (fsTabId === id) leaveHtmlFullscreen(t, true); dismissJsDialogFor(id); + cancelApprovalsFor(id); win.contentView.removeChildView(t.view); t.view.webContents.destroy?.(); if (tabs.length === 0) { createTab(); return; } @@ -6125,9 +6129,19 @@ let unlockPop = null; // vault unlock prompt (unlock.html), see requestVault const approvalQueue = []; let approvalCurrent = null; // { reqId, resolve } let approvalSeq = 0; +// An approval a page asked for belongs to that page's tab, like a page +// dialog: it shows only while that tab is in front and waits otherwise, and +// it is cancelled when the tab closes or navigates. A background tab used to +// be able to time its request to pop over whatever the user was doing. function pumpApproval() { if (approvalCurrent || !approvalQueue.length || !approvalPop) return; - const next = approvalQueue.shift(); + for (let i = approvalQueue.length - 1; i >= 0; i--) { + const q = approvalQueue[i]; + if (q.tabId != null && !tabById(q.tabId)) { approvalQueue.splice(i, 1); try { q.resolve("cancel"); } catch {} } + } + const at = approvalQueue.findIndex((q) => q.tabId == null || q.tabId === activeId); + if (at < 0) return; + const next = approvalQueue.splice(at, 1)[0]; approvalCurrent = next; // The overlay's page loads lazily after first paint; a dapp on a restored // tab can ask for approval before that, so wait for the page rather than @@ -6147,7 +6161,7 @@ function pumpApproval() { } }); } -function showApprovalModal(opts, addonId) { +function showApprovalModal(opts, addonId, tabId = null) { const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); const req = { reqId: ++approvalSeq, @@ -6166,10 +6180,38 @@ function showApprovalModal(opts, addonId) { } : null, }; return new Promise((resolve) => { - approvalQueue.push({ req, resolve }); + approvalQueue.push({ req, resolve, tabId: tabId == null ? null : tabId }); pumpApproval(); }); } +function cancelApprovalsFor(tabId) { + for (let i = approvalQueue.length - 1; i >= 0; i--) { + if (approvalQueue[i].tabId === tabId) { const q = approvalQueue.splice(i, 1)[0]; try { q.resolve("cancel"); } catch {} } + } + if (approvalCurrent && approvalCurrent.tabId === tabId) { + const c = approvalCurrent; approvalCurrent = null; + try { approvalPop.setVisible(false); } catch {} + try { c.resolve("cancel"); } catch {} + pumpApproval(); + pumpJsDialog(); + } +} +// Tab switch: the current approval hides with its tab and comes back (shown +// afresh, so re-armed) when the tab does; another tab's waiting approval may +// now show. +function syncApprovalVisibility() { + if (!approvalPop) return; + const cur = approvalCurrent; + if (!cur) { pumpApproval(); return; } + const show = cur.tabId == null || cur.tabId === activeId; + if (!show) { + try { approvalPop.setVisible(false); } catch {} + approvalCurrent = null; + approvalQueue.unshift(cur); + pumpApproval(); + return; + } +} ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => { if (!approvalPop || e.sender !== approvalPop.webContents) return false; if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;