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 await chrome.windows.create({187 url: chrome.runtime.getURL("popup.html?codex=pdf"),188 type: "popup",189 width: 460,190 height: 720,191 });192 window.close();193 } catch (err) {194 console.warn("[codex] failed to detach popup for PDF import:", err);195 pdfFileInput?.click();196 }197 }198199 async function handlePDFSelected(e: Event) {200 const input = e.currentTarget as HTMLInputElement;201 const file = input.files?.[0];202 203 input.value = "";204 if (!file) return;205206 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {207 setPdfError("please select a Peach Codex PDF file.");208 return;209 }210211 setPhase("pdf-import");212 setPdfProgress(null);213 setPdfError(null);214215 try {216 217 218 const { importCodexPDF } = await import("../codex/pdf-import");219 const result = await importCodexPDF(file, (page, total) => {220 setPdfProgress({ page, total });221 });222223 if (result.envelopes.length === 0) {224 setPdfError(225 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",226 );227 setPhase("choose");228 return;229 }230231 for (const envelope of result.envelopes) {232 await buffer.add(envelope);233 }234 refreshFromBuffer();235236 if (!buffer.isReady()) {237 238 setPdfError(239 `found ${buffer.size()}${240 result.totalExpected ? ` of ${result.totalExpected}` : ""241 } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,242 );243 await startCameraScan();244 }245 } catch (err) {246 setPdfError(247 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,248 );249 setPhase("choose");250 }251 }252253 254 255 256 257 258 const autoPickPdf = (() => {259 try {260 return new URLSearchParams(window.location.search).get("codex") === "pdf";261 } catch {262 return false;263 }264 })();265266 onMount(() => {267 if (autoPickPdf) {268 269 270 setTimeout(() => pdfFileInput?.click(), 50);271 }272 });273274 onCleanup(() => {275 if (scanner) {276 scanner.stop();277 scanner = null;278 }279 280 281 282 283 if (videoEl?.srcObject) {284 const stream = videoEl.srcObject as MediaStream;285 stream.getTracks().forEach((t) => t.stop());286 videoEl.srcObject = null;287 }288 });289290 function explainRestoreError(error: string | undefined): string {291 if (!error) return "restoration failed. please try again.";292 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))293 return "wrong master password. try again.";294 if (error.includes("INVALID_VAULT_BLOB"))295 return "the reconstructed vault is unreadable. please re-scan the Codex.";296 if (error.includes("FINGERPRINT_MISMATCH"))297 return "vault integrity check failed. please re-scan the Codex.";298 return error;299 }300301 async function handleRestore() {302 if (phase() === "restoring") return;303 const pw = passphrase().trim();304 if (!pw) {305 setRestoreError("enter your master password.");306 return;307 }308309 setPhase("restoring");310 setRestoreError(null);311312 let payload = pendingPayload();313 if (!payload) {314 try {315 payload = await reconstructAndVerify(buffer);316 setPendingPayload(payload);317 } catch (err) {318 const message =319 err instanceof Error320 ? err.message321 : "the scanned codes could not be reassembled.";322 setRestoreError(message);323 setPhase("ready");324 return;325 }326 }327328 const res = (await sendMessage({329 type: "RESTORE_FROM_CODEX",330 payload: {331 vaultBlob: payload.vaultBlob,332 timestamp: payload.timestamp,333 verifyCode: payload.verifyCode,334 vaultFingerprint: payload.vaultFingerprint,335 entryCount: payload.entryCount,336 masterPassphrase: pw,337 },338 })) as {339 ok: boolean;340 data?: { entryCount: number; verifyCode: string };341 error?: string;342 };343344 if (res.ok && res.data) {345 setRestoredCount(res.data.entryCount);346 setPhase("success");347 if (scanner) {348 scanner.stop();349 scanner = null;350 }351 setTimeout(() => props.onRestoreSuccess(), 1500);352 } else {353 setRestoreError(explainRestoreError(res.error));354 setPhase("ready");355 }356 }357358 function progressPercent(): number {359 const ds = dataShards();360 if (!ds || ds <= 0) return 0;361 return Math.min(100, Math.round((scanned() / ds) * 100));362 }363364 return (365 <div class="peach-overlay-view ov-enter-right" style="display:flex">366 <div class="peach-overlay-header">367 <button368 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"369 onClick={props.onBack}370 aria-label="Back"371 >372 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"373 stroke="currentColor" stroke-width="1.1"374 stroke-linecap="round" stroke-linejoin="round">375 <path d="M6 1L1 6L6 11" />376 </svg>377 </button>378 <span class="peach-overlay-title">restore from codex</span>379 <div class="peach-overlay-spacer"></div>380 </div>381382 <div class="peach-settings-body peach-codex-body">383 <Show when={phase() === "choose"}>384 <div class="peach-codex-restore-choose">385 <h2 class="peach-codex-restore-headline">restore from codex.</h2>386 <p class="peach-codex-restore-sub">387 how do you have your codex?388 </p>389390 <button391 class="peach-codex-restore-option"392 onClick={() => void startCameraScan()}393 >394 <div class="peach-codex-restore-option-icon">395 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"396 stroke="currentColor" stroke-width="1.5"397 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">398 <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" />399 <circle cx="12" cy="13" r="3.5" />400 </svg>401 </div>402 <div class="peach-codex-restore-option-content">403 <span class="peach-codex-restore-option-title">404 scan printed pages405 </span>406 <span class="peach-codex-restore-option-sub">407 point your camera at each code408 </span>409 </div>410 </button>411412 <button413 class="peach-codex-restore-option"414 onClick={() => void openPdfPicker()}415 >416 <div class="peach-codex-restore-option-icon">417 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"418 stroke="currentColor" stroke-width="1.5"419 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">420 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />421 <path d="M14 3v5h5" />422 <path d="M9 14h6" />423 <path d="M9 17h4" />424 </svg>425 </div>426 <div class="peach-codex-restore-option-content">427 <span class="peach-codex-restore-option-title">428 import PDF file429 </span>430 <span class="peach-codex-restore-option-sub">431 select your codex PDF from a USB drive or folder432 </span>433 </div>434 </button>435436 <input437 ref={(el) => (pdfFileInput = el)}438 type="file"439 accept=".pdf,application/pdf"440 style={{ display: "none" }}441 onChange={(e) => void handlePDFSelected(e)}442 />443444 <p class="peach-codex-restore-guidance">445 have both the printed pages and the PDF? the PDF import is446 faster and more reliable — use it if you can.447 </p>448449 <Show when={pdfError()}>450 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">451 {pdfError()}452 </p>453 </Show>454 </div>455 </Show>456457 <Show458 when={459 phase() === "scanning" ||460 phase() === "ready" ||461 phase() === "restoring"462 }463 >464 <div class="peach-codex-scanner">465 <video466 ref={(el) => (videoEl = el)}467 class="peach-codex-video"468 autoplay469 playsinline470 muted471 />472 <Show when={cameraError()}>473 <div class="peach-codex-camera-overlay">474 <p class="peach-hint peach-hint-error">{cameraError()}</p>475 </div>476 </Show>477 </div>478 </Show>479480 <Show when={phase() === "pdf-import"}>481 <div class="peach-settings-group peach-codex-pdf-import">482 <p class="peach-codex-restore-headline">reading your codex…</p>483 <Show when={pdfProgress()}>484 {(p) => (485 <>486 <div class="peach-codex-progress-track">487 <div488 class="peach-codex-progress-fill"489 style={{490 width: `${Math.round((p().page / p().total) * 100)}%`,491 }}492 />493 </div>494 <p class="peach-codex-progress-label">495 page {p().page} of {p().total}496 </p>497 </>498 )}499 </Show>500 </div>501 </Show>502503 <Show504 when={505 phase() === "scanning" ||506 phase() === "ready" ||507 phase() === "restoring"508 }509 >510 <div class="peach-settings-group peach-codex-progress-group">511 <div class="peach-codex-progress-track">512 <div513 class="peach-codex-progress-fill"514 style={{ width: `${progressPercent()}%` }}515 />516 </div>517 <p class="peach-codex-progress-label">518 <Show519 when={totalShards() !== null}520 fallback={<span>{scanned()} codes scanned</span>}521 >522 <span>523 {scanned()} / {dataShards()} codes524 </span>525 </Show>526 <Show when={verifyCode()}>527 <span class="peach-codex-verify-tag">528 {verifyCode()}529 </span>530 </Show>531 </p>532 <p class="peach-codex-hint">{hint()}</p>533 <Show when={scanWarning()}>534 <p class="peach-hint peach-hint--warning peach-codex-warning">535 {scanWarning()}536 </p>537 </Show>538 </div>539 </Show>540541 <Show when={phase() === "ready" || phase() === "restoring"}>542 <div class="peach-settings-group peach-codex-passphrase-group">543 <label class="peach-settings-label" for="codex-passphrase">544 master password545 </label>546 <input547 id="codex-passphrase"548 type="password"549 class="peach-underline-input"550 autocomplete="current-password"551 autocorrect="off"552 autocapitalize="none"553 spellcheck={false}554 disabled={phase() === "restoring"}555 value={passphrase()}556 onInput={(e) => {557 setPassphrase((e.target as HTMLInputElement).value);558 if (restoreError()) setRestoreError(null);559 }}560 onKeyDown={(e) => {561 if (e.key === "Enter" && phase() === "ready") {562 void handleRestore();563 }564 }}565 />566 <Show when={restoreError()}>567 <p class="peach-hint peach-hint-error">{restoreError()}</p>568 </Show>569 <button570 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"571 disabled={phase() === "restoring" || passphrase().length === 0}572 onClick={() => void handleRestore()}573 >574 {phase() === "restoring" ? "restoring…" : "restore vault"}575 </button>576 </div>577 </Show>578579 <Show when={phase() === "success"}>580 <div class="peach-settings-group peach-codex-done">581 <p class="peach-hint peach-hint--success">✓ vault restored</p>582 <p class="peach-codex-meta">583 {restoredCount()}{" "}584 {restoredCount() === 1 ? "entry" : "entries"} recovered.585 </p>586 <p class="peach-codex-footnote">587 your codex has been read and your vault is unlocked.588 </p>589 </div>590 </Show>591592 <Show when={phase() !== "success"}>593 <button594 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"595 onClick={props.onBack}596 >597 cancel598 </button>599 </Show>600 </div>601 </div>602 );603}