harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults. The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives. Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does. closes #320
This commit was merged in pull request #344.
This commit is contained in:
@@ -1,46 +1,42 @@
|
||||
// State management and extension storage persistence.
|
||||
//
|
||||
// The `state` export is a module-level singleton: ONE in-memory copy of the
|
||||
// profile per bundle, loaded once by loadState() and mutated in place from
|
||||
// then on. That is the popup's model — one page, one load at boot, one
|
||||
// lifetime.
|
||||
//
|
||||
// It is NOT the background's model, and the background must not reach it. The
|
||||
// MV3 service worker is torn down when idle and revived by the next message,
|
||||
// nothing loads state at module scope, and an unpopulated read used to hand
|
||||
// back DEFAULT_STATE with no complaint — five defects came out of that one
|
||||
// fact (https://git.eeqj.de/sneak/AutistMask/issues/324). Two things close it:
|
||||
// this module is unreachable from the background bundle, and reading a
|
||||
// persisted field of the singleton before a load now THROWS instead of quietly
|
||||
// serving a default.
|
||||
//
|
||||
// The unreachability is enforced by the BUILD. build.js fails when esbuild's
|
||||
// own metafile reports this module as an input of a background bundle — the
|
||||
// resolution the shipped file was built from, so no specifier syntax gets past
|
||||
// it — from the table in script/lib/forbiddenBundleInputs.js, which also
|
||||
// records what that does and does not cover. The ESLint rule that reports the
|
||||
// same thing in the editor is fast feedback in front of the build, not the
|
||||
// guarantee.
|
||||
|
||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||
const { networkById } = require("./networks");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
normalizePersisted,
|
||||
} = require("./persistedState");
|
||||
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
const { log } = require("./log");
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
hasWallet: false,
|
||||
wallets: [],
|
||||
trackedTokens: [],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||
// Endpoints remembered per network: { [networkId]: { rpcUrl,
|
||||
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
|
||||
// of the active network; this is what the others are restored from
|
||||
// when the active network changes. See onChainSwitch().
|
||||
networkEndpoints: {},
|
||||
lastBalanceRefresh: 0,
|
||||
activeAddress: null,
|
||||
allowedSites: {},
|
||||
deniedSites: {},
|
||||
rememberSiteChoice: true,
|
||||
showZeroBalanceTokens: true,
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideFraudContracts: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
utcTimestamps: false,
|
||||
fraudContracts: [],
|
||||
tokenHolderCache: {},
|
||||
theme: "system",
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
const state = {
|
||||
// The live record the proxy below guards. Everything inside this module reads
|
||||
// and writes THIS object, never the proxy: the guard is for callers.
|
||||
const rawState = {
|
||||
...DEFAULT_STATE,
|
||||
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
|
||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
||||
// mutates this map in place, and a spread copies the reference.
|
||||
networkEndpoints: {},
|
||||
currentView: null,
|
||||
@@ -51,161 +47,75 @@ const state = {
|
||||
viewStack: [],
|
||||
};
|
||||
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
// False until loadState() has completed in this bundle. Until then, a
|
||||
// persisted field that has not been assigned in this context cannot be READ:
|
||||
// see StateNotLoadedError.
|
||||
let loaded = false;
|
||||
|
||||
// True once this context has assigned anything into the singleton.
|
||||
//
|
||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||
// behind it used to be restored verbatim, so Back could walk onto a screen
|
||||
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
||||
// Back control to leave by. Truncating at the first such entry instead of
|
||||
// splicing it out keeps the result a prefix of the stored stack, so every
|
||||
// surviving entry's Back target is exactly the one it had; splicing would
|
||||
// silently re-point the entry above the hole at a different screen.
|
||||
// What the guard is for is a context that READS a profile nobody put there —
|
||||
// every one of the five defects was a pure read of an untouched singleton,
|
||||
// answered out of DEFAULT_STATE. A context that has written into it is
|
||||
// managing it deliberately (the popup does, via loadState() at boot and by
|
||||
// hand thereafter), and reading back what you yourself put there is not the
|
||||
// mistake being caught.
|
||||
//
|
||||
// Filtering happens here on load rather than in saveState(): the live
|
||||
// in-session stack is legitimate (the screen really is rendered while the
|
||||
// popup is open), and only a load-side filter also repairs the stacks
|
||||
// already in storage, including ones written before a view left the set.
|
||||
function restorableStack(stored, currentView) {
|
||||
// A stored stack that is missing or not an array keeps nothing, but it
|
||||
// still goes through the never-empty rule below rather than returning
|
||||
// early: otherwise a corrupt stack would depend on exactly the goBack()
|
||||
// fallback that the explicit ["main"] exists in order not to depend on.
|
||||
const source = Array.isArray(stored) ? stored : [];
|
||||
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
|
||||
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
|
||||
// A view restored below the root still needs somewhere for Back to go.
|
||||
if (
|
||||
kept.length === 0 &&
|
||||
currentView !== "main" &&
|
||||
RESTORABLE_VIEWS.has(currentView)
|
||||
) {
|
||||
return ["main"];
|
||||
// The cost of that is honest and worth naming: a context that writes one field
|
||||
// and then reads a different, untouched one is still served that field's
|
||||
// default. Nothing closes that here — what closes it for the background is
|
||||
// that the background cannot reach this module at all, which build.js asserts
|
||||
// against esbuild's metafile on every build (FORBIDDEN_INPUTS in
|
||||
// script/lib/forbiddenBundleInputs.js, pinned by
|
||||
// tests/buildForbiddenInputs.test.js).
|
||||
let adopted = false;
|
||||
|
||||
// Every field whose pre-load value would be a plausible-looking default rather
|
||||
// than the user's data. The view scratch fields are guarded too: currentView
|
||||
// and viewStack are persisted, and a save that carried their pre-load values
|
||||
// would overwrite a real stored stack with an empty one.
|
||||
const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
|
||||
|
||||
class StateNotLoadedError extends Error {
|
||||
constructor(field) {
|
||||
super(
|
||||
"state." +
|
||||
field +
|
||||
" was read before loadState(); this context has no profile" +
|
||||
" loaded and must not be served DEFAULT_STATE",
|
||||
);
|
||||
this.name = "StateNotLoadedError";
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Loud, not defaulted. The whole defect class this guard closes looks exactly
|
||||
// like working code at the call site: the read succeeds, the value is
|
||||
// well-formed, and it describes a wallet that is not the user's.
|
||||
const state = new Proxy(rawState, {
|
||||
get(target, prop, receiver) {
|
||||
if (
|
||||
!loaded &&
|
||||
!adopted &&
|
||||
typeof prop === "string" &&
|
||||
GUARDED_FIELDS.has(prop)
|
||||
) {
|
||||
throw new StateNotLoadedError(prop);
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
|
||||
adopted = true;
|
||||
}
|
||||
return Reflect.set(target, prop, value, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
// Return the network configuration for the currently selected network.
|
||||
function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// Every field written to and read from the single "autistmask" storage key.
|
||||
// hasWallet is deliberately excluded from the diffing/merge logic below —
|
||||
// like loadState() does, it is always derived from `wallets`, never carried
|
||||
// as an independent value.
|
||||
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||
.filter((key) => key !== "hasWallet")
|
||||
.concat([
|
||||
"currentView",
|
||||
"selectedWallet",
|
||||
"selectedAddress",
|
||||
"selectedToken",
|
||||
"viewData",
|
||||
"viewStack",
|
||||
]);
|
||||
|
||||
// Turn a raw stored (or missing) record into the full, defaulted shape
|
||||
// loadState() used to assign directly onto `state`. Pulled out as a pure
|
||||
// function so saveState() can apply it too: the fields THIS page did not
|
||||
// change still have to come from storage in their loaded-and-normalized
|
||||
// form, not as the raw bytes another page (or an old release) left there —
|
||||
// otherwise a legacy shape a load has always self-healed in memory (a
|
||||
// missing networkEndpoints map, an out-of-range flag) is dropped right back
|
||||
// into storage unfixed every time the page that DID normalize it saves
|
||||
// something unrelated, because that field's value never "changed" for that
|
||||
// page to notice.
|
||||
function normalizePersisted(saved) {
|
||||
saved = saved || {};
|
||||
const out = {};
|
||||
out.wallets = saved.wallets || [];
|
||||
// Derived, never trusted verbatim off storage — see loadState().
|
||||
out.hasWallet = out.wallets.length > 0;
|
||||
out.trackedTokens = saved.trackedTokens || [];
|
||||
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||
// An actual object is required, not merely a truthy non-array: the code
|
||||
// below and onChainSwitch() index and ASSIGN INTO this value, and
|
||||
// assigning a property to a string or a number is a silent no-op in
|
||||
// sloppy mode. Copied rather than referenced, nested pairs included, so
|
||||
// normalizing never mutates the object a caller handed in.
|
||||
const rawEndpoints =
|
||||
typeof saved.networkEndpoints === "object" &&
|
||||
saved.networkEndpoints !== null &&
|
||||
!Array.isArray(saved.networkEndpoints)
|
||||
? saved.networkEndpoints
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
for (const netId of Object.keys(rawEndpoints)) {
|
||||
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
|
||||
}
|
||||
// A profile written before this map existed carries exactly one pair of
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
// that network's remembered pair, so a custom endpoint set on the old
|
||||
// build is not lost by the first switch away and back.
|
||||
if (!out.networkEndpoints[out.networkId]) {
|
||||
out.networkEndpoints[out.networkId] = {
|
||||
rpcUrl: out.rpcUrl,
|
||||
blockscoutUrl: out.blockscoutUrl,
|
||||
};
|
||||
}
|
||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||
out.activeAddress = saved.activeAddress || null;
|
||||
out.allowedSites =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? saved.allowedSites
|
||||
: {};
|
||||
out.deniedSites =
|
||||
saved.deniedSites && !Array.isArray(saved.deniedSites)
|
||||
? saved.deniedSites
|
||||
: {};
|
||||
out.rememberSiteChoice =
|
||||
saved.rememberSiteChoice !== undefined
|
||||
? saved.rememberSiteChoice
|
||||
: true;
|
||||
out.showZeroBalanceTokens =
|
||||
saved.showZeroBalanceTokens !== undefined
|
||||
? saved.showZeroBalanceTokens
|
||||
: true;
|
||||
// A profile written before this setting existed has no key for it. It
|
||||
// is a safety filter, so absent must load as on, not as undefined.
|
||||
out.hideSpoofedSymbols =
|
||||
saved.hideSpoofedSymbols !== undefined
|
||||
? saved.hideSpoofedSymbols
|
||||
: true;
|
||||
out.hideLowHolderTokens =
|
||||
saved.hideLowHolderTokens !== undefined
|
||||
? saved.hideLowHolderTokens
|
||||
: true;
|
||||
out.hideFraudContracts =
|
||||
saved.hideFraudContracts !== undefined
|
||||
? saved.hideFraudContracts
|
||||
: true;
|
||||
out.hideDustTransactions =
|
||||
saved.hideDustTransactions !== undefined
|
||||
? saved.hideDustTransactions
|
||||
: true;
|
||||
out.dustThresholdGwei =
|
||||
saved.dustThresholdGwei !== undefined
|
||||
? saved.dustThresholdGwei
|
||||
: 100000;
|
||||
out.utcTimestamps =
|
||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||
out.fraudContracts = saved.fraudContracts || [];
|
||||
out.tokenHolderCache = saved.tokenHolderCache || {};
|
||||
out.theme = saved.theme || "system";
|
||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||
out.currentView = saved.currentView || null;
|
||||
out.selectedWallet =
|
||||
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||
out.selectedAddress =
|
||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||
out.selectedToken = saved.selectedToken || null;
|
||||
out.viewData = saved.viewData || {};
|
||||
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The persisted fields as they stood at the end of this page's last
|
||||
// loadState() or saveState(). saveState() diffs the live state against this
|
||||
// to find only the fields THIS page actually changed.
|
||||
@@ -217,7 +127,7 @@ let baseline = null;
|
||||
|
||||
function snapshotPersisted() {
|
||||
const out = {};
|
||||
for (const key of PERSISTED_FIELDS) out[key] = state[key];
|
||||
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -374,11 +284,10 @@ function mergeWallet(base, ours, theirs) {
|
||||
}
|
||||
|
||||
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
|
||||
// tokenBalances is itself an array, but only backgroundRefresh() ever
|
||||
// writes it and always wholesale (refreshBalances() in
|
||||
// src/shared/balances.js), so there is no membership to reconcile within
|
||||
// it — it is a leaf like balance or ensName, not a list with its own
|
||||
// identity.
|
||||
// tokenBalances is itself an array, but only a balance refresh ever writes
|
||||
// it and always wholesale (refreshBalances() in src/shared/balances.js), so
|
||||
// there is no membership to reconcile within it — it is a leaf like balance
|
||||
// or ensName, not a list with its own identity.
|
||||
function mergeAddress(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -425,12 +334,12 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
}
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||
// list is itself membership, not a leaf — src/background/index.js pushes a
|
||||
// newly approved/denied hostname onto it in place, and the Settings "revoke"
|
||||
// button (src/popup/views/settings.js) filters a hostname out of it in
|
||||
// place, from a different page. Merge it the same way wallets are merged:
|
||||
// identity is the hostname itself, so a merged pair is always equal and
|
||||
// mergeItem is a no-op pick.
|
||||
// list is itself membership, not a leaf — the background appends a newly
|
||||
// approved/denied hostname to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||
// different page. Merge it the same way wallets are merged: identity is the
|
||||
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// pick.
|
||||
function mergeHostnameList(base, ours, theirs) {
|
||||
return mergeListByIdentity(
|
||||
base,
|
||||
@@ -445,14 +354,15 @@ function mergeSiteMap(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||
}
|
||||
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }. onChainSwitch()
|
||||
// (src/shared/chainSwitch.js) writes state.networkEndpoints[networkId] in
|
||||
// place before saving. No code path ever removes a key from this map, so the
|
||||
// membership collision that matters for allowedSites/wallets (an add on one
|
||||
// page racing a delete on another) can't happen here — but two pages
|
||||
// switching to two different networks concurrently still race a whole-field
|
||||
// diff the same way, so it gets the same per-key merge for the leaf edit
|
||||
// case (e.g. Settings saving a custom RPC URL for the active network).
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||
// applyChainSwitchFields() (src/shared/chainSwitchFields.js) writes
|
||||
// networkEndpoints[networkId] in place before saving. No code path ever
|
||||
// removes a key from this map, so the membership collision that matters for
|
||||
// allowedSites/wallets (an add on one page racing a delete on another) can't
|
||||
// happen here — but two pages switching to two different networks
|
||||
// concurrently still race a whole-field diff the same way, so it gets the same
|
||||
// per-key merge for the leaf edit case (e.g. Settings saving a custom RPC URL
|
||||
// for the active network).
|
||||
function mergeEndpointEntry(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -468,12 +378,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
|
||||
// Read-modify-write, merged per field, rather than one full-blob write.
|
||||
//
|
||||
// Every extension page (the toolbar popup, a dApp approval window, the
|
||||
// background's backgroundRefresh()) holds its own in-memory `state`, loaded
|
||||
// once, and showView() saves on every navigation. A full-blob write here
|
||||
// clobbers whatever a second page had written since — including, in the
|
||||
// worst case, an entire wallet and its encrypted secret with no attacker
|
||||
// and no unusual input (see the issue this fixes).
|
||||
// Every extension page (the toolbar popup, a dApp approval window) holds its
|
||||
// own in-memory `state`, loaded once, and showView() saves on every
|
||||
// navigation. A full-blob write here clobbers whatever a second page had
|
||||
// written since — including, in the worst case, an entire wallet and its
|
||||
// encrypted secret with no attacker and no unusual input (see the issue this
|
||||
// fixes).
|
||||
//
|
||||
// Only the fields this page actually changed — those that differ from
|
||||
// `baseline`, captured at the last loadState()/saveState() on this page —
|
||||
@@ -482,28 +392,27 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
//
|
||||
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
|
||||
// identity and then by address identity within each wallet), not as one
|
||||
// whole field: backgroundRefresh() mutates wallets IN PLACE (addr.balance /
|
||||
// whole field: a balance refresh mutates wallets IN PLACE (addr.balance /
|
||||
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
|
||||
// would mark all of `wallets` "changed" the moment any balance moved and
|
||||
// write back background's own copy — loaded before its multi-second network
|
||||
// write back that page's own copy — loaded before its multi-second network
|
||||
// round trip — clobbering a wallet another page added, or resurrecting one
|
||||
// another page deleted, in that window. Merging by identity lets
|
||||
// background's leaf changes and another page's membership changes
|
||||
// (add/delete a wallet or an address) apply independently instead of
|
||||
// colliding as the same field.
|
||||
// another page deleted, in that window. Merging by identity lets the leaf
|
||||
// changes and another page's membership changes (add/delete a wallet or an
|
||||
// address) apply independently instead of colliding as the same field.
|
||||
//
|
||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||
// by address key and then by hostname within each address's list), for the
|
||||
// identical reason: src/background/index.js pushes a newly
|
||||
// approved/denied hostname onto them in place, and the Settings "revoke"
|
||||
// button (src/popup/views/settings.js) filters one out in place, from a
|
||||
// different page. A whole-field diff here doesn't just lose data, it is a
|
||||
// security defect — a stale page's save can resurrect a just-revoked site
|
||||
// permission, or silently wipe a permission just granted elsewhere.
|
||||
// identical reason: the background appends a newly approved/denied hostname
|
||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
||||
// filters one out in place, from a different page. A whole-field diff here
|
||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||
// resurrect a just-revoked site permission, or silently wipe a permission just
|
||||
// granted elsewhere.
|
||||
//
|
||||
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
|
||||
// by network id), since onChainSwitch() writes into it in place; the value
|
||||
// per key is a small leaf object with no membership of its own; see the
|
||||
// by network id), since applyChainSwitchFields() writes into it in place; the
|
||||
// value per key is a small leaf object with no membership of its own; see the
|
||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||
// milder than the other two.
|
||||
//
|
||||
@@ -511,8 +420,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
||||
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
||||
// only ever reset wholesale to `{}` (onChainSwitch()) — so there is no
|
||||
// in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// this page's own UI scratch space, not data another page has any reason to
|
||||
// share membership of.
|
||||
//
|
||||
@@ -539,7 +448,7 @@ async function saveStateOnce() {
|
||||
const result = await storageGet("autistmask");
|
||||
// Normalized, not raw: a field this page did not change still has to
|
||||
// come from storage in its loaded (self-healed) shape. See
|
||||
// normalizePersisted() above.
|
||||
// normalizePersisted() in persistedState.js.
|
||||
const fresh = normalizePersisted(result.autistmask);
|
||||
|
||||
const merged = { ...fresh };
|
||||
@@ -578,7 +487,7 @@ async function saveStateOnce() {
|
||||
// Derived from this page's own wallets, never adopted off the wire —
|
||||
// see loadState(). Everything else this page did not change is left
|
||||
// exactly as it stood; see the note above.
|
||||
state.hasWallet = state.wallets.length > 0;
|
||||
rawState.hasWallet = rawState.wallets.length > 0;
|
||||
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
@@ -606,14 +515,21 @@ function saveState() {
|
||||
async function loadState() {
|
||||
const result = await storageGet("autistmask");
|
||||
if (result.autistmask) {
|
||||
Object.assign(state, normalizePersisted(result.autistmask));
|
||||
Object.assign(rawState, normalizePersisted(result.autistmask));
|
||||
}
|
||||
// The point of comparison every saveState() on this page diffs against,
|
||||
// whether storage had a profile or was empty. See PERSISTED_FIELDS above
|
||||
// saveState() for why a reference here would be wrong.
|
||||
// Whether storage had a profile or was empty, this context has now read
|
||||
// it, and the defaults standing in for an empty profile are the right
|
||||
// answer rather than a stand-in for one nobody looked for.
|
||||
loaded = true;
|
||||
// The point of comparison every saveState() on this page diffs against.
|
||||
// See PERSISTED_FIELDS in persistedState.js for why a reference here
|
||||
// would be wrong.
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
|
||||
// Through the guarded proxy, not rawState: a caller asking which address is
|
||||
// selected before anything was loaded gets the same loud failure it would get
|
||||
// reading the fields itself.
|
||||
function currentAddress() {
|
||||
if (state.selectedWallet === null || state.selectedAddress === null) {
|
||||
return null;
|
||||
@@ -627,4 +543,5 @@ module.exports = {
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
StateNotLoadedError,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user