Files
AutistMask/src/popup/index.js
T
clawbot e517219038
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: a balance refresh that returns after a network switch writes nothing (closes #577)
The popup's doRefreshAndRender() and the background's backgroundRefresh()
each note the network a refresh starts on and write its balances only if
that network is still the active one when the requests return. Otherwise
nothing is written and lastBalanceRefresh stays as the switch left it. The
background checks inside updateState(). The popup refreshes a copy of its
wallets and copies the results onto `state` from inside its save, under the
lock, only if both its own network and the stored record's still match, so
a switch the background makes is caught too. saveState() takes an optional
function, run there on the stored record before the page's state is
compared, that may change `state` or return false to cancel the write.

Model: opus-5-5
2026-10-20 22:59:51 +02:00

371 lines
14 KiB
JavaScript

// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const {
state,
saveState,
onSaveFailure,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const {
$,
showView,
updateDebugBanner,
setBackRenderer,
showSaveFailureBanner,
pushCurrentView,
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
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());
let refreshInFlight = 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 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,
// changes only the stored record. 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.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
try {
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
refreshPrices(),
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];
}
}
}
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
// reject: an unhandled rejection is not a report of anything. 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;
}
}
// 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 refresh already
// running it starts no second one, and on the recovery screen it does nothing.
function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender();
if (refreshTimer === null) {
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
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");
}
// 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. The connection prompt 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);
if (!state.hasWallet) {
showView("welcome");
} else {
renderWalletList();
restoreView();
startRefresh();
}
}
document.addEventListener("DOMContentLoaded", init);