fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already reads the stored record
and runs the same check loadState() runs at open; the popup now sends a save
refused by that check to the recovery screen and stops its ten-second refresh,
while any other failed save keeps the "NOT SAVED" banner and the screen it is
on. The recovery screen ignores a second request to show it, so a save that
was in flight does not clear an export or a typed confirmation. The test
harness records the refresh loop so a test can run one tick of it.

Model: opus-5-5
This commit is contained in:
2026-10-04 19:50:14 +00:00
parent 3b713809c8
commit bdbe999c05
7 changed files with 130 additions and 22 deletions
+27 -15
View File
@@ -1114,16 +1114,18 @@ because bumping for one would send every older install to StateRecovery for
nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
bytes, before normalization: `loadState()` and every `saveState()` for the
popup, and `getState()` for the background. It refuses a record that is not an
object, a `schemaVersion` this build does not understand (a newer one included),
a `wallets` that is not a list of wallet records with address records in them,
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
the whole point — a record the wallet cannot vouch for is never normalized,
never written back, and never half-loaded. The popup shows StateRecovery,
whether it finds the record unreadable when it opens or at a save while it is
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
spec leaves unassigned) saying the saved data cannot be read and that nothing
was signed or sent, rather than the generic `-32603` every request used to
answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and the floor is not the same for every field. Some are type-checked as a
@@ -1167,8 +1169,9 @@ now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
covers — a quota, a revoked permission — and the wallet must never look healthy
while that is true. A save that fails because the stored record fails the gate,
such as one a newer build wrote, gets StateRecovery instead of the banner.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
@@ -1961,9 +1964,18 @@ view would leave a wallet one click from deletion.
#### StateRecovery (`state-recovery`)
- **When**: `loadState()` refused the stored profile, so the popup has no
profile at all. It is the only screen reached without one, and the only one
that never appears during ordinary use.
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
`loadState()` refusing it, so the popup has no profile at all. While the popup
is open, on any screen, it is a save refusing it: every `saveState()` reads
the stored record and runs the same check before writing, so the popup finds
it at the next navigation, or at the next ten-second balance refresh that
reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)).
A save that fails for any other reason, such as a storage read or write that
errors, gets the "NOT SAVED" banner instead and leaves the screen as it is.
Once up, the screen stays until the popup closes or the record is erased:
nothing in AutistMask writes over a record that fails the check, so it cannot
become readable again underneath. It is the only screen that never appears
during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.