Files
AutistMask/src/popup/index.js
T
clawbot 8998d6c661
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: a refresh asked for while another runs is no longer dropped (closes #581)
doRefreshAndRender() returned at once when a refresh was already running. An import asks for a refresh after its address scan, and that request was dropped when it came during a refresh, so the addresses the scan found showed 0.0000 until the next ten-second refresh. A request made through ctx while a refresh runs now leads to one more refresh once the running one ends, however many such requests came in. A tick of the ten-second timer during a refresh is still dropped, so the wallet asks the node no more often than before.

Model: opus-5-5
2026-10-27 20:14:09 +01:00

463 lines
17 KiB
JavaScript

// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances, dropUntrackedTokens } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
updateDebugBanner,
setBackRenderer,
showSaveFailureBanner,
pushCurrentView,
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
windowsUpdate,
} = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
// through it; anything else falls back to the nearest restorable parent.
const { renderView, makeBackRenderer } = require("./viewRouter");
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const addressDetail = require("./views/addressDetail");
const addressToken = require("./views/addressToken");
const send = require("./views/send");
const confirmTx = require("./views/confirmTx");
const txStatus = require("./views/txStatus");
const transactionDetail = require("./views/transactionDetail");
const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const deleteAddress = require("./views/deleteAddress");
const approval = require("./views/approval");
const stateRecovery = require("./views/stateRecovery");
function renderWalletList() {
home.render(ctx);
}
// Aborted when the popup page goes away, closed or reloaded. Chrome then
// cancels the requests the page still has open, and a cancelled fetch() fails
// with the same "Failed to fetch" as a server that cannot be reached. pagehide
// fires first, so code that reports a failed request checks this and stays
// silent about one the page's own closing cancelled.
const pageClosed = new AbortController();
window.addEventListener("pagehide", () => pageClosed.abort());
// While a refresh runs, a request through ctx is not dropped. The request sets
// refreshAgain, and one more refresh runs once the running one ends. Several
// requests in that time lead to one more refresh, not several. An import asks
// for a refresh after its address scan. A refresh already running does not
// have the addresses the scan found
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
let refreshInFlight = false;
let refreshAgain = false;
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh.
let refreshTimer = null;
// The refresh first takes the stored network, "Use Blockscout" and "Check
// recipients on Etherscan" (followStoredSettings() below). So the screens it
// draws reflect any change saved elsewhere.
//
// The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followStoredSettings()).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
async function doRefreshAndRender() {
if (refreshInFlight) {
refreshAgain = true;
return;
}
refreshInFlight = true;
try {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
refreshPrices(pageClosed.signal),
refreshBalances(
wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
networkId,
pageClosed.signal,
),
]);
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
refreshed.set(addr.address, addr);
}
}
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
const got = refreshed.get(addr.address);
if (!got) continue;
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
} catch (e) {
// No caller waits for this function, so it must never reject. An
// unhandled rejection reports nothing. The save inside it reports its
// own failure through onSaveFailure() (see src/shared/state.js); what
// is left here is a failed network round trip, which the next tick
// retries.
log.errorf("popup: background refresh failed:", e);
} finally {
refreshInFlight = false;
}
if (refreshAgain) {
refreshAgain = false;
doRefreshAndRender();
}
}
// Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
// refresh already started, it starts no second one. On the recovery screen it
// does nothing.
function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender();
if (refreshTimer === null) {
// A tick that finds a refresh running is dropped.
refreshTimer = setInterval(() => {
if (!refreshInFlight) return doRefreshAndRender();
}, 10000);
}
}
const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
addWallet.show();
},
showAddressDetail: () => {
pushCurrentView();
addressDetail.show();
},
showAddressToken: () => {
pushCurrentView();
addressToken.show();
},
showAddTokenView: () => {
pushCurrentView();
addToken.show();
},
showConfirmTx: (txInfo) => {
pushCurrentView();
confirmTx.show(txInfo);
},
showReceive: () => {
pushCurrentView();
receive.show();
},
showTransactionDetail: (tx) => {
pushCurrentView();
transactionDetail.show(tx);
},
showSettingsView: () => {
pushCurrentView();
settings.show();
},
showSettingsAddTokenView: () => {
pushCurrentView();
settingsAddToken.show();
},
showDeleteAddress: (walletIdx, addrIdx) => {
pushCurrentView();
deleteAddress.show(walletIdx, addrIdx);
},
};
// The view modules the router renders through, keyed as it expects them.
const viewModules = {
main: { show: () => fallbackView() },
addressDetail,
addressToken,
receive,
settings,
settingsAddToken,
confirmTx,
transactionDetail,
txStatus,
};
function restoreView() {
if (!renderView(state.currentView, state, viewModules)) {
fallbackView();
}
}
function fallbackView() {
renderWalletList();
showView("main");
}
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
//
// Another page may change "Use Blockscout" or "Check recipients on Etherscan".
// This page takes either change the same way and draws its screens again. This
// page's own changes to the two settings are made under the lock too
// (views/settings.js, views/welcome.js).
async function followStoredSettings() {
let changed = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId !== state.networkId) {
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
changed = true;
}
if (stored.useBlockscout !== state.useBlockscout) {
settings.setUseBlockscout(stored.useBlockscout);
changed = true;
}
if (
stored.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
state.checkRecipientsOnEtherscan =
stored.checkRecipientsOnEtherscan;
changed = true;
}
if (!changed) return false;
}).catch(() => {});
if (changed) settings.showNewSettings();
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
// up, the window is widened by however far the page would scroll sideways.
// That distance is in page pixels and the window's width in screen pixels:
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
// page's own tab, because asked without a tab id Firefox answers for the
// window focused last. Firefox draws some zoom steps a little larger than the
// zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. An approval can also show in the
// toolbar popup, whose window is the user's browser window; that one is left
// alone.
async function widenApprovalWindow() {
const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return;
const win = await windowsGetCurrent();
if (win.type !== "popup") return;
const tab = await tabsGetCurrent();
const zoom = await tabsGetZoom(tab.id);
let width = win.width;
for (let tries = 0; tries < 3; tries++) {
const sideways = page.scrollWidth - page.clientWidth;
if (sideways <= 0) return;
width += Math.ceil(sideways * zoom);
const resized = new Promise((resolve) =>
window.addEventListener("resize", resolve, { once: true }),
);
await windowsUpdate(win.id, { width });
await resized;
}
}
async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
// reported rather than reaching only the console
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
//
// Every save first reads the stored record and refuses it with the same
// check loadState() runs below. So a record that becomes unreadable while
// the popup is open is found by the next save, a navigation or the
// ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
// screen to showView() first leaves the current screen as any navigation
// does, so a phrase, key or password on it is wiped, and from then on
// showView() shows nothing else. A later save that fails the same way,
// such as a refresh already in flight, comes back here, where both calls
// see the screen already up and do nothing. Any other failed save is a
// read or write that failed, and gets the banner without changing the
// screen.
onSaveFailure((e) => {
if (e instanceof StateUnusableError) {
clearInterval(refreshTimer);
showView("state-recovery");
stateRecovery.show(e);
} else {
showSaveFailureBanner(e);
}
});
try {
await loadState();
} catch (e) {
// A profile this build cannot read is the one failure that must not
// fall through to the rest of init(). It used to: the load "succeeded"
// on a record nothing had validated, and the first dereference below
// threw, leaving a popup with no view, no message and no control on
// it, and no way out of the wallet from inside the product
// (https://git.eeqj.de/sneak/AutistMask/issues/311). Now the load
// refuses, and this is the screen that says so.
if (e instanceof StateUnusableError) {
stateRecovery.show(e);
return;
}
throw e;
}
applyTheme(state.theme);
// Sync runtime debug flag from persisted state before first render
setRuntimeDebug(state.debugMode);
// Create the debug/testnet banner if needed (uses runtime debug state)
updateDebugBanner();
// Auto-default active address
if (
state.activeAddress === null &&
state.wallets.length > 0 &&
state.wallets[0].addresses.length > 0
) {
state.activeAddress = state.wallets[0].addresses[0].address;
await saveState();
}
// Always init approval and txStatus — they may run in the approval popup window
approval.init(ctx);
txStatus.init(ctx);
// Check for approval mode
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path. show() puts the approval's own screen up once the
// background has described it; the window is widened to fit it then.
approval.show(approvalId).then(widenApprovalWindow);
return;
}
$("btn-settings").addEventListener("click", () => {
if (
!document
.getElementById("view-settings")
.classList.contains("hidden")
) {
goBack();
return;
}
pushCurrentView();
settings.show();
});
setBackRenderer(makeBackRenderer(state, viewModules));
welcome.init(ctx);
addWallet.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
addressToken.init(ctx);
send.init(ctx);
confirmTx.init(ctx);
transactionDetail.init(ctx);
receive.init(ctx);
addToken.init(ctx);
settings.init(ctx);
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
// This page follows a record only when its network, "Use Blockscout" or
// "Check recipients on Etherscan" differs from this page's. A record
// written by this page's own save matches this page. A record naming no
// network is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (
written.networkId !== state.networkId ||
written.useBlockscout !== state.useBlockscout ||
written.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
followStoredSettings();
}
});
// A change saved since loadState() above, before there was a listener.
followStoredSettings();
if (!state.hasWallet) {
welcome.show();
} else {
renderWalletList();
restoreView();
startRefresh();
}
}
document.addEventListener("DOMContentLoaded", init);