CoinDesk now refuses requests without an API key, so mainnet showed no USD prices. Mainnet prices now come from Chainlink's USD price feeds, read with eth_call through the user's RPC endpoint; no other service is asked. The bundled list pairs each of 53 tokens with its feed by contract address, and ETH and WETH use ETH/USD. The popup reads the feeds of ETH and the tokens the user holds or tracks in one batched request, at most every 5 minutes. A reading older than its heartbeat plus 30 minutes, or not positive, is no price. A token with no feed shows no USD value. CoinDesk is gone. RULES.md still names the CoinDesk price API. Model: opus-5-5
446 lines
17 KiB
JavaScript
446 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());
|
|
|
|
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 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) 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) {
|
|
// 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.
|
|
//
|
|
// 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);
|