// 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, loadState() has REFUSED, deliberately, and reading the // singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311). // // 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) { 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 };