1import { createSignal, createMemo, For, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { PeachWordmark } from "../components/PeachMark";4import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";56interface RecoveryWordDisplayViewProps {7 onDone: () => void;8}9101112function pickConfirmIndices(): number[] {13 const pool = Array.from({ length: 12 }, (_, i) => i);14 const rand = new Uint32Array(pool.length);15 crypto.getRandomValues(rand);16 for (let i = pool.length - 1; i > 0; i--) {17 const j = (rand[i] ?? 0) % (i + 1);18 const tmp = pool[i]!;19 pool[i] = pool[j]!;20 pool[j] = tmp;21 }22 return pool.slice(0, 3).sort((a, b) => a - b);23}2425export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {26 27 const confirmIndices = pickConfirmIndices();28 const [words, setWords] = createSignal<string[]>([]);29 const [error, setError] = createSignal("");30 const [loading, setLoading] = createSignal(true);31 const [confirmWords, setConfirmWords] = createSignal(["", "", ""]);32 const [confirmError, setConfirmError] = createSignal("");33 const [confirmed, setConfirmed] = createSignal(false);34 35 const [countdown, setCountdown] = createSignal(5);36 let countdownTimer: ReturnType<typeof setInterval> | null = null;3738 onMount(async () => {39 await refreshWords();4041 42 let remaining = 5;43 countdownTimer = setInterval(() => {44 remaining -= 1;45 setCountdown(remaining);46 if (remaining <= 0 && countdownTimer) {47 clearInterval(countdownTimer);48 countdownTimer = null;49 }50 }, 1000);51 });5253 async function refreshWords(): Promise<void> {54 55 const res = (await sendMessage({56 type: "GET_RECOVERY_WORDS",57 })) as { ok: boolean; data?: string[]; error?: string };5859 if (res.ok && res.data) {60 setWords(res.data);61 } else {62 setError("could not retrieve recovery words");63 }64 setLoading(false);65 }6667 68 69 70 71 const handleVisibilityChange = () => {72 if (document.visibilityState === "hidden") {73 setWords([]);74 } else if (!confirmed()) {75 void refreshWords();76 }77 };78 document.addEventListener("visibilitychange", handleVisibilityChange);7980 onCleanup(() => {81 if (countdownTimer) clearInterval(countdownTimer);82 document.removeEventListener("visibilitychange", handleVisibilityChange);83 });8485 async function handleConfirm() {86 const confirmed = confirmWords().map((w) => w.toLowerCase().trim());87 if (confirmed.some((w) => !w)) {88 setConfirmError("all three words are required");89 return;90 }9192 93 const stored = words();94 for (let i = 0; i < confirmIndices.length; i++) {95 const pos = confirmIndices[i]!;96 if (confirmed[i] !== stored[pos]) {97 setConfirmError(`word ${pos + 1} is incorrect`);98 return;99 }100 }101102 setConfirmed(true);103 setConfirmError("");104 105 106 107 108 109 await sendMessage({ type: "CLEAR_RECOVERY_CONFIRM_PENDING" });110 111 await new Promise((r) => setTimeout(r, 600));112 props.onDone();113 }114115 const canConfirm = createMemo(() => countdown() <= 0 && !confirmed());116117 return (118 <section class="peach-screen peach-theme lock-screen lock-screen--recovery lock-screen--recovery-display">119 <RecoveryRuneBackground />120121 <header class="peach-screen-header">122 <div class="peach-wordmark-stack">123 <PeachWordmark class="peach-wordmark-wrap" />124 </div>125 </header>126127 <div class="peach-recovery-header">128 <h1 class="peach-onboarding-headline">save your recovery words</h1>129 <p class="peach-hint peach-hint--warning">130 write these down — you can view them again in Settings with your master password131 </p>132 </div>133134 <Show when={loading()}>135 <p class="peach-hint">loading...</p>136 </Show>137138 <Show when={error()}>139 <p class="peach-hint peach-hint-error">{error()}</p>140 </Show>141142 <Show when={!loading() && !error()}>143 <div class="peach-recovery-words-display">144 <div class="peach-recovery-words-label">your 12 recovery words</div>145 <ol class="peach-recovery-word-list" start={1}>146 <For each={words()}>147 {(word, i) => (148 <li class="peach-recovery-word-item">149 <span class="peach-recovery-word-num">{i() + 1}</span>150 <span class="peach-recovery-word-text">{word}</span>151 </li>152 )}153 </For>154 </ol>155 <button156 type="button"157 class="peach-recovery-download-btn"158 onClick={() => void saveRecoveryWords(words())}159 >160 download as .txt161 </button>162 </div>163164 {}165 <div class="peach-recovery-confirm">166 <Show when={!confirmed()}>167 <p class="peach-recovery-confirm-label">168 {countdown() > 0169 ? `type the highlighted words to confirm (${countdown()}s)`170 : "type the highlighted words to confirm"}171 </p>172 </Show>173 <Show when={confirmed()}>174 <p class="peach-hint peach-hint--success">✓ words verified</p>175 </Show>176177 <div class="peach-recovery-confirm-fields">178 <For each={confirmIndices}>179 {(pos, i) => (180 <div class="peach-recovery-confirm-field">181 <span class="peach-recovery-confirm-num">182 word {pos + 1}183 </span>184 <input185 class="peach-recovery-word-input peach-recovery-confirm-input"186 type="text"187 autocomplete="off"188 autocorrect="off"189 autocapitalize="none"190 spellcheck={false}191 disabled={!canConfirm()}192 value={confirmWords()[i()]}193 onInput={(e) => {194 const updated = [...confirmWords()];195 updated[i()] = (e.target as HTMLInputElement).value;196 setConfirmWords(updated);197 if (confirmError()) setConfirmError("");198 }}199 onKeyDown={(e) => {200 if (e.key === "Enter" && canConfirm()) void handleConfirm();201 }}202 />203 </div>204 )}205 </For>206 </div>207208 <Show when={confirmError()}>209 <p class="peach-hint peach-hint-error">{confirmError()}</p>210 </Show>211212 <button213 class="peach-btn peach-btn--accent peach-btn--md peach-recovery-confirm-btn"214 disabled={!canConfirm() || confirmed()}215 onClick={() => void handleConfirm()}216 >217 {confirmed() ? "verified ✓" : countdown() > 0 ? `wait ${countdown()}s` : "i wrote them down"}218 </button>219 </div>220 </Show>221 </section>222 );223}224225function recoveryWordsText(words: string[]): string {226 const numbered = words.map((w, i) => `${String(i + 1).padStart(2, " ")}. ${w}`).join("\n");227 return (228 "PEACH RECOVERY WORDS\n" +229 "====================\n\n" +230 "Anyone with these 12 words can recover your vault.\n" +231 "Store this somewhere only you can access.\n\n" +232 numbered + "\n"233 );234}235236237238239240function saveRecoveryWords(words: string[]): void {241 const blob = new Blob([recoveryWordsText(words)], { type: "text/plain" });242 const a = document.createElement("a");243 a.download = "peach-recovery-words.txt";244 a.href = URL.createObjectURL(blob);245 setTimeout(() => URL.revokeObjectURL(a.href), 1000);246 document.body.appendChild(a);247 a.click();248 a.remove();249}