Theseus: a wallet approval belongs to the tab that asked for it

Approvals were one global queue drawn over whatever tab was in front,
so a background tab could time a request to pop up while the user was
in the middle of something on a trusted dapp, and a request outlived
the page that made it. The host now carries the tab of a page message
through the add-on's handler (AsyncLocalStorage across its awaits); an
approval from a page shows only while its tab is in front, hides and
comes back re-armed when the user switches tabs, and is cancelled when
the tab closes or navigates. Approvals from a panel or from
WizardConnect have no tab and behave as before.
This commit is contained in:
Local Dev 2026-10-04 04:28:18 +02:00
parent 455e468186
commit f834fbf80a
2 changed files with 53 additions and 5 deletions

View file

@ -21,6 +21,10 @@
const fs = require("node:fs"); const fs = require("node:fs");
const { storeFor } = require("./lib/addon-store.cjs"); 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"); const path = require("node:path");
// How long a call waits for an async activate() to settle before it is // 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`); 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)`); 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. // capture-tab: snapshot the currently-active tab.
// opts.mode "visible" | "full" | "region" (required) // opts.mode "visible" | "full" | "region" (required)
@ -1005,6 +1010,7 @@ class AddonHost {
} }
const handler = active.handlers.get(String(msg)); const handler = active.handlers.get(String(msg));
if (!handler) throw new Error(`add-on "${id}" has no handler for "${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 || {}); return handler(payload, ctx || {});
} }
hasHandler(id, msg) { hasHandler(id, msg) {

50
main.js
View file

@ -2634,7 +2634,7 @@ function initAddons() {
return v.getImportSigner(importsState, id); 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 }), vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }),
// The one PIN, for built-in add-ons that draw their own PIN pad (Aegis). // 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 // 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); if (t?.prov) pushNav(t.prov);
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null); chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
syncJsDialogVisibility(); syncJsDialogVisibility();
syncApprovalVisibility();
notifyTabChange(); notifyTabChange();
emitTabs(); emitTabs();
if (t) emitTranslateState(t); 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. // A new document means a new (or no) web-app manifest; the chip follows.
wc.on("did-navigate", () => { tab.webapp = null; }); 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 // 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 // from then on; otherwise openSettingsTab keeps focusing a tab that no
// longer shows Settings. // longer shows Settings.
@ -4365,6 +4368,7 @@ function closeTab(id) {
const [t] = tabs.splice(i, 1); const [t] = tabs.splice(i, 1);
if (fsTabId === id) leaveHtmlFullscreen(t, true); if (fsTabId === id) leaveHtmlFullscreen(t, true);
dismissJsDialogFor(id); dismissJsDialogFor(id);
cancelApprovalsFor(id);
win.contentView.removeChildView(t.view); win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.(); t.view.webContents.destroy?.();
if (tabs.length === 0) { createTab(); return; } if (tabs.length === 0) { createTab(); return; }
@ -6125,9 +6129,19 @@ let unlockPop = null; // vault unlock prompt (unlock.html), see requestVault
const approvalQueue = []; const approvalQueue = [];
let approvalCurrent = null; // { reqId, resolve } let approvalCurrent = null; // { reqId, resolve }
let approvalSeq = 0; 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() { function pumpApproval() {
if (approvalCurrent || !approvalQueue.length || !approvalPop) return; 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; approvalCurrent = next;
// The overlay's page loads lazily after first paint; a dapp on a restored // 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 // 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 a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
const req = { const req = {
reqId: ++approvalSeq, reqId: ++approvalSeq,
@ -6166,10 +6180,38 @@ function showApprovalModal(opts, addonId) {
} : null, } : null,
}; };
return new Promise((resolve) => { return new Promise((resolve) => {
approvalQueue.push({ req, resolve }); approvalQueue.push({ req, resolve, tabId: tabId == null ? null : tabId });
pumpApproval(); 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) => { ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => {
if (!approvalPop || e.sender !== approvalPop.webContents) return false; if (!approvalPop || e.sender !== approvalPop.webContents) return false;
if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false; if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;