Files
AutistMask/src/popup/views/stateRecovery.js
T
clawbot 2b97aae04a
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
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-05 00:09:06 +02:00

205 lines
8.4 KiB
JavaScript

// The screen the popup shows when it cannot read the stored profile.
//
// Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by
// 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).
//
// 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
// takes no ctx and needs no init(): whatever the rest of the popup did or did
// not manage to wire up, this shows.
//
// Two controls, and both are required. An export with no reset leaves the user
// looking at their 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 key material
// a later build could read. So the export is offered first, in the page where
// it cannot fail, and the reset is behind a typed confirmation.
// $ and VIEWS only: nothing else in helpers is safe here, since showView() and
// everything under it read the state singleton. $ is taken from there rather
// than written again locally so that tests/popupElementIds.test.js sees these
// lookups and holds every id below against the markup.
const { $, VIEWS } = require("./helpers");
const { storageGet, storageRemove } = require("../../shared/browserApi");
const { log } = require("../../shared/log");
// Typed in full before anything is erased, in the same spirit as the wallet
// name on DeleteWalletLostPassword: this button destroys key material and
// there is no password in front of it, because there is no profile to check a
// password against. Compared case-insensitively — the phrase is the barrier,
// not the shift key.
const RESET_PHRASE = "ERASE MY WALLET";
let wired = false;
function setFlash(message) {
const node = $("state-recovery-flash");
node.textContent = message;
node.style.visibility = message ? "visible" : "hidden";
}
// The stored record exactly as storage hands it back, however malformed, with
// no normalization, no defaulting and no repair on it: this is evidence, and
// the point of the export is that a later build (or a human) sees what is
// actually there. It is not the raw bytes — storage deserializes, and
// exportRecord() re-serializes with JSON.stringify — so a value JSON cannot
// represent is the one thing that does not survive the trip. See there.
async function rawRecord() {
const result = await storageGet("autistmask");
return result.autistmask;
}
// Best effort, and never the only route. A download from an extension popup
// depends on the browser, the popup staying open long enough, and the
// extension's content security policy; the textarea below depends on none of
// those, and is filled first.
function offerDownload(text) {
try {
if (
typeof Blob !== "function" ||
typeof URL === "undefined" ||
typeof URL.createObjectURL !== "function"
) {
return false;
}
const url = URL.createObjectURL(
new Blob([text], { type: "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = "autistmask-saved-data.json";
link.click();
// Revoked in a later task, not in this one: the download is started
// from the click and revoking the URL in the same turn can cancel it
// before it has been read. If the popup closes first the URL dies with
// the document anyway.
if (typeof URL.revokeObjectURL === "function") {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
return true;
} catch (e) {
log.errorf("state recovery: download failed:", e);
return false;
}
}
// Residual, stated rather than left to be discovered: structured-clone storage
// holds values JSON does not have, and no build here writes one, but the export
// is a funds-recovery path and what it cannot carry has to be written down.
//
// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in
// the catch below, so the export fails entirely and erase is the only control
// left on the screen.
//
// Silent, and the worse of the two, because the box then looks complete:
// a Date becomes its ISO string, a Map or a Set becomes {}, a property whose
// value is undefined is dropped from the output entirely, and NaN and
// ±Infinity become null. Nothing here can serialize any of it faithfully;
// recovering such a record needs the browser's own storage inspector.
async function exportRecord() {
let text;
try {
const record = await rawRecord();
text = JSON.stringify(record === undefined ? null : record, null, 2);
} catch (e) {
log.errorf("state recovery: export failed:", e);
setFlash(
"The saved data could not be read out of storage. Nothing has" +
" been changed.",
);
return;
}
// JSON.stringify answers undefined for a value it cannot represent, and
// an empty box would read as "there was nothing there".
if (typeof text !== "string") text = String(text);
const box = $("state-recovery-blob");
box.value = text;
box.classList.remove("hidden");
const downloaded = offerDownload(text);
setFlash(
downloaded
? "Saved data downloaded, and shown below. Keep a copy before" +
" erasing anything."
: "Saved data shown below. Copy it and keep it before erasing" +
" anything.",
);
}
async function resetProfile() {
const typed = $("state-recovery-reset-input").value || "";
if (typed.trim().toUpperCase() !== RESET_PHRASE) {
setFlash("Type " + RESET_PHRASE + " to confirm. Nothing was erased.");
return;
}
try {
await storageRemove("autistmask");
} catch (e) {
log.errorf("state recovery: reset failed:", e);
setFlash("The saved data could not be erased. Nothing was changed.");
return;
}
setFlash("Saved data erased. AutistMask is starting fresh.");
// Back to a first run, which is what the wallet now is. A popup that
// cannot reload says so rather than sitting on a screen describing a
// profile that no longer exists.
if (
typeof window !== "undefined" &&
window.location &&
typeof window.location.reload === "function"
) {
window.location.reload();
return;
}
setFlash("Saved data erased. Close and reopen AutistMask.");
}
function wire() {
if (wired) return;
wired = true;
$("btn-state-recovery-export").addEventListener("click", exportRecord);
$("btn-state-recovery-reset").addEventListener("click", resetProfile);
}
/**
* Show the recovery screen, naming `problem`.
*
* @param {Error|string} problem the StateUnusableError from the read that
* refused, or its sentence.
*/
function show(problem) {
// Already up: a later save that trips over the same record, such as a
// refresh that was in flight when the screen went up, must not clear what
// the user has exported or typed here.
if (!$("view-state-recovery").classList.contains("hidden")) return;
const sentence =
(problem && (problem.problem || problem.message)) || String(problem);
// Not showView(): that reads and writes the singleton this screen exists
// because nothing could load.
for (const view of VIEWS) {
const node = document.getElementById("view-" + view);
if (node) node.classList.add("hidden");
}
// The one global control, and it leads to a screen that renders from the
// profile. There is nowhere to go from here but out.
const gear = $("btn-settings");
if (gear) gear.classList.add("hidden");
$("state-recovery-problem").textContent = sentence;
$("state-recovery-blob").value = "";
$("state-recovery-blob").classList.add("hidden");
$("state-recovery-reset-input").value = "";
setFlash("");
wire();
$("view-state-recovery").classList.remove("hidden");
log.errorf("state is unusable, showing the recovery screen:", sentence);
}
module.exports = { show, RESET_PHRASE };