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 167 168 169 170 171 172 async function openPdfPicker() {173 if (autoPickPdf) {174 pdfFileInput?.click();175 return;176 }177 const canDetach =178 typeof chrome !== "undefined" &&179 typeof chrome.windows?.create === "function" &&180 typeof chrome.runtime?.getURL === "function";181 if (!canDetach) {182 pdfFileInput?.click();183 return;184 }185 try {186 187 188 189 190 const popupHtml =191 chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";192 await chrome.windows.create({193 url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),194 type: "popup",195 width: 460,196 height: 720,197 });198 window.close();199 } catch (err) {200 console.warn("[codex] failed to detach popup for PDF import:", err);201 pdfFileInput?.click();202 }203 }204205 async function handlePDFSelected(e: Event) {206 const input = e.currentTarget as HTMLInputElement;207 const file = input.files?.[0];208 209 input.value = "";210 if (!file) return;211212 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {213 setPdfError("please select a Peach Codex PDF file.");214 return;215 }216217 setPhase("pdf-import");218 setPdfProgress(null);219 setPdfError(null);220221 try {222 223 224 const { importCodexPDF } = await import("../codex/pdf-import");225 const result = await importCodexPDF(file, (page, total) => {226 setPdfProgress({ page, total });227 });228229 if (result.envelopes.length === 0) {230 setPdfError(231 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",232 );233 setPhase("choose");234 return;235 }236237 for (const envelope of result.envelopes) {238 await buffer.add(envelope);239 }240 refreshFromBuffer();241242 if (!buffer.isReady()) {243 244 setPdfError(245 `found ${buffer.size()}${246 result.totalExpected ? ` of ${result.totalExpected}` : ""247 } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,248 );249 await startCameraScan();250 }251 } catch (err) {252 setPdfError(253 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,254 );255 setPhase("choose");256 }257 }258259 260 261 262 263 264 const autoPickPdf = (() => {265 try {266 return new URLSearchParams(window.location.search).get("codex") === "pdf";267 } catch {268 return false;269 }270 })();271272 onMount(() => {273 if (autoPickPdf) {274 275 276 setTimeout(() => pdfFileInput?.click(), 50);277 }278 });279280 onCleanup(() => {281 if (scanner) {282 scanner.stop();283 scanner = null;284 }285 286 287 288 289 if (videoEl?.srcObject) {290 const stream = videoEl.srcObject as MediaStream;291 stream.getTracks().forEach((t) => t.stop());292 videoEl.srcObject = null;293 }294 });295296 function explainRestoreError(error: string | undefined): string {297 if (!error) return "restoration failed. please try again.";298 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))299 return "wrong master password. try again.";300 if (error.includes("INVALID_VAULT_BLOB"))301 return "the reconstructed vault is unreadable. please re-scan the Codex.";302 if (error.includes("FINGERPRINT_MISMATCH"))303 return "vault integrity check failed. please re-scan the Codex.";304 return error;305 }306307 async function handleRestore() {308 if (phase() === "restoring") return;309 const pw = passphrase().trim();310 if (!pw) {311 setRestoreError("enter your master password.");312 return;313 }314315 setPhase("restoring");316 setRestoreError(null);317318 let payload = pendingPayload();319 if (!payload) {320 try {321 payload = await reconstructAndVerify(buffer);322 setPendingPayload(payload);323 } catch (err) {324 const message =325 err instanceof Error326 ? err.message327 : "the scanned codes could not be reassembled.";328 setRestoreError(message);329 setPhase("ready");330 return;331 }332 }333334 const res = (await sendMessage({335 type: "RESTORE_FROM_CODEX",336 payload: {337 vaultBlob: payload.vaultBlob,338 timestamp: payload.timestamp,339 verifyCode: payload.verifyCode,340 vaultFingerprint: payload.vaultFingerprint,341 entryCount: payload.entryCount,342 masterPassphrase: pw,343 },344 })) as {345 ok: boolean;346 data?: { entryCount: number; verifyCode: string };347 error?: string;348 };349350 if (res.ok && res.data) {351 setRestoredCount(res.data.entryCount);352 setPhase("success");353 if (scanner) {354 scanner.stop();355 scanner = null;356 }357 setTimeout(() => props.onRestoreSuccess(), 1500);358 } else {359 setRestoreError(explainRestoreError(res.error));360 setPhase("ready");361 }362 }363364 function progressPercent(): number {365 const ds = dataShards();366 if (!ds || ds <= 0) return 0;367 return Math.min(100, Math.round((scanned() / ds) * 100));368 }369370 return (371 <div class="peach-overlay-view ov-enter-right" style="display:flex">372 <div class="peach-overlay-header">373 <button374 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"375 onClick={props.onBack}376 aria-label="Back"377 >378 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"379 stroke="currentColor" stroke-width="1.1"380 stroke-linecap="round" stroke-linejoin="round">381 <path d="M6 1L1 6L6 11" />382 </svg>383 </button>384 <span class="peach-overlay-title">restore from codex</span>385 <div class="peach-overlay-spacer"></div>386 </div>387388 <div class="peach-settings-body peach-codex-body">389 <Show when={phase() === "choose"}>390 <div class="peach-codex-restore-choose">391 <h2 class="peach-codex-restore-headline">restore from codex.</h2>392 <p class="peach-codex-restore-sub">393 how do you have your codex?394 </p>395396 <button397 class="peach-codex-restore-option"398 onClick={() => void startCameraScan()}399 >400 <div class="peach-codex-restore-option-icon">401 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"402 stroke="currentColor" stroke-width="1.5"403 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">404 <path d="M3 8a2 2 0 0 1 2-2h2l1.5-2h7L17 6h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8z" />405 <circle cx="12" cy="13" r="3.5" />406 </svg>407 </div>408 <div class="peach-codex-restore-option-content">409 <span class="peach-codex-restore-option-title">410 scan printed pages411 </span>412 <span class="peach-codex-restore-option-sub">413 point your camera at each code414 </span>415 </div>416 </button>417418 <button419 class="peach-codex-restore-option"420 onClick={() => void openPdfPicker()}421 >422 <div class="peach-codex-restore-option-icon">423 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"424 stroke="currentColor" stroke-width="1.5"425 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">426 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />427 <path d="M14 3v5h5" />428 <path d="M9 14h6" />429 <path d="M9 17h4" />430 </svg>431 </div>432 <div class="peach-codex-restore-option-content">433 <span class="peach-codex-restore-option-title">434 import PDF file435 </span>436 <span class="peach-codex-restore-option-sub">437 select your codex PDF from a USB drive or folder438 </span>439 </div>440 </button>441442 <input443 ref={(el) => (pdfFileInput = el)}444 type="file"445 accept=".pdf,application/pdf"446 style={{ display: "none" }}447 onChange={(e) => void handlePDFSelected(e)}448 />449450 <p class="peach-codex-restore-guidance">451 have both the printed pages and the PDF? the PDF import is452 faster and more reliable — use it if you can.453 </p>454455 <Show when={pdfError()}>456 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">457 {pdfError()}458 </p>459 </Show>460 </div>461 </Show>462463 <Show464 when={465 phase() === "scanning" ||466 phase() === "ready" ||467 phase() === "restoring"468 }469 >470 <div class="peach-codex-scanner">471 <video472 ref={(el) => (videoEl = el)}473 class="peach-codex-video"474 autoplay475 playsinline476 muted477 />478 <Show when={cameraError()}>479 <div class="peach-codex-camera-overlay">480 <p class="peach-hint peach-hint-error">{cameraError()}</p>481 </div>482 </Show>483 </div>484 </Show>485486 <Show when={phase() === "pdf-import"}>487 <div class="peach-settings-group peach-codex-pdf-import">488 <p class="peach-codex-restore-headline">reading your codex…</p>489 <Show when={pdfProgress()}>490 {(p) => (491 <>492 <div class="peach-codex-progress-track">493 <div494 class="peach-codex-progress-fill"495 style={{496 width: `${Math.round((p().page / p().total) * 100)}%`,497 }}498 />499 </div>500 <p class="peach-codex-progress-label">501 page {p().page} of {p().total}502 </p>503 </>504 )}505 </Show>506 </div>507 </Show>508509 <Show510 when={511 phase() === "scanning" ||512 phase() === "ready" ||513 phase() === "restoring"514 }515 >516 <div class="peach-settings-group peach-codex-progress-group">517 <div class="peach-codex-progress-track">518 <div519 class="peach-codex-progress-fill"520 style={{ width: `${progressPercent()}%` }}521 />522 </div>523 <p class="peach-codex-progress-label">524 <Show525 when={totalShards() !== null}526 fallback={<span>{scanned()} codes scanned</span>}527 >528 <span>529 {scanned()} / {dataShards()} codes530 </span>531 </Show>532 <Show when={verifyCode()}>533 <span class="peach-codex-verify-tag">534 {verifyCode()}535 </span>536 </Show>537 </p>538 <p class="peach-codex-hint">{hint()}</p>539 <Show when={scanWarning()}>540 <p class="peach-hint peach-hint--warning peach-codex-warning">541 {scanWarning()}542 </p>543 </Show>544 </div>545 </Show>546547 <Show when={phase() === "ready" || phase() === "restoring"}>548 <div class="peach-settings-group peach-codex-passphrase-group">549 <label class="peach-settings-label" for="codex-passphrase">550 master password551 </label>552 <input553 id="codex-passphrase"554 type="password"555 class="peach-underline-input"556 autocomplete="current-password"557 autocorrect="off"558 autocapitalize="none"559 spellcheck={false}560 disabled={phase() === "restoring"}561 value={passphrase()}562 onInput={(e) => {563 setPassphrase((e.target as HTMLInputElement).value);564 if (restoreError()) setRestoreError(null);565 }}566 onKeyDown={(e) => {567 if (e.key === "Enter" && phase() === "ready") {568 void handleRestore();569 }570 }}571 />572 <Show when={restoreError()}>573 <p class="peach-hint peach-hint-error">{restoreError()}</p>574 </Show>575 <button576 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"577 disabled={phase() === "restoring" || passphrase().length === 0}578 onClick={() => void handleRestore()}579 >580 {phase() === "restoring" ? "restoring…" : "restore vault"}581 </button>582 </div>583 </Show>584585 <Show when={phase() === "success"}>586 <div class="peach-settings-group peach-codex-done">587 <p class="peach-hint peach-hint--success">✓ vault restored</p>588 <p class="peach-codex-meta">589 {restoredCount()}{" "}590 {restoredCount() === 1 ? "entry" : "entries"} recovered.591 </p>592 <p class="peach-codex-footnote">593 your codex has been read and your vault is unlocked.594 </p>595 </div>596 </Show>597598 <Show when={phase() !== "success"}>599 <button600 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"601 onClick={props.onBack}602 >603 cancel604 </button>605 </Show>606 </div>607 </div>608 );609}