import { createSignal, createMemo, For, onCleanup, onMount, Show } from "solid-js"; import { sendMessage } from "../App"; import { PeachWordmark } from "../components/PeachMark"; import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground"; interface RecoveryWordDisplayViewProps { onDone: () => void; } // Three random positions are confirmed each time this screen mounts, so a // user can't get away with only memorizing a fixed set of slots. function pickConfirmIndices(): number[] { const pool = Array.from({ length: 12 }, (_, i) => i); const rand = new Uint32Array(pool.length); crypto.getRandomValues(rand); for (let i = pool.length - 1; i > 0; i--) { const j = (rand[i] ?? 0) % (i + 1); const tmp = pool[i]!; pool[i] = pool[j]!; pool[j] = tmp; } return pool.slice(0, 3).sort((a, b) => a - b); } export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) { // Chosen once per mount; the labels and the validation must agree. const confirmIndices = pickConfirmIndices(); const [words, setWords] = createSignal([]); const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(true); const [confirmWords, setConfirmWords] = createSignal(["", "", ""]); const [confirmError, setConfirmError] = createSignal(""); const [confirmed, setConfirmed] = createSignal(false); // 5-second countdown before confirmation input is enabled const [countdown, setCountdown] = createSignal(5); let countdownTimer: ReturnType | null = null; onMount(async () => { await refreshWords(); // Start 5-second countdown let remaining = 5; countdownTimer = setInterval(() => { remaining -= 1; setCountdown(remaining); if (remaining <= 0 && countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } }, 1000); }); async function refreshWords(): Promise { // Fetch the recovery words from the background (vault must be unlocked). const res = (await sendMessage({ type: "GET_RECOVERY_WORDS", })) as { ok: boolean; data?: string[]; error?: string }; if (res.ok && res.data) { setWords(res.data); } else { setError("could not retrieve recovery words"); } setLoading(false); } // Screenshot hygiene: blank the words whenever the window is backgrounded, // so an OS screenshot of the hidden popup never captures them. They are // re-fetched when the user returns (the recovery-pending window still // exempts the fetch from password re-auth). const handleVisibilityChange = () => { if (document.visibilityState === "hidden") { setWords([]); } else if (!confirmed()) { void refreshWords(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); onCleanup(() => { if (countdownTimer) clearInterval(countdownTimer); document.removeEventListener("visibilitychange", handleVisibilityChange); }); async function handleConfirm() { const confirmed = confirmWords().map((w) => w.toLowerCase().trim()); if (confirmed.some((w) => !w)) { setConfirmError("all three words are required"); return; } // Verify against stored words const stored = words(); for (let i = 0; i < confirmIndices.length; i++) { const pos = confirmIndices[i]!; if (confirmed[i] !== stored[pos]) { setConfirmError(`word ${pos + 1} is incorrect`); return; } } setConfirmed(true); setConfirmError(""); // Clear the background-owned recovery-pending state so lock-on-close / // auto-lock resume // their normal behavior and future unlocks no longer re-route here. // Once the user has confirmed the words, losing the in-memory key is // no longer a data-loss event. await sendMessage({ type: "CLEAR_RECOVERY_CONFIRM_PENDING" }); // Small pause so user sees the success state, then proceed. await new Promise((r) => setTimeout(r, 600)); props.onDone(); } const canConfirm = createMemo(() => countdown() <= 0 && !confirmed()); return (

save your recovery words

write these down — you can view them again in Settings with your master password

loading...

{error()}

your 12 recovery words
    {(word, i) => (
  1. {i() + 1} {word}
  2. )}
{/* Confirmation step */}

{countdown() > 0 ? `type the highlighted words to confirm (${countdown()}s)` : "type the highlighted words to confirm"}

✓ words verified

{(pos, i) => (
word {pos + 1} { const updated = [...confirmWords()]; updated[i()] = (e.target as HTMLInputElement).value; setConfirmWords(updated); if (confirmError()) setConfirmError(""); }} onKeyDown={(e) => { if (e.key === "Enter" && canConfirm()) void handleConfirm(); }} />
)}

{confirmError()}

); } function recoveryWordsText(words: string[]): string { const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n"); return ( "PEACH RECOVERY WORDS\n" + "====================\n\n" + "Anyone with these 12 words can recover your vault.\n" + "Store this somewhere only you can access.\n\n" + numbered + "\n" ); } // Desktop only — writes via a blob to a user-chosen save location. The native // path deliberately does NOT use a file download: on Android the data:-URL // download sink writes to world-readable public Downloads (audit MEDIUM), so // native offers a clipboard copy (copyRecoveryWords) the user routes instead. function saveRecoveryWords(words: string[]): void { const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" }); const a = document.createElement("a"); a.download = "peach-recovery-words.txt"; a.href = URL.createObjectURL(blob); setTimeout(() => URL.revokeObjectURL(a.href), 1000); document.body.appendChild(a); a.click(); a.remove(); }