12345678910111213141516import { createSignal, onCleanup, onMount, Show } from "solid-js";17import { sendMessage } from "../../shared/bridge";18import {19 ChunkBuffer,20 parseRawScan,21 reconstructAndVerify,22} from "../codex/restore";23import { startScanner } from "../codex/scanner";24import type { ScannerHandle } from "../codex/scanner";25import type { CodexPayload } from "../../shared/codex-types";2627interface CodexRestoreViewProps {28 onBack: () => void;29 onRestoreSuccess: () => void;30}3132type Phase =33 | "choose"34 | "scanning"35 | "pdf-import"36 | "ready"37 | "restoring"38 | "success"39 | "error";4041export function CodexRestoreView(props: CodexRestoreViewProps) {42 43 44 45 46 const buffer = new ChunkBuffer();47 let videoEl: HTMLVideoElement | undefined;48 let scanner: ScannerHandle | null = null;49 let pdfFileInput: HTMLInputElement | undefined;5051 const [phase, setPhase] = createSignal<Phase>("choose");52 const [scanned, setScanned] = createSignal(0);53 const [totalShards, setTotalShards] = createSignal<number | null>(null);54 const [dataShards, setDataShards] = createSignal<number | null>(null);55 const [verifyCode, setVerifyCode] = createSignal<string | null>(null);56 const [hint, setHint] = createSignal(57 "point your camera at the first code on page 1",58 );59 const [cameraError, setCameraError] = createSignal<string | null>(null);60 const [scanWarning, setScanWarning] = createSignal<string | null>(null);61 const [pdfProgress, setPdfProgress] = createSignal<{62 page: number;63 total: number;64 } | null>(null);65 const [pdfError, setPdfError] = createSignal<string | null>(null);66 67 68 69 const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(70 null,71 );72 const [passphrase, setPassphrase] = createSignal("");73 const [restoreError, setRestoreError] = createSignal<string | null>(null);74 const [restoredCount, setRestoredCount] = createSignal<number | null>(null);7576 function refreshFromBuffer() {77 setScanned(buffer.size());78 const ctx = buffer.getContext();79 if (ctx) {80 setTotalShards(ctx.total);81 setDataShards(ctx.dataShards);82 setVerifyCode(ctx.verifyCode);83 }84 if (buffer.isReady() && (phase() === "scanning" || phase() === "pdf-import")) {85 setPhase("ready");86 setHint("scan complete. enter your master password to restore.");87 88 89 if (scanner) {90 scanner.stop();91 scanner = null;92 }93 } else if (phase() === "scanning") {94 const ds = ctx?.dataShards;95 if (ds) {96 const remaining = Math.max(ds - buffer.size(), 0);97 setHint(98 remaining > 099 ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`100 : "almost there…",101 );102 }103 }104 }105106 function explainScanReason(reason: string): string {107 switch (reason) {108 case "foreign_codex":109 return "that code belongs to a different Codex.";110 case "bad_integrity":111 return "a code's checksum failed — try again.";112 case "duplicate":113 return ""; 114 case "parse_error":115 return "could not read that code.";116 default:117 return "";118 }119 }120121 async function handleScan(raw: string) {122 const parsed = parseRawScan(raw);123 if ("error" in parsed) {124 125 126 return;127 }128129 const result = await buffer.add(parsed);130 refreshFromBuffer();131 if (!result.accepted && result.reason) {132 const msg = explainScanReason(result.reason);133 if (msg) {134 setScanWarning(msg);135 136 setTimeout(() => setScanWarning(null), 2500);137 }138 }139 }140141 function handleScannerError(err: Error) {142 setCameraError(143 err.name === "NotAllowedError"144 ? "camera access was denied. allow camera permission and reopen this screen."145 : err.message || "camera failed to start.",146 );147 }148149 async function startCameraScan() {150 setPhase("scanning");151 setCameraError(null);152 153 await Promise.resolve();154 if (!videoEl) return;155 try {156 scanner = await startScanner({157 videoEl,158 onScan: (text) => void handleScan(text),159 onError: (err) => handleScannerError(err),160 });161 } catch (err) {162 handleScannerError(err instanceof Error ? err : new Error(String(err)));163 }164 }165166 async function handlePDFSelected(e: Event) {167 const input = e.currentTarget as HTMLInputElement;168 const file = input.files?.[0];169 170 input.value = "";171 if (!file) return;172173 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {174 setPdfError("please select a Peach Codex PDF file.");175 return;176 }177178 setPhase("pdf-import");179 setPdfProgress(null);180 setPdfError(null);181182 try {183 184 185 const { importCodexPDF } = await import("../codex/pdf-import");186 const result = await importCodexPDF(file, (page, total) => {187 setPdfProgress({ page, total });188 });189190 if (result.envelopes.length === 0) {191 setPdfError(192 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",193 );194 setPhase("choose");195 return;196 }197198 for (const envelope of result.envelopes) {199 await buffer.add(envelope);200 }201 refreshFromBuffer();202203 if (!buffer.isReady()) {204 205 setPdfError(206 `found ${buffer.size()}${207 result.totalExpected ? ` of ${result.totalExpected}` : ""208 } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,209 );210 await startCameraScan();211 }212 } catch (err) {213 setPdfError(214 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,215 );216 setPhase("choose");217 }218 }219220 onMount(() => {221 222 223 });224225 onCleanup(() => {226 if (scanner) {227 scanner.stop();228 scanner = null;229 }230 231 232 233 234 if (videoEl?.srcObject) {235 const stream = videoEl.srcObject as MediaStream;236 stream.getTracks().forEach((t) => t.stop());237 videoEl.srcObject = null;238 }239 });240241 function explainRestoreError(error: string | undefined): string {242 if (!error) return "restoration failed. please try again.";243 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))244 return "wrong master password. try again.";245 if (error.includes("INVALID_VAULT_BLOB"))246 return "the reconstructed vault is unreadable. please re-scan the Codex.";247 if (error.includes("FINGERPRINT_MISMATCH"))248 return "vault integrity check failed. please re-scan the Codex.";249 return error;250 }251252 async function handleRestore() {253 if (phase() === "restoring") return;254 const pw = passphrase().trim();255 if (!pw) {256 setRestoreError("enter your master password.");257 return;258 }259260 setPhase("restoring");261 setRestoreError(null);262263 let payload = pendingPayload();264 if (!payload) {265 try {266 payload = await reconstructAndVerify(buffer);267 setPendingPayload(payload);268 } catch (err) {269 const message =270 err instanceof Error271 ? err.message272 : "the scanned codes could not be reassembled.";273 setRestoreError(message);274 setPhase("ready");275 return;276 }277 }278279 const res = (await sendMessage({280 type: "RESTORE_FROM_CODEX",281 payload: {282 vaultBlob: payload.vaultBlob,283 timestamp: payload.timestamp,284 verifyCode: payload.verifyCode,285 vaultFingerprint: payload.vaultFingerprint,286 entryCount: payload.entryCount,287 masterPassphrase: pw,288 },289 })) as {290 ok: boolean;291 data?: { entryCount: number; verifyCode: string };292 error?: string;293 };294295 if (res.ok && res.data) {296 setRestoredCount(res.data.entryCount);297 setPhase("success");298 if (scanner) {299 scanner.stop();300 scanner = null;301 }302 setTimeout(() => props.onRestoreSuccess(), 1500);303 } else {304 setRestoreError(explainRestoreError(res.error));305 setPhase("ready");306 }307 }308309 function progressPercent(): number {310 const ds = dataShards();311 if (!ds || ds <= 0) return 0;312 return Math.min(100, Math.round((scanned() / ds) * 100));313 }314315 return (316 <div class="peach-overlay-view ov-enter-right" style="display:flex">317 <div class="peach-overlay-header">318 <button319 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"320 onClick={props.onBack}321 aria-label="Back"322 >323 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"324 stroke="currentColor" stroke-width="1.1"325 stroke-linecap="round" stroke-linejoin="round">326 <path d="M6 1L1 6L6 11" />327 </svg>328 </button>329 <span class="peach-overlay-title">restore from codex</span>330 <div class="peach-overlay-spacer"></div>331 </div>332333 <div class="peach-settings-body peach-codex-body">334 <Show when={phase() === "choose"}>335 <div class="peach-codex-restore-choose">336 <h2 class="peach-codex-restore-headline">restore from codex.</h2>337 <p class="peach-codex-restore-sub">338 how do you have your codex?339 </p>340341 <button342 class="peach-codex-restore-option"343 onClick={() => void startCameraScan()}344 >345 <div class="peach-codex-restore-option-icon">📷</div>346 <div class="peach-codex-restore-option-content">347 <span class="peach-codex-restore-option-title">348 scan printed pages349 </span>350 <span class="peach-codex-restore-option-sub">351 point your camera at each code352 </span>353 </div>354 </button>355356 <button357 class="peach-codex-restore-option"358 onClick={() => pdfFileInput?.click()}359 >360 <div class="peach-codex-restore-option-icon">📄</div>361 <div class="peach-codex-restore-option-content">362 <span class="peach-codex-restore-option-title">363 import PDF file364 </span>365 <span class="peach-codex-restore-option-sub">366 select your codex PDF from a USB drive or folder367 </span>368 </div>369 </button>370371 <input372 ref={(el) => (pdfFileInput = el)}373 type="file"374 accept=".pdf,application/pdf"375 style={{ display: "none" }}376 onChange={(e) => void handlePDFSelected(e)}377 />378379 <p class="peach-codex-restore-guidance">380 have both the printed pages and the PDF? the PDF import is381 faster and more reliable — use it if you can.382 </p>383384 <Show when={pdfError()}>385 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">386 {pdfError()}387 </p>388 </Show>389 </div>390 </Show>391392 <Show393 when={394 phase() === "scanning" ||395 phase() === "ready" ||396 phase() === "restoring"397 }398 >399 <div class="peach-codex-scanner">400 <video401 ref={(el) => (videoEl = el)}402 class="peach-codex-video"403 autoplay404 playsinline405 muted406 />407 <Show when={cameraError()}>408 <div class="peach-codex-camera-overlay">409 <p class="peach-hint peach-hint-error">{cameraError()}</p>410 </div>411 </Show>412 </div>413 </Show>414415 <Show when={phase() === "pdf-import"}>416 <div class="peach-settings-group peach-codex-pdf-import">417 <p class="peach-codex-restore-headline">reading your codex…</p>418 <Show when={pdfProgress()}>419 {(p) => (420 <>421 <div class="peach-codex-progress-track">422 <div423 class="peach-codex-progress-fill"424 style={{425 width: `${Math.round((p().page / p().total) * 100)}%`,426 }}427 />428 </div>429 <p class="peach-codex-progress-label">430 page {p().page} of {p().total}431 </p>432 </>433 )}434 </Show>435 </div>436 </Show>437438 <Show439 when={440 phase() === "scanning" ||441 phase() === "ready" ||442 phase() === "restoring"443 }444 >445 <div class="peach-settings-group peach-codex-progress-group">446 <div class="peach-codex-progress-track">447 <div448 class="peach-codex-progress-fill"449 style={{ width: `${progressPercent()}%` }}450 />451 </div>452 <p class="peach-codex-progress-label">453 <Show454 when={totalShards() !== null}455 fallback={<span>{scanned()} codes scanned</span>}456 >457 <span>458 {scanned()} / {dataShards()} codes459 </span>460 </Show>461 <Show when={verifyCode()}>462 <span class="peach-codex-verify-tag">463 {verifyCode()}464 </span>465 </Show>466 </p>467 <p class="peach-codex-hint">{hint()}</p>468 <Show when={scanWarning()}>469 <p class="peach-hint peach-hint--warning peach-codex-warning">470 {scanWarning()}471 </p>472 </Show>473 </div>474 </Show>475476 <Show when={phase() === "ready" || phase() === "restoring"}>477 <div class="peach-settings-group peach-codex-passphrase-group">478 <label class="peach-settings-label" for="codex-passphrase">479 master password480 </label>481 <input482 id="codex-passphrase"483 type="password"484 class="peach-underline-input"485 autocomplete="current-password"486 autocorrect="off"487 autocapitalize="none"488 spellcheck={false}489 disabled={phase() === "restoring"}490 value={passphrase()}491 onInput={(e) => {492 setPassphrase((e.target as HTMLInputElement).value);493 if (restoreError()) setRestoreError(null);494 }}495 onKeyDown={(e) => {496 if (e.key === "Enter" && phase() === "ready") {497 void handleRestore();498 }499 }}500 />501 <Show when={restoreError()}>502 <p class="peach-hint peach-hint-error">{restoreError()}</p>503 </Show>504 <button505 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"506 disabled={phase() === "restoring" || passphrase().length === 0}507 onClick={() => void handleRestore()}508 >509 {phase() === "restoring" ? "restoring…" : "restore vault"}510 </button>511 </div>512 </Show>513514 <Show when={phase() === "success"}>515 <div class="peach-settings-group peach-codex-done">516 <p class="peach-hint peach-hint--success">✓ vault restored</p>517 <p class="peach-codex-meta">518 {restoredCount()}{" "}519 {restoredCount() === 1 ? "entry" : "entries"} recovered.520 </p>521 <p class="peach-codex-footnote">522 your codex has been read and your vault is unlocked.523 </p>524 </div>525 </Show>526527 <Show when={phase() !== "success"}>528 <button529 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"530 onClick={props.onBack}531 >532 cancel533 </button>534 </Show>535 </div>536 </div>537 );538}