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 await chrome.windows.create({197 url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),198 type: "popup",199 width: 320,200 height: 550,201 });202 window.close();203 } catch (err) {204 console.warn("[codex] failed to detach popup for PDF import:", err);205 pdfFileInput?.click();206 }207 }208209 async function handlePDFSelected(e: Event) {210 const input = e.currentTarget as HTMLInputElement;211 const file = input.files?.[0];212 213 input.value = "";214 if (!file) return;215216 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {217 setPdfError("please select a Peach Codex PDF file.");218 return;219 }220221 setPhase("pdf-import");222 setPdfProgress(null);223 setPdfError(null);224225 try {226 227 228 const { importCodexPDF } = await import("../codex/pdf-import");229 const result = await importCodexPDF(file, (page, total) => {230 setPdfProgress({ page, total });231 });232233 if (result.envelopes.length === 0) {234 setPdfError(235 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",236 );237 setPhase("choose");238 return;239 }240241 for (const envelope of result.envelopes) {242 await buffer.add(envelope);243 }244 refreshFromBuffer();245246 if (!buffer.isReady()) {247 248 setPdfError(249 `found ${buffer.size()}${250 result.totalExpected ? ` of ${result.totalExpected}` : ""251 } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,252 );253 await startCameraScan();254 }255 } catch (err) {256 setPdfError(257 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,258 );259 setPhase("choose");260 }261 }262263 264 265 266 267 268 const autoPickPdf = (() => {269 try {270 return new URLSearchParams(window.location.search).get("codex") === "pdf";271 } catch {272 return false;273 }274 })();275276 onMount(() => {277 if (autoPickPdf) {278 279 280 setTimeout(() => pdfFileInput?.click(), 50);281 }282 });283284 onCleanup(() => {285 if (scanner) {286 scanner.stop();287 scanner = null;288 }289 290 291 292 293 if (videoEl?.srcObject) {294 const stream = videoEl.srcObject as MediaStream;295 stream.getTracks().forEach((t) => t.stop());296 videoEl.srcObject = null;297 }298 });299300 function explainRestoreError(error: string | undefined): string {301 if (!error) return "restoration failed. please try again.";302 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))303 return "wrong master password. try again.";304 if (error.includes("INVALID_VAULT_BLOB"))305 return "the reconstructed vault is unreadable. please re-scan the Codex.";306 if (error.includes("FINGERPRINT_MISMATCH"))307 return "vault integrity check failed. please re-scan the Codex.";308 return error;309 }310311 async function handleRestore() {312 if (phase() === "restoring") return;313 const pw = passphrase().trim();314 if (!pw) {315 setRestoreError("enter your master password.");316 return;317 }318319 setPhase("restoring");320 setRestoreError(null);321322 let payload = pendingPayload();323 if (!payload) {324 try {325 payload = await reconstructAndVerify(buffer);326 setPendingPayload(payload);327 } catch (err) {328 const message =329 err instanceof Error330 ? err.message331 : "the scanned codes could not be reassembled.";332 setRestoreError(message);333 setPhase("ready");334 return;335 }336 }337338 const res = (await sendMessage({339 type: "RESTORE_FROM_CODEX",340 payload: {341 vaultBlob: payload.vaultBlob,342 timestamp: payload.timestamp,343 verifyCode: payload.verifyCode,344 vaultFingerprint: payload.vaultFingerprint,345 entryCount: payload.entryCount,346 masterPassphrase: pw,347 },348 })) as {349 ok: boolean;350 data?: { entryCount: number; verifyCode: string };351 error?: string;352 };353354 if (res.ok && res.data) {355 setRestoredCount(res.data.entryCount);356 setPhase("success");357 if (scanner) {358 scanner.stop();359 scanner = null;360 }361 setTimeout(() => props.onRestoreSuccess(), 1500);362 } else {363 setRestoreError(explainRestoreError(res.error));364 setPhase("ready");365 }366 }367368 function progressPercent(): number {369 const ds = dataShards();370 if (!ds || ds <= 0) return 0;371 return Math.min(100, Math.round((scanned() / ds) * 100));372 }373374 return (375 <div class="peach-overlay-view ov-enter-right" style="display:flex">376 <div class="peach-overlay-header">377 <button378 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"379 onClick={props.onBack}380 aria-label="Back"381 >382 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"383 stroke="currentColor" stroke-width="1.1"384 stroke-linecap="round" stroke-linejoin="round">385 <path d="M6 1L1 6L6 11" />386 </svg>387 </button>388 <span class="peach-overlay-title">restore from codex</span>389 <div class="peach-overlay-spacer"></div>390 </div>391392 <div class="peach-settings-body peach-codex-body">393 <Show when={phase() === "choose"}>394 <div class="peach-codex-restore-choose">395 <h2 class="peach-codex-restore-headline">restore from codex.</h2>396 <p class="peach-codex-restore-sub">397 how do you have your codex?398 </p>399400 <button401 class="peach-codex-restore-option"402 onClick={() => void startCameraScan()}403 >404 <div class="peach-codex-restore-option-icon">405 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"406 stroke="currentColor" stroke-width="1.5"407 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">408 <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" />409 <circle cx="12" cy="13" r="3.5" />410 </svg>411 </div>412 <div class="peach-codex-restore-option-content">413 <span class="peach-codex-restore-option-title">414 scan printed pages415 </span>416 <span class="peach-codex-restore-option-sub">417 point your camera at each code418 </span>419 </div>420 </button>421422 <button423 class="peach-codex-restore-option"424 onClick={() => void openPdfPicker()}425 >426 <div class="peach-codex-restore-option-icon">427 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"428 stroke="currentColor" stroke-width="1.5"429 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">430 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />431 <path d="M14 3v5h5" />432 <path d="M9 14h6" />433 <path d="M9 17h4" />434 </svg>435 </div>436 <div class="peach-codex-restore-option-content">437 <span class="peach-codex-restore-option-title">438 import PDF file439 </span>440 <span class="peach-codex-restore-option-sub">441 select your codex PDF from a USB drive or folder442 </span>443 </div>444 </button>445446 <input447 ref={(el) => (pdfFileInput = el)}448 type="file"449 accept=".pdf,application/pdf"450 style={{ display: "none" }}451 onChange={(e) => void handlePDFSelected(e)}452 />453454 <p class="peach-codex-restore-guidance">455 have both the printed pages and the PDF? the PDF import is456 faster and more reliable — use it if you can.457 </p>458459 <Show when={pdfError()}>460 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">461 {pdfError()}462 </p>463 </Show>464 </div>465 </Show>466467 <Show468 when={469 phase() === "scanning" ||470 phase() === "ready" ||471 phase() === "restoring"472 }473 >474 <div class="peach-codex-scanner">475 <video476 ref={(el) => (videoEl = el)}477 class="peach-codex-video"478 autoplay479 playsinline480 muted481 />482 <Show when={cameraError()}>483 <div class="peach-codex-camera-overlay">484 <p class="peach-hint peach-hint-error">{cameraError()}</p>485 </div>486 </Show>487 </div>488 </Show>489490 <Show when={phase() === "pdf-import"}>491 <div class="peach-settings-group peach-codex-pdf-import">492 <p class="peach-codex-restore-headline">reading your codex…</p>493 <Show when={pdfProgress()}>494 {(p) => (495 <>496 <div class="peach-codex-progress-track">497 <div498 class="peach-codex-progress-fill"499 style={{500 width: `${Math.round((p().page / p().total) * 100)}%`,501 }}502 />503 </div>504 <p class="peach-codex-progress-label">505 page {p().page} of {p().total}506 </p>507 </>508 )}509 </Show>510 </div>511 </Show>512513 <Show514 when={515 phase() === "scanning" ||516 phase() === "ready" ||517 phase() === "restoring"518 }519 >520 <div class="peach-settings-group peach-codex-progress-group">521 <div class="peach-codex-progress-track">522 <div523 class="peach-codex-progress-fill"524 style={{ width: `${progressPercent()}%` }}525 />526 </div>527 <p class="peach-codex-progress-label">528 <Show529 when={totalShards() !== null}530 fallback={<span>{scanned()} codes scanned</span>}531 >532 <span>533 {scanned()} / {dataShards()} codes534 </span>535 </Show>536 <Show when={verifyCode()}>537 <span class="peach-codex-verify-tag">538 {verifyCode()}539 </span>540 </Show>541 </p>542 <p class="peach-codex-hint">{hint()}</p>543 <Show when={scanWarning()}>544 <p class="peach-hint peach-hint--warning peach-codex-warning">545 {scanWarning()}546 </p>547 </Show>548 </div>549 </Show>550551 <Show when={phase() === "ready" || phase() === "restoring"}>552 <div class="peach-settings-group peach-codex-passphrase-group">553 <label class="peach-settings-label" for="codex-passphrase">554 master password555 </label>556 <input557 id="codex-passphrase"558 type="password"559 class="peach-underline-input"560 autocomplete="current-password"561 autocorrect="off"562 autocapitalize="none"563 spellcheck={false}564 disabled={phase() === "restoring"}565 value={passphrase()}566 onInput={(e) => {567 setPassphrase((e.target as HTMLInputElement).value);568 if (restoreError()) setRestoreError(null);569 }}570 onKeyDown={(e) => {571 if (e.key === "Enter" && phase() === "ready") {572 void handleRestore();573 }574 }}575 />576 <Show when={restoreError()}>577 <p class="peach-hint peach-hint-error">{restoreError()}</p>578 </Show>579 <button580 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"581 disabled={phase() === "restoring" || passphrase().length === 0}582 onClick={() => void handleRestore()}583 >584 {phase() === "restoring" ? "restoring…" : "restore vault"}585 </button>586 </div>587 </Show>588589 <Show when={phase() === "success"}>590 <div class="peach-settings-group peach-codex-done">591 <p class="peach-hint peach-hint--success">✓ vault restored</p>592 <p class="peach-codex-meta">593 {restoredCount()}{" "}594 {restoredCount() === 1 ? "entry" : "entries"} recovered.595 </p>596 <p class="peach-codex-footnote">597 your codex has been read and your vault is unlocked.598 </p>599 </div>600 </Show>601602 <Show when={phase() !== "success"}>603 <button604 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"605 onClick={props.onBack}606 >607 cancel608 </button>609 </Show>610 </div>611 </div>612 );613}