fix: an open popup follows a network switch the background saves (closes #650)
When the background saves a network switch after the user approves a site's request, or another popup page saves one, an open popup takes the new network from the stored record inside a save, holding the lock, and checks the record once at start-up for a switch saved before it listened. The network it takes is not counted as the popup's own change, even when that save fails to write. It then leaves the page as a switch in Settings does. A switch in Settings is now made inside its own save too, so one picked while a follow waits for the lock is made after it. The popup never switches the network itself. Model: opus-5-5
This commit was merged in pull request #654.
This commit is contained in:
+44
-6
@@ -5,12 +5,14 @@ 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,
|
||||
@@ -22,6 +24,7 @@ const {
|
||||
} = require("./views/helpers");
|
||||
const { applyTheme } = require("./theme");
|
||||
const {
|
||||
onStorageChanged,
|
||||
tabsGetCurrent,
|
||||
tabsGetZoom,
|
||||
windowsGetCurrent,
|
||||
@@ -72,12 +75,13 @@ let refreshTimer = null;
|
||||
// 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.
|
||||
// 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.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
@@ -147,6 +151,7 @@ const ctx = {
|
||||
renderWalletList,
|
||||
doRefreshAndRender,
|
||||
startRefresh,
|
||||
restoreView,
|
||||
pageClosed: pageClosed.signal,
|
||||
showAddWalletView: () => {
|
||||
pushCurrentView();
|
||||
@@ -214,6 +219,28 @@ function fallbackView() {
|
||||
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
|
||||
@@ -358,6 +385,17 @@ async function init() {
|
||||
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 {
|
||||
|
||||
@@ -39,8 +39,8 @@ function markViewRendered(view) {
|
||||
}
|
||||
|
||||
// Begin a fresh page-load scope. The popup gets one by being loaded, and
|
||||
// asks for one when the user switches the network, because every screen
|
||||
// then on the page shows the previous network (views/settings.js); the
|
||||
// asks for one after a network switch, because every screen then on the page
|
||||
// shows the previous network (showNewNetwork() in views/settings.js); the
|
||||
// unit tests, which simulate several page loads against one module
|
||||
// instance, ask for one too.
|
||||
function resetRenderedViews() {
|
||||
|
||||
+61
-28
@@ -38,6 +38,8 @@ const {
|
||||
|
||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
let ctx;
|
||||
|
||||
let versionClickCount = 0;
|
||||
let versionClickTimer = null;
|
||||
|
||||
@@ -243,7 +245,51 @@ async function renderSiteLists() {
|
||||
);
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
// A network switch, made by the user here or saved by the background once the
|
||||
// user approved a site's request (src/popup/index.js), leaves every screen on
|
||||
// the page showing the previous network. These two leave the page as README's
|
||||
// Screen Map describes, the first before the switch is saved, so that save
|
||||
// keeps the cut for a popup closed straight after, the second once `state`
|
||||
// holds the new network.
|
||||
//
|
||||
// The stack is cut at the first screen that cannot be rendered from saved
|
||||
// state, such as Send or AddToken, and at AddressToken unless it shows the
|
||||
// native token: any other token there was picked on the previous network, so
|
||||
// it is no longer selected either. Without a wallet the stack starts at
|
||||
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
|
||||
function cutViewStack() {
|
||||
if (!state.hasWallet) return;
|
||||
if (state.selectedToken !== "ETH") {
|
||||
const tokenView = state.viewStack.indexOf("address-token");
|
||||
if (tokenView !== -1) {
|
||||
state.viewStack = state.viewStack.slice(0, tokenView);
|
||||
}
|
||||
state.selectedToken = null;
|
||||
}
|
||||
state.viewStack = restorableStack(state.viewStack, state.currentView);
|
||||
}
|
||||
|
||||
// Every screen counts as not yet rendered, so "Back" renders the one it lands
|
||||
// on for the new network. Settings on top is brought up to date in place;
|
||||
// any other screen is drawn again as a reopened popup draws it, which is Home
|
||||
// for one that cannot be rendered from saved state. Without a wallet any other
|
||||
// screen is Welcome or AddWallet, which show nothing from a network.
|
||||
function showNewNetwork() {
|
||||
resetRenderedViews();
|
||||
if (state.currentView === "settings") {
|
||||
markViewRendered("settings");
|
||||
$("settings-network").value = state.networkId;
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
renderTrackedTokens();
|
||||
} else if (state.hasWallet) {
|
||||
ctx.restoreView();
|
||||
}
|
||||
updateDebugBanner(state.currentView);
|
||||
}
|
||||
|
||||
function init(_ctx) {
|
||||
ctx = _ctx;
|
||||
deleteWallet.init(ctx);
|
||||
showPhrase.init();
|
||||
|
||||
@@ -319,37 +365,18 @@ function init(ctx) {
|
||||
|
||||
const networkSelect = $("settings-network");
|
||||
networkSelect.addEventListener("change", async () => {
|
||||
// Whether `state` already has the network picked is asked only once
|
||||
// the switch holds the lock: while an earlier pick waits for it,
|
||||
// `state` still has the network before that pick, and a pick back to
|
||||
// that network must still be made.
|
||||
const newId = networkSelect.value;
|
||||
if (newId === state.networkId) return;
|
||||
// The screens beneath Settings show the previous network, so Back
|
||||
// finds them as it does in a reopened popup: each is rendered again
|
||||
// when Back lands on it, and the stack is cut at the first one that
|
||||
// cannot be rendered from saved state, such as Send or AddToken. It
|
||||
// is cut at AddressToken too, unless that shows the native token: any
|
||||
// other token there was picked on the previous network. Without a
|
||||
// wallet the stack starts at Welcome, which that cut would drop, and
|
||||
// Welcome shows nothing from a network. The cut comes before the
|
||||
// switch so that the switch's save keeps it for a popup closed
|
||||
// straight after.
|
||||
if (state.hasWallet) {
|
||||
const tokenView = state.viewStack.indexOf("address-token");
|
||||
if (tokenView !== -1 && state.selectedToken !== "ETH") {
|
||||
state.viewStack = state.viewStack.slice(0, tokenView);
|
||||
}
|
||||
state.viewStack = restorableStack(state.viewStack, "settings");
|
||||
}
|
||||
cutViewStack();
|
||||
const net = await onChainSwitch(newId);
|
||||
if (!net) return;
|
||||
// Open pages are told by the background, as after a site's approved
|
||||
// switch request.
|
||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||
resetRenderedViews();
|
||||
// Settings itself, and the testnet banner above it, are brought up
|
||||
// to date for the new network here.
|
||||
markViewRendered("settings");
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
renderTrackedTokens();
|
||||
updateDebugBanner(state.currentView);
|
||||
showNewNetwork();
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
});
|
||||
|
||||
@@ -484,4 +511,10 @@ function init(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, renderSiteLists };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
renderSiteLists,
|
||||
cutViewStack,
|
||||
showNewNetwork,
|
||||
};
|
||||
|
||||
@@ -224,6 +224,19 @@ function storageRemove(keys) {
|
||||
return Promise.resolve(storage.remove(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `listener` after every write to `storage.local`, whether this page,
|
||||
* another page or the background made it, with what the write changed, as the
|
||||
* browser reports it: `{ [key]: { oldValue, newValue } }`.
|
||||
*
|
||||
* @param {function(Object): void} listener
|
||||
*/
|
||||
function onStorageChanged(listener) {
|
||||
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === "local") listener(changes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` while holding the browser's lock called `name`
|
||||
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
|
||||
@@ -385,6 +398,7 @@ module.exports = {
|
||||
getPlatformInfo,
|
||||
hasBrowserNamespace,
|
||||
notify,
|
||||
onStorageChanged,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
sessionStorageGet,
|
||||
|
||||
+37
-17
@@ -1,8 +1,18 @@
|
||||
// Consolidated chain-switch handler for the popup.
|
||||
// The popup's network switch.
|
||||
//
|
||||
// Every state change required when the active network changes is
|
||||
// performed here so that callers (settings UI, future chain additions) all go
|
||||
// through a single code path.
|
||||
// A network switch reaches the popup's `state` in one of two ways:
|
||||
//
|
||||
// - onChainSwitch() below makes the switch the user picks in Settings, with
|
||||
// applyChainSwitchFields() (chainSwitchFields.js).
|
||||
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
|
||||
// or another popup page saved, from the stored record, with
|
||||
// takeStoredNetwork() (state.js), which copies it with
|
||||
// copyChainSwitchFields() (chainSwitchFields.js).
|
||||
//
|
||||
// Both change `state` inside a save, holding the lock, and both reset the
|
||||
// popup's own caches through clearPopupCaches(), so a reset added there
|
||||
// reaches both. A field applyChainSwitchFields() sets has to be copied by
|
||||
// copyChainSwitchFields() too.
|
||||
//
|
||||
// Adding a new chain (e.g. ETC) requires only a new entry in
|
||||
// networks.js — no per-caller wiring is needed.
|
||||
@@ -20,22 +30,32 @@
|
||||
const { applyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const { clearPrices } = require("./prices");
|
||||
|
||||
// The popup's own caches a network switch resets. Prices are chain-specific
|
||||
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
|
||||
// and per bundle, so this is the popup's own cache — the only context that
|
||||
// ever fills it.
|
||||
function clearPopupCaches() {
|
||||
clearPrices();
|
||||
}
|
||||
|
||||
// Switch the active chain and reset all chain-specific cached state.
|
||||
// Returns the network configuration object for the new chain.
|
||||
// Returns the network configuration object for the new chain, or null when
|
||||
// `state` already has that network once the save holds the lock, and nothing
|
||||
// is switched.
|
||||
//
|
||||
// `state` is switched inside the save that stores the switch, holding the
|
||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
||||
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
|
||||
// follow runs first, and this switch is made after it.
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const { state, saveState } = require("./state");
|
||||
|
||||
const net = applyChainSwitchFields(state, newNetworkId);
|
||||
|
||||
// --- price cache ---
|
||||
// Prices are chain-specific (testnet tokens are worthless,
|
||||
// ETC has different pricing, etc.). In-memory and per bundle, so this is
|
||||
// the popup's own cache — the only context that ever fills it.
|
||||
clearPrices();
|
||||
|
||||
await saveState();
|
||||
|
||||
let net = null;
|
||||
await saveState(() => {
|
||||
if (state.networkId === newNetworkId) return false;
|
||||
net = applyChainSwitchFields(state, newNetworkId);
|
||||
clearPopupCaches();
|
||||
});
|
||||
return net;
|
||||
}
|
||||
|
||||
module.exports = { onChainSwitch };
|
||||
module.exports = { onChainSwitch, clearPopupCaches };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the singleton and saves; the background applies it to the detached record of
|
||||
// its own read-modify-write (src/background/state.js).
|
||||
//
|
||||
// Everything here is synchronous and touches nothing but the object it is
|
||||
// Everything here is synchronous and touches nothing but the objects it is
|
||||
// given: no storage, no caches, no imports beyond the network table. That is
|
||||
// what makes it usable on a record that has been read fresh from storage
|
||||
// microseconds earlier and is about to be written back.
|
||||
@@ -76,4 +76,33 @@ function applyChainSwitchFields(s, newNetworkId) {
|
||||
return net;
|
||||
}
|
||||
|
||||
module.exports = { applyChainSwitchFields };
|
||||
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
|
||||
// the popup takes a switch the background saved from the stored record this
|
||||
// way, rather than performing it again (takeStoredNetwork() in
|
||||
// src/shared/state.js). Balances are taken by address, and an address
|
||||
// `switched` does not have keeps none.
|
||||
function copyChainSwitchFields(s, switched) {
|
||||
s.networkEndpoints = switched.networkEndpoints;
|
||||
s.networkTrackedTokens = switched.networkTrackedTokens;
|
||||
s.trackedTokens = switched.trackedTokens;
|
||||
s.networkId = switched.networkId;
|
||||
s.rpcUrl = switched.rpcUrl;
|
||||
s.blockscoutUrl = switched.blockscoutUrl;
|
||||
s.lastBalanceRefresh = switched.lastBalanceRefresh;
|
||||
s.tokenHolderCache = switched.tokenHolderCache;
|
||||
s.fraudContracts = switched.fraudContracts;
|
||||
|
||||
const stored = new Map();
|
||||
for (const wallet of switched.wallets) {
|
||||
for (const addr of wallet.addresses) stored.set(addr.address, addr);
|
||||
}
|
||||
for (const wallet of s.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
const got = stored.get(addr.address);
|
||||
addr.balance = got ? got.balance : "0";
|
||||
addr.tokenBalances = got ? got.tokenBalances : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
|
||||
|
||||
+20
-3
@@ -23,6 +23,7 @@
|
||||
// guarantee.
|
||||
|
||||
const { networkById } = require("./networks");
|
||||
const { copyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
@@ -125,8 +126,10 @@ function currentNetwork() {
|
||||
}
|
||||
|
||||
// The persisted fields as this page held them when its last loadState()
|
||||
// finished, or when its last successful saveState() began. saveState() diffs
|
||||
// the live state against this to find only the fields THIS page changed since.
|
||||
// finished, or when its last successful saveState() began, with any network
|
||||
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
|
||||
// saveState() diffs the live state against this to find only the fields THIS
|
||||
// page changed since.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||
@@ -466,7 +469,9 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// exists to remove, just delayed and confined to one page instead of two
|
||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||
// does not own goes on being whatever its last loadState() saw, same as
|
||||
// before this fix; only the persisted record is guaranteed current.
|
||||
// before this fix; only the persisted record is guaranteed current. The
|
||||
// network is the exception: the popup takes a switch another context saved
|
||||
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js).
|
||||
async function saveStateOnce(beforeWrite) {
|
||||
const result = await storageGet("autistmask");
|
||||
// The record in storage right now is about to be merged into and written
|
||||
@@ -597,6 +602,17 @@ function saveState(beforeWrite) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
// Give this page the network switch held by `stored`, a record a save of this
|
||||
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
|
||||
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
|
||||
// with a whole record. The switch is then not this page's own change, even when
|
||||
// that save fails to write: a later switch back to the previous network still
|
||||
// differs from `baseline`, and is stored.
|
||||
function takeStoredNetwork(stored) {
|
||||
copyChainSwitchFields(rawState, stored);
|
||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
||||
}
|
||||
|
||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||
@@ -642,6 +658,7 @@ module.exports = {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
takeStoredNetwork,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
|
||||
Reference in New Issue
Block a user