The ten-second refresh was started only by init(), and only when a wallet existed as the popup opened, so a popup that opened on Welcome refreshed the new wallet once and then showed that balance until it was reopened. init() and the three import paths on AddWallet now call startRefresh(), which refreshes at once and starts the ten-second refresh unless it is already running, and does nothing while the recovery screen is up. The two AddWallet tests that build their own context pass it in place of the refresh they passed before. Model: opus-5-5
340 lines
12 KiB
JavaScript
340 lines
12 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;
|
|
|
|
async function doRefreshAndRender() {
|
|
if (refreshInFlight) return;
|
|
refreshInFlight = true;
|
|
try {
|
|
await Promise.all([
|
|
refreshPrices(),
|
|
refreshBalances(
|
|
state.wallets,
|
|
state.rpcUrl,
|
|
state.blockscoutUrl,
|
|
state.trackedTokens,
|
|
state.networkId,
|
|
pageClosed.signal,
|
|
),
|
|
]);
|
|
state.lastBalanceRefresh = Date.now();
|
|
await saveState();
|
|
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 being swallowed by the save queue
|
|
// (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);
|