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 193 194 195 196 197 198 await chrome.windows.create({199 url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),200 type: "popup",201 width: 340,202 height: 560,203 });204 window.close();205 } catch (err) {206 console.warn("[codex] failed to detach popup for PDF import:", err);207 pdfFileInput?.click();208 }209 }210211 async function handlePDFSelected(e: Event) {212 const input = e.currentTarget as HTMLInputElement;213 const file = input.files?.[0];214 215 input.value = "";216 if (!file) return;217218 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {219 setPdfError("please select a Peach Codex PDF file.");220 return;221 }222223 setPhase("pdf-import");224 setPdfProgress(null);225 setPdfError(null);226227 try {228 229 230 const { importCodexPDF } = await import("../codex/pdf-import");231 const result = await importCodexPDF(file, (page, total) => {232 setPdfProgress({ page, total });233 });234235 if (result.envelopes.length === 0) {236 setPdfError(237 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",238 );239 setPhase("choose");240 return;241 }242243 for (const envelope of result.envelopes) {244 await buffer.add(envelope);245 }246 refreshFromBuffer();247248 if (!buffer.isReady()) {249 250 setPdfError(251 `found ${buffer.size()}${252 result.totalExpected ? ` of ${result.totalExpected}` : ""253 } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,254 );255 await startCameraScan();256 }257 } catch (err) {258 setPdfError(259 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,260 );261 setPhase("choose");262 }263 }264265 266 267 268 269 270 const autoPickPdf = (() => {271 try {272 return new URLSearchParams(window.location.search).get("codex") === "pdf";273 } catch {274 return false;275 }276 })();277278 onMount(() => {279 if (autoPickPdf) {280 281 282 setTimeout(() => pdfFileInput?.click(), 50);283 }284 });285286 onCleanup(() => {287 if (scanner) {288 scanner.stop();289 scanner = null;290 }291 292 293 294 295 if (videoEl?.srcObject) {296 const stream = videoEl.srcObject as MediaStream;297 stream.getTracks().forEach((t) => t.stop());298 videoEl.srcObject = null;299 }300 });301302 function explainRestoreError(error: string | undefined): string {303 if (!error) return "restoration failed. please try again.";304 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))305 return "wrong master password. try again.";306 if (error.includes("INVALID_VAULT_BLOB"))307 return "the reconstructed vault is unreadable. please re-scan the Codex.";308 if (error.includes("FINGERPRINT_MISMATCH"))309 return "vault integrity check failed. please re-scan the Codex.";310 return error;311 }312313 async function handleRestore() {314 if (phase() === "restoring") return;315 const pw = passphrase().trim();316 if (!pw) {317 setRestoreError("enter your master password.");318 return;319 }320321 setPhase("restoring");322 setRestoreError(null);323324 let payload = pendingPayload();325 if (!payload) {326 try {327 payload = await reconstructAndVerify(buffer);328 setPendingPayload(payload);329 } catch (err) {330 const message =331 err instanceof Error332 ? err.message333 : "the scanned codes could not be reassembled.";334 setRestoreError(message);335 setPhase("ready");336 return;337 }338 }339340 const res = (await sendMessage({341 type: "RESTORE_FROM_CODEX",342 payload: {343 vaultBlob: payload.vaultBlob,344 timestamp: payload.timestamp,345 verifyCode: payload.verifyCode,346 vaultFingerprint: payload.vaultFingerprint,347 entryCount: payload.entryCount,348 masterPassphrase: pw,349 },350 })) as {351 ok: boolean;352 data?: { entryCount: number; verifyCode: string };353 error?: string;354 };355356 if (res.ok && res.data) {357 setRestoredCount(res.data.entryCount);358 setPhase("success");359 if (scanner) {360 scanner.stop();361 scanner = null;362 }363 setTimeout(() => props.onRestoreSuccess(), 1500);364 } else {365 setRestoreError(explainRestoreError(res.error));366 setPhase("ready");367 }368 }369370 function progressPercent(): number {371 const ds = dataShards();372 if (!ds || ds <= 0) return 0;373 return Math.min(100, Math.round((scanned() / ds) * 100));374 }375376 return (377 <div class="peach-overlay-view ov-enter-right" style="display:flex">378 <div class="peach-overlay-header">379 <button380 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"381 onClick={props.onBack}382 aria-label="Back"383 >384 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"385 stroke="currentColor" stroke-width="1.1"386 stroke-linecap="round" stroke-linejoin="round">387 <path d="M6 1L1 6L6 11" />388 </svg>389 </button>390 <span class="peach-overlay-title">restore from codex</span>391 <div class="peach-overlay-spacer"></div>392 </div>393394 <div class="peach-settings-body peach-codex-body">395 <Show when={phase() === "choose"}>396 <div class="peach-codex-restore-choose">397 <h2 class="peach-codex-restore-headline">restore from codex.</h2>398 <p class="peach-codex-restore-sub">399 how do you have your codex?400 </p>401402 <button403 class="peach-codex-restore-option"404 onClick={() => void startCameraScan()}405 >406 <div class="peach-codex-restore-option-icon">407 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"408 stroke="currentColor" stroke-width="1.5"409 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">410 <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" />411 <circle cx="12" cy="13" r="3.5" />412 </svg>413 </div>414 <div class="peach-codex-restore-option-content">415 <span class="peach-codex-restore-option-title">416 scan printed pages417 </span>418 <span class="peach-codex-restore-option-sub">419 point your camera at each code420 </span>421 </div>422 </button>423424 <button425 class="peach-codex-restore-option"426 onClick={() => void openPdfPicker()}427 >428 <div class="peach-codex-restore-option-icon">429 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"430 stroke="currentColor" stroke-width="1.5"431 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">432 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />433 <path d="M14 3v5h5" />434 <path d="M9 14h6" />435 <path d="M9 17h4" />436 </svg>437 </div>438 <div class="peach-codex-restore-option-content">439 <span class="peach-codex-restore-option-title">440 import PDF file441 </span>442 <span class="peach-codex-restore-option-sub">443 select your codex PDF from a USB drive or folder444 </span>445 </div>446 </button>447448 <input449 ref={(el) => (pdfFileInput = el)}450 type="file"451 accept=".pdf,application/pdf"452 style={{ display: "none" }}453 onChange={(e) => void handlePDFSelected(e)}454 />455456 <p class="peach-codex-restore-guidance">457 have both the printed pages and the PDF? the PDF import is458 faster and more reliable — use it if you can.459 </p>460461 <Show when={pdfError()}>462 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">463 {pdfError()}464 </p>465 </Show>466 </div>467 </Show>468469 <Show470 when={471 phase() === "scanning" ||472 phase() === "ready" ||473 phase() === "restoring"474 }475 >476 <div class="peach-codex-scanner">477 <video478 ref={(el) => (videoEl = el)}479 class="peach-codex-video"480 autoplay481 playsinline482 muted483 />484 <Show when={cameraError()}>485 <div class="peach-codex-camera-overlay">486 <p class="peach-hint peach-hint-error">{cameraError()}</p>487 </div>488 </Show>489 </div>490 </Show>491492 <Show when={phase() === "pdf-import"}>493 <div class="peach-settings-group peach-codex-pdf-import">494 <p class="peach-codex-restore-headline">reading your codex…</p>495 <Show when={pdfProgress()}>496 {(p) => (497 <>498 <div class="peach-codex-progress-track">499 <div500 class="peach-codex-progress-fill"501 style={{502 width: `${Math.round((p().page / p().total) * 100)}%`,503 }}504 />505 </div>506 <p class="peach-codex-progress-label">507 page {p().page} of {p().total}508 </p>509 </>510 )}511 </Show>512 </div>513 </Show>514515 <Show516 when={517 phase() === "scanning" ||518 phase() === "ready" ||519 phase() === "restoring"520 }521 >522 <div class="peach-settings-group peach-codex-progress-group">523 <div class="peach-codex-progress-track">524 <div525 class="peach-codex-progress-fill"526 style={{ width: `${progressPercent()}%` }}527 />528 </div>529 <p class="peach-codex-progress-label">530 <Show531 when={totalShards() !== null}532 fallback={<span>{scanned()} codes scanned</span>}533 >534 <span>535 {scanned()} / {dataShards()} codes536 </span>537 </Show>538 <Show when={verifyCode()}>539 <span class="peach-codex-verify-tag">540 {verifyCode()}541 </span>542 </Show>543 </p>544 <p class="peach-codex-hint">{hint()}</p>545 <Show when={scanWarning()}>546 <p class="peach-hint peach-hint--warning peach-codex-warning">547 {scanWarning()}548 </p>549 </Show>550 </div>551 </Show>552553 <Show when={phase() === "ready" || phase() === "restoring"}>554 <div class="peach-settings-group peach-codex-passphrase-group">555 <label class="peach-settings-label" for="codex-passphrase">556 master password557 </label>558 <input559 id="codex-passphrase"560 type="password"561 class="peach-underline-input"562 autocomplete="current-password"563 autocorrect="off"564 autocapitalize="none"565 spellcheck={false}566 disabled={phase() === "restoring"}567 value={passphrase()}568 onInput={(e) => {569 setPassphrase((e.target as HTMLInputElement).value);570 if (restoreError()) setRestoreError(null);571 }}572 onKeyDown={(e) => {573 if (e.key === "Enter" && phase() === "ready") {574 void handleRestore();575 }576 }}577 />578 <Show when={restoreError()}>579 <p class="peach-hint peach-hint-error">{restoreError()}</p>580 </Show>581 <button582 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"583 disabled={phase() === "restoring" || passphrase().length === 0}584 onClick={() => void handleRestore()}585 >586 {phase() === "restoring" ? "restoring…" : "restore vault"}587 </button>588 </div>589 </Show>590591 <Show when={phase() === "success"}>592 <div class="peach-settings-group peach-codex-done">593 <p class="peach-hint peach-hint--success">✓ vault restored</p>594 <p class="peach-codex-meta">595 {restoredCount()}{" "}596 {restoredCount() === 1 ? "entry" : "entries"} recovered.597 </p>598 <p class="peach-codex-footnote">599 your codex has been read and your vault is unlocked.600 </p>601 </div>602 </Show>603604 <Show when={phase() !== "success"}>605 <button606 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"607 onClick={props.onBack}608 >609 cancel610 </button>611 </Show>612 </div>613 </div>614 );615}