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
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 ten-second refresh, whether or not the network
answers ([#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. The screen it
replaces is left as any navigation leaves it, so a revealed phrase or key, or
a typed password, is wiped. Once up, the screen stays until the popup closes
or reloads: work still running in the popup, such as a transaction wait,
cannot replace it, even after the record is erased in another window. It is
the only screen that never appears during ordinary use.
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.
The screen it replaces is left as any navigation leaves it, so a revealed
phrase or key, or a typed password, is wiped. Once up, the screen stays until
the popup closes or the record is erased: work still running in the popup,
such as a transaction wait, cannot replace it, and 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.
@@ -2020,20 +2021,20 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line
- **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
could not be read. `showView()` is not used to raise it, for the same reason:
it reads and writes the state singleton. Under an open popup the screen is
passed to `showView()` only to run the replaced screen's cleanup; from then on
`showView()` shows nothing else in that popup.
could not be read. At open `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton. Under an open popup it is
also passed to `showView()`, which runs the replaced screen's cleanup and
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
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
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
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
can become readable again under an open popup, erased in another window, and
the next save from that popup then succeeds. A popup opened after that shows
**Welcome** or **Home** as usual.
- Not in `RESTORABLE_VIEWS`, and not persisted as the current view: at open
nothing on this path writes state at all, and under an open popup the check
that raised the screen refuses the save that would record it.
### 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
([#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.
Every save already ran the check the popup runs at open, so the popup finds
the record at the next navigation or ten-second refresh, whether or not the
network answers; a save that fails that check now raises the recovery screen
and stops the refresh. The screen it replaces is left as any navigation leaves
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
else in that popup can replace it, and a later save or a transaction wait that
ends does not clear an export or a typed confirmation. It is never saved as
the current view, so a popup opened after the record is erased in another
window opens normally. Any other failed save still gets the banner and leaves
the screen alone.
Every save already ran the check the popup runs at open; a save that fails
that check now raises the recovery screen and stops the ten-second refresh, so
the popup finds the record at the next navigation or refresh. The screen it
replaces is left as any navigation leaves it, so a revealed phrase or key or a
typed password is wiped. Once up, nothing else can replace it, and a later
save or a transaction wait that ends does not clear an export or a typed
confirmation. 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
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
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
// 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.
// (https://git.eeqj.de/sneak/AutistMask/issues/373). The recovery screen
// is then also passed to showView(), which runs the leave cleanup of the
// screen it replaces, so a phrase, key or password on it is wiped, and
// records it as the current view, after which showView() shows nothing
// else. The save showView() fires fails too and 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);
showView("state-recovery");
} else {
showSaveFailureBanner(e);
}
+5 -14
View File
@@ -52,8 +52,10 @@ const VIEWS = [
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read, never by showView() (see there), but listed so that
// every view-hiding loop covers it.
// cannot be read. At open it is not reached through showView(), since the
// 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",
];
@@ -84,28 +86,17 @@ function hideError(id) {
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) {
// 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
// it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (stateRecoveryShown) return;
if (state.currentView === "state-recovery") return;
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
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) {
const el = document.getElementById(`view-${v}`);
if (el) {
+2 -1
View File
@@ -6,7 +6,8 @@
// the time this runs, the stored record has been REFUSED, deliberately: at
// open loadState() refused it and reading the singleton throws
// (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
// 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");
});
// 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 () => {
const env = await bootPopup(
unversionedValidProfile({