A balance refresh that returns now draws the balances on AddressDetail or AddressToken again when that screen is up, not only the wallet list; their transaction lists are left alone. Every add path on AddToken and SettingsAddToken tracks a token through one step, trackToken(), which also lists it in the new stored field tokensNotFetched: its line reads `quantity unknown` instead of 0.0000 until a refresh that started with it tracked has fetched every address's token balances, even if the popup closes first. To tell an address whose token balances the explorer did not return, the refresh takes them off its copy of the wallets before fetching. Model: opus-5-5
436 lines
17 KiB
JavaScript
436 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 } = 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());
|
|
|
|
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,
|
|
// reaches this page only after the stored record (followNetworkSwitch() below).
|
|
// 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.
|
|
//
|
|
// Once the save is done, the wallet list is drawn again, and so are the
|
|
// balances on AddressDetail or AddressToken when that is the screen up. No
|
|
// other screen is drawn again, DeleteAddress and its balance lines included.
|
|
async function doRefreshAndRender() {
|
|
if (refreshInFlight) return;
|
|
refreshInFlight = true;
|
|
try {
|
|
const networkId = state.networkId;
|
|
// The tracked tokens this refresh asks the explorer about. A token
|
|
// added on AddToken after this point is not among them, so this
|
|
// refresh does not count as having fetched it.
|
|
const tokens = state.trackedTokens.map((t) => t.address.toLowerCase());
|
|
const wallets = structuredClone(state.wallets);
|
|
// Taken off the copy, so that an address whose token balances the
|
|
// explorer did not return comes back without any and keeps its
|
|
// stored ones.
|
|
for (const wallet of wallets) {
|
|
for (const addr of wallet.addresses) delete addr.tokenBalances;
|
|
}
|
|
await Promise.all([
|
|
refreshPrices(),
|
|
refreshBalances(
|
|
wallets,
|
|
state.rpcUrl,
|
|
state.blockscoutUrl,
|
|
state.trackedTokens,
|
|
networkId,
|
|
pageClosed.signal,
|
|
),
|
|
]);
|
|
const refreshed = new Map();
|
|
let allTokenBalancesFetched = true;
|
|
for (const wallet of wallets) {
|
|
for (const addr of wallet.addresses) {
|
|
refreshed.set(addr.address, addr);
|
|
if (addr.tokenBalances === undefined) {
|
|
allTokenBalancesFetched = false;
|
|
}
|
|
}
|
|
}
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
// Each token this refresh asked about is now fetched for every
|
|
// address.
|
|
if (allTokenBalancesFetched) {
|
|
state.tokensNotFetched = state.tokensNotFetched.filter(
|
|
(token) => !tokens.includes(token),
|
|
);
|
|
}
|
|
state.lastBalanceRefresh = Date.now();
|
|
return true;
|
|
});
|
|
renderWalletList();
|
|
if (state.currentView === "address") addressDetail.renderBalances();
|
|
if (state.currentView === "address-token") addressToken.renderBalance();
|
|
} 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,
|
|
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.
|
|
async function followNetworkSwitch() {
|
|
let switched = false;
|
|
// A failed save is reported by saveState() itself: see onSaveFailure().
|
|
await saveState((stored) => {
|
|
if (stored.networkId === state.networkId) return false;
|
|
settings.cutViewStack();
|
|
takeStoredNetwork(stored);
|
|
clearPopupCaches();
|
|
switched = true;
|
|
}).catch(() => {});
|
|
if (switched) settings.showNewNetwork();
|
|
}
|
|
|
|
// 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);
|
|
|
|
// Only a record naming a network other than this page's is followed: the
|
|
// one this page's own switch in Settings saves names the network it
|
|
// already has, and a record naming none is no switch.
|
|
onStorageChanged((changes) => {
|
|
const written = changes.autistmask && changes.autistmask.newValue;
|
|
if (!written || !written.networkId) return;
|
|
if (written.networkId !== state.networkId) followNetworkSwitch();
|
|
});
|
|
// A switch saved since loadState() above, before there was a listener.
|
|
followNetworkSwitch();
|
|
|
|
if (!state.hasWallet) {
|
|
showView("welcome");
|
|
} else {
|
|
renderWalletList();
|
|
restoreView();
|
|
startRefresh();
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|