A popup already open when the stored profile became unreadable stayed on the last good profile until reopened. Every save already runs the check loadState() runs at open; a save refused by it now stops the ten-second refresh, runs the leave cleanup of the current screen, and raises the recovery screen. From then on showView() shows nothing else in that popup, so a transaction wait or a later save cannot take the user off it or clear an export or a typed confirmation. That is held in memory, never as the saved current view, so a popup opened after the record is erased elsewhere opens normally. Any other failed save keeps the "NOT SAVED" banner. The popup test harness now honours clearInterval(). Model: opus-5-5
277 lines
8.9 KiB
JavaScript
277 lines
8.9 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");
|
|
// 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);
|
|
}
|
|
|
|
let refreshInFlight = false;
|
|
|
|
// The ten-second refresh init() 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,
|
|
),
|
|
]);
|
|
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;
|
|
}
|
|
}
|
|
|
|
const ctx = {
|
|
renderWalletList,
|
|
doRefreshAndRender,
|
|
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");
|
|
}
|
|
|
|
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.
|
|
approval.show(approvalId);
|
|
showView("approve-site");
|
|
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();
|
|
doRefreshAndRender();
|
|
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|