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:
parent
455e468186
commit
f834fbf80a
2 changed files with 53 additions and 5 deletions
|
|
@ -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
50
main.js
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue