Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 76c52f4ac6 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 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 runs the check loadState()
runs at open; a save refused by it now stops the ten-second refresh, runs the
leave cleanup of the current screen, and raises the recovery screen. From then
on showView() shows nothing else in that popup, so a transaction wait or a later
save cannot take the user off it or clear an export or a typed confirmation.
That is held in memory, never as the saved current view, so a popup opened
after the record is erased elsewhere opens normally. Any other failed save
keeps the "NOT SAVED" banner. The popup test harness now honours
clearInterval().

Model: opus-5-5
2026-10-04 21:06:21 +00:00
6 changed files with 74 additions and 42 deletions
+17 -18
View File
@@ -1985,16 +1985,15 @@ 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 at the next ten-second balance refresh that it at the next navigation or ten-second refresh, whether or not the network
reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
A save that fails for any other reason, such as a storage read or write that fails for any other reason, such as a storage read or write that errors, gets
errors, gets the "NOT SAVED" banner instead and leaves the screen as it is. the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
The screen it replaces is left as any navigation leaves it, so a revealed replaces is left as any navigation leaves it, so a revealed phrase or key, or
phrase or key, or a typed password, is wiped. Once up, the screen stays until a typed password, is wiped. Once up, the screen stays until the popup closes
the popup closes or the record is erased: work still running in the popup, or reloads: work still running in the popup, such as a transaction wait,
such as a transaction wait, cannot replace it, and nothing in AutistMask cannot replace it, even after the record is erased in another window. It is
writes over a record that fails the check, so it cannot become readable again the only screen that never appears during ordinary use.
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.
@@ -2021,20 +2020,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. At open `showView()` is not used to raise it for the same could not be read. `showView()` is not used to raise it, for the same reason:
reason — it reads and writes the state singleton. Under an open popup it is it reads and writes the state singleton. Under an open popup the screen is
also passed to `showView()`, which runs the replaced screen's cleanup and passed to `showView()` only to run the replaced screen's cleanup; from then on
records it as the current view, and `showView()` shows nothing else after `showView()` shows nothing else in that popup.
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 not persisted as the current view: at open - Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
nothing on this path writes state at all, and under an open popup the check can become readable again under an open popup, erased in another window, and
that raised the screen refuses the save that would record it. the next save from that popup then succeeds. A popup opened after that shows
**Welcome** or **Home** as usual.
### External Services ### External Services
+10 -8
View File
@@ -49,14 +49,16 @@ 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; a save that fails Every save already ran the check the popup runs at open, so the popup finds
that check now raises the recovery screen and stops the ten-second refresh, so the record at the next navigation or ten-second refresh, whether or not the
the popup finds the record at the next navigation or refresh. The screen it network answers; a save that fails that check now raises the recovery screen
replaces is left as any navigation leaves it, so a revealed phrase or key or a and stops the refresh. The screen it replaces is left as any navigation leaves
typed password is wiped. Once up, nothing else can replace it, and a later it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
save or a transaction wait that ends does not clear an export or a typed else in that popup can replace it, and a later save or a transaction wait that
confirmation. Any other failed save still gets the banner and leaves the ends does not clear an export or a typed confirmation. It is never saved as
screen alone. 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.
- 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). The recovery screen // (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
// is then also passed to showView(), which runs the leave cleanup of the // screen to showView() first leaves the current screen as any navigation
// screen it replaces, so a phrase, key or password on it is wiped, and // does, so a phrase, key or password on it is wiped, and from then on
// records it as the current view, after which showView() shows nothing // showView() shows nothing else. A later save that fails the same way,
// else. The save showView() fires fails too and comes back here, where // such as a refresh already in flight, comes back here, where both calls
// both calls see the screen already up and do nothing. Any other failed // see the screen already up and do nothing. Any other failed save is a
// save is a read or write that failed, and gets the banner without // read or write that failed, and gets the banner without changing the
// changing the screen. // screen.
onSaveFailure((e) => { onSaveFailure((e) => {
if (e instanceof StateUnusableError) { if (e instanceof StateUnusableError) {
clearInterval(refreshTimer); clearInterval(refreshTimer);
stateRecovery.show(e);
showView("state-recovery"); showView("state-recovery");
stateRecovery.show(e);
} else { } else {
showSaveFailureBanner(e); showSaveFailureBanner(e);
} }
+14 -5
View File
@@ -52,10 +52,8 @@ 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. At open it is not reached through showView(), since the // cannot be read, never by showView() (see there), but listed so that
// state singleton refuses to be read; under an open popup, // every view-hiding loop covers it.
// src/popup/index.js also passes it to showView() so the screen it
// replaces is left like any other.
"state-recovery", "state-recovery",
]; ];
@@ -86,17 +84,28 @@ 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 (state.currentView === "state-recovery") return; if (stateRecoveryShown) 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) {
+1 -2
View File
@@ -6,8 +6,7 @@
// 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, so every further save fails too // a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
// (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,6 +195,29 @@ 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({