Compare commits

..
1 Commits
Author SHA1 Message Date
sneak e791e06fb1 fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
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 runs the check loadState()
runs at open; a save refused by it now raises the recovery screen, stops the
ten-second refresh, and passes the screen to showView(), which runs the leave
cleanup of the screen it replaces and records it as the current view. From
then on showView() shows nothing else, so a transaction wait or a later save
cannot take the user off it or clear an export or a typed confirmation. Any
other failed save keeps the "NOT SAVED" banner. The popup test harness now
honours clearInterval().

Model: opus-5-5
2026-10-04 20:21:04 +00:00
6 changed files with 42 additions and 74 deletions
+18 -17
View File
@@ -1985,15 +1985,16 @@ view would leave a wallet one click from deletion.
`loadState()` refusing it, so the popup has no profile at all. While the popup `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 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 the stored record and runs the same check before writing, so the popup finds
it at the next navigation or ten-second refresh, whether or not the network it at the next navigation, or at the next ten-second balance refresh that
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)).
fails for any other reason, such as a storage read or write that errors, gets A save that fails for any other reason, such as a storage read or write that
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it errors, gets the "NOT SAVED" banner instead and leaves the screen as it is.
replaces is left as any navigation leaves it, so a revealed phrase or key, or The screen it replaces is left as any navigation leaves it, so a revealed
a typed password, is wiped. Once up, the screen stays until the popup closes phrase or key, or a typed password, is wiped. Once up, the screen stays until
or reloads: work still running in the popup, such as a transaction wait, the popup closes or the record is erased: work still running in the popup,
cannot replace it, even after the record is erased in another window. It is such as a transaction wait, cannot replace it, and nothing in AutistMask
the only screen that never appears during ordinary use. 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 - **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 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. internal error, and no reset or wipe control existed anywhere in the product.
@@ -2020,20 +2021,20 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this - **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that screen is up, because every screen behind it renders from the profile that
could not be read. `showView()` is not used to raise it, for the same reason: could not be read. At open `showView()` is not used to raise it for the same
it reads and writes the state singleton. Under an open popup the screen is reason — it reads and writes the state singleton. Under an open popup it is
passed to `showView()` only to run the replaced screen's cleanup; from then on also passed to `showView()`, which runs the replaced screen's cleanup and
`showView()` shows nothing else in that popup. records it as the current view, and `showView()` shows nothing else after
that.
- **Both controls are required.** An export with no reset leaves the user - **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses, material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there and for the same reason: there is no password to gate this with, since there
is no profile to check one against. is no profile to check one against.
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record - Not in `RESTORABLE_VIEWS`, and not persisted as the current view: at open
can become readable again under an open popup, erased in another window, and nothing on this path writes state at all, and under an open popup the check
the next save from that popup then succeeds. A popup opened after that shows that raised the screen refuses the save that would record it.
**Welcome** or **Home** as usual.
### External Services ### External Services
+8 -10
View File
@@ -49,16 +49,14 @@ but the review is broader than any of them.
unreadable moves to the recovery screen unreadable moves to the recovery screen
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
the last good profile, with the "NOT SAVED" banner at most, until reopened. the last good profile, with the "NOT SAVED" banner at most, until reopened.
Every save already ran the check the popup runs at open, so the popup finds Every save already ran the check the popup runs at open; a save that fails
the record at the next navigation or ten-second refresh, whether or not the that check now raises the recovery screen and stops the ten-second refresh, so
network answers; a save that fails that check now raises the recovery screen the popup finds the record at the next navigation or refresh. The screen it
and stops the refresh. The screen it replaces is left as any navigation leaves replaces is left as any navigation leaves it, so a revealed phrase or key or a
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing typed password is wiped. Once up, nothing else can replace it, and a later
else in that popup can replace it, and a later save or a transaction wait that save or a transaction wait that ends does not clear an export or a typed
ends does not clear an export or a typed confirmation. It is never saved as confirmation. Any other failed save still gets the banner and leaves the
the current view, so a popup opened after the record is erased in another screen alone.
window opens normally. Any other failed save still gets the banner and leaves
the screen alone.
- 2026-10-04: The signature screen shows a personal message as the bytes that - 2026-10-04: The signature screen shows a personal message as the bytes that
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
showed only the decoded text, with bidirectional and zero-width characters showed only the decoded text, with bidirectional and zero-width characters
+9 -9
View File
@@ -164,19 +164,19 @@ async function init() {
// check loadState() runs below. So a record that becomes unreadable while // 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 // 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 // ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery // (https://git.eeqj.de/sneak/AutistMask/issues/373). The recovery screen
// screen to showView() first leaves the current screen as any navigation // is then also passed to showView(), which runs the leave cleanup of the
// does, so a phrase, key or password on it is wiped, and from then on // screen it replaces, so a phrase, key or password on it is wiped, and
// showView() shows nothing else. A later save that fails the same way, // records it as the current view, after which showView() shows nothing
// such as a refresh already in flight, comes back here, where both calls // else. The save showView() fires fails too and comes back here, where
// see the screen already up and do nothing. Any other failed save is a // both calls see the screen already up and do nothing. Any other failed
// read or write that failed, and gets the banner without changing the // save is a read or write that failed, and gets the banner without
// screen. // changing the screen.
onSaveFailure((e) => { onSaveFailure((e) => {
if (e instanceof StateUnusableError) { if (e instanceof StateUnusableError) {
clearInterval(refreshTimer); clearInterval(refreshTimer);
showView("state-recovery");
stateRecovery.show(e); stateRecovery.show(e);
showView("state-recovery");
} else { } else {
showSaveFailureBanner(e); showSaveFailureBanner(e);
} }
+5 -14
View File
@@ -52,8 +52,10 @@ const VIEWS = [
"export-privkey", "export-privkey",
"show-phrase", "show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile // Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read, never by showView() (see there), but listed so that // cannot be read. At open it is not reached through showView(), since the
// every view-hiding loop covers it. // state singleton refuses to be read; under an open popup,
// src/popup/index.js also passes it to showView() so the screen it
// replaces is left like any other.
"state-recovery", "state-recovery",
]; ];
@@ -84,28 +86,17 @@ function hideError(id) {
el.style.visibility = "hidden"; el.style.visibility = "hidden";
} }
// Set when src/popup/index.js passes the recovery screen to showView(), and
// never cleared. Kept in memory for this popup's life, never in
// state.currentView, which is saved: a popup opened later must not inherit it.
let stateRecoveryShown = false;
function showView(name) { function showView(name) {
// The recovery screen, once up, is never replaced: work still running // The recovery screen, once up, is never replaced: work still running
// when it went up, such as a transaction wait, must not take the user off // when it went up, such as a transaction wait, must not take the user off
// it or clear what they exported or typed there // it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373). // (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (stateRecoveryShown) return; if (state.currentView === "state-recovery") return;
const leaving = state.currentView; const leaving = state.currentView;
if (leaving && leaving !== name) { if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving); const onLeave = viewLeaveHandlers.get(leaving);
if (onLeave) onLeave(); if (onLeave) onLeave();
} }
// Passed here only so the screen it replaces is left like any other;
// stateRecovery.show() raises it, and it is never the current view.
if (name === "state-recovery") {
stateRecoveryShown = true;
return;
}
for (const v of VIEWS) { for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`); const el = document.getElementById(`view-${v}`);
if (el) { if (el) {
+2 -1
View File
@@ -6,7 +6,8 @@
// the time this runs, the stored record has been REFUSED, deliberately: at // the time this runs, the stored record has been REFUSED, deliberately: at
// open loadState() refused it and reading the singleton throws // open loadState() refused it and reading the singleton throws
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup // (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373). // a save refused it, so every further save fails too
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
// //
// So this module talks to the DOM directly and touches no state at all. It is // So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it // the one screen that must work when nothing else can, which is also why it
-23
View File
@@ -195,29 +195,6 @@ describe("a popup already open when the stored profile becomes unreadable", () =
expect(env.value("state-recovery-reset-input")).toBe("erase my"); expect(env.value("state-recovery-reset-input")).toBe("erase my");
}); });
// The record can become readable again under this popup, erased from the
// recovery screen of another window, so a save from this one can succeed.
test("a popup opened after the record is erased elsewhere shows a screen", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
await env.storage.remove("autistmask");
const { saveState } = require("../src/shared/state");
await saveState();
const reopened = await bootPopup(env.storage.read("autistmask"));
expect(reopened.visibleViews()).toEqual(["welcome"]);
});
test("a stored current view of the recovery screen does not blank the popup", async () => {
const env = await bootPopup(
unversionedValidProfile({ currentView: "state-recovery" }),
);
expect(env.visibleViews()).toEqual(["main"]);
});
test("a transaction wait that ends under it does not replace it", async () => { test("a transaction wait that ends under it does not replace it", async () => {
const env = await bootPopup( const env = await bootPopup(
unversionedValidProfile({ unversionedValidProfile({