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 | "pdf-shortfall"37 | "ready"38 | "restoring"39 | "success"40 | "error";4142export function CodexRestoreView(props: CodexRestoreViewProps) {43 44 45 46 47 const buffer = new ChunkBuffer();48 let videoEl: HTMLVideoElement | undefined;49 let scanner: ScannerHandle | null = null;50 let pdfFileInput: HTMLInputElement | undefined;5152 const [phase, setPhase] = createSignal<Phase>("choose");53 const [scanned, setScanned] = createSignal(0);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 const [pdfShortfall, setPdfShortfall] = createSignal<{67 found: number;68 missing: number;69 ready: number;70 total: number;71 } | null>(null);72 73 74 75 const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(76 null,77 );78 const [passphrase, setPassphrase] = createSignal("");79 const [restoreError, setRestoreError] = createSignal<string | null>(null);80 const [restoredCount, setRestoredCount] = createSignal<number | null>(null);8182 function refreshFromBuffer() {83 setScanned(buffer.size());84 const ctx = buffer.getContext();85 if (ctx) {86 setDataShards(ctx.dataShards);87 setVerifyCode(ctx.verifyCode);88 }89 if (90 buffer.isReady() &&91 (phase() === "scanning" || phase() === "pdf-import" || phase() === "pdf-shortfall")92 ) {93 setPhase("ready");94 setHint("scan complete. enter your master password to restore.");95 setPdfShortfall(null);96 97 98 if (scanner) {99 scanner.stop();100 scanner = null;101 }102 } else if (phase() === "scanning") {103 const ds = ctx?.dataShards;104 if (ds) {105 const remaining = Math.max(ds - buffer.size(), 0);106 setHint(107 remaining > 0108 ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`109 : "almost there…",110 );111 }112 }113 }114115 function explainScanReason(reason: string): string {116 switch (reason) {117 case "foreign_codex":118 return "that code belongs to a different Codex.";119 case "bad_integrity":120 return "a code's checksum failed — try again.";121 case "duplicate":122 return ""; 123 case "parse_error":124 return "could not read that code.";125 default:126 return "";127 }128 }129130 async function handleScan(raw: string) {131 const parsed = parseRawScan(raw);132 if ("error" in parsed) {133 134 135 return;136 }137138 const result = await buffer.add(parsed);139 refreshFromBuffer();140 if (!result.accepted && result.reason) {141 const msg = explainScanReason(result.reason);142 if (msg) {143 setScanWarning(msg);144 145 setTimeout(() => setScanWarning(null), 2500);146 }147 }148 }149150 function handleScannerError(err: Error) {151 setCameraError(152 err.name === "NotAllowedError"153 ? "camera access was denied. allow camera permission and reopen this screen."154 : err.message || "camera failed to start.",155 );156 }157158 async function startCameraScan() {159 setPhase("scanning");160 setCameraError(null);161 setPdfShortfall(null);162 163 await Promise.resolve();164 if (!videoEl) return;165 try {166 scanner = await startScanner({167 videoEl,168 onScan: (text) => void handleScan(text),169 onError: (err) => handleScannerError(err),170 });171 } catch (err) {172 handleScannerError(err instanceof Error ? err : new Error(String(err)));173 }174 }175176 177 178 179 180 181 182 183 async function openPdfPicker() {184 if (autoPickPdf) {185 pdfFileInput?.click();186 return;187 }188 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {189 pdfFileInput?.click();190 return;191 }192 try {193 194 195 196 const res = (await sendMessage({197 type: "OPEN_DETACHED_POPUP",198 payload: { route: "codex-pdf" },199 })) as { ok: boolean; data?: { detached?: boolean } };200 if (!res?.data?.detached) {201 pdfFileInput?.click();202 return;203 }204 205 206 } catch (err) {207 console.warn("[codex] failed to detach popup for PDF import:", err);208 pdfFileInput?.click();209 }210 }211212 async function handlePDFSelected(e: Event) {213 const input = e.currentTarget as HTMLInputElement;214 const file = input.files?.[0];215 216 input.value = "";217 if (!file) return;218219 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {220 setPdfError("please select a Peach Codex PDF file.");221 return;222 }223224 setPhase("pdf-import");225 setPdfProgress(null);226 setPdfError(null);227 setPdfShortfall(null);228229 try {230 231 232 const { importCodexPDF } = await import("../codex/pdf-import");233 const result = await importCodexPDF(file, (page, total) => {234 setPdfProgress({ page, total });235 });236237 if (result.envelopes.length === 0) {238 239 240 241 setPdfError(242 result.errors.length > 0243 ? result.errors[0]!244 : "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",245 );246 setPhase("choose");247 return;248 }249250 for (const envelope of result.envelopes) {251 await buffer.add(envelope);252 }253 refreshFromBuffer();254255 if (!buffer.isReady()) {256 const stripes = buffer.stripeReadiness();257 const ready = stripes.filter((s) => s.isReady).length;258 const missing = stripes.reduce(259 (sum, s) => sum + Math.max(0, s.required - s.found),260 0,261 );262 setPdfShortfall({263 found: buffer.size(),264 missing,265 ready,266 total: stripes.length,267 });268 setPhase("pdf-shortfall");269 return;270 }271 } catch (err) {272 setPdfError(273 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,274 );275 setPhase("choose");276 }277 }278279 function retryPdfImport() {280 setPdfShortfall(null);281 void openPdfPicker();282 }283284 285 286 287 288 289 const autoPickPdf = (() => {290 try {291 return new URLSearchParams(window.location.search).get("codex") === "pdf";292 } catch {293 return false;294 }295 })();296297 onMount(() => {298 if (autoPickPdf) {299 300 301 setTimeout(() => pdfFileInput?.click(), 50);302 }303 });304305 onCleanup(() => {306 if (scanner) {307 scanner.stop();308 scanner = null;309 }310 311 312 313 314 if (videoEl?.srcObject) {315 const stream = videoEl.srcObject as MediaStream;316 stream.getTracks().forEach((t) => t.stop());317 videoEl.srcObject = null;318 }319 });320321 function explainRestoreError(error: string | undefined): string {322 if (!error) return "restoration failed. please try again.";323 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))324 return "wrong master password. try again.";325 if (error.includes("INVALID_VAULT_BLOB"))326 return "the reconstructed vault is unreadable. please re-scan the Codex.";327 if (error.includes("FINGERPRINT_MISMATCH"))328 return "vault integrity check failed. please re-scan the Codex.";329 return error;330 }331332 async function handleRestore() {333 if (phase() === "restoring") return;334 const pw = passphrase();335 336 337 338 if (!pw.trim()) {339 setRestoreError("enter your master password.");340 return;341 }342343 setPhase("restoring");344 setRestoreError(null);345346 let payload = pendingPayload();347 if (!payload) {348 try {349 payload = await reconstructAndVerify(buffer);350 setPendingPayload(payload);351 } catch (err) {352 const message =353 err instanceof Error354 ? err.message355 : "the scanned codes could not be reassembled.";356 setRestoreError(message);357 setPhase("ready");358 return;359 }360 }361362 const res = (await sendMessage({363 type: "RESTORE_FROM_CODEX",364 payload: {365 vaultBlob: payload.vaultBlob,366 timestamp: payload.timestamp,367 verifyCode: payload.verifyCode,368 vaultFingerprint: payload.vaultFingerprint,369 entryCount: payload.entryCount,370 masterPassphrase: pw,371 },372 })) as {373 ok: boolean;374 data?: { entryCount: number; verifyCode: string };375 error?: string;376 };377378 if (res.ok && res.data) {379 setRestoredCount(res.data.entryCount);380 setPhase("success");381 if (scanner) {382 scanner.stop();383 scanner = null;384 }385 setTimeout(() => props.onRestoreSuccess(), 1500);386 } else {387 setRestoreError(explainRestoreError(res.error));388 setPhase("ready");389 }390 }391392 function progressPercent(): number {393 const stripes = buffer.stripeReadiness();394 if (stripes.length > 0) {395 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);396 if (worst.required > 0) {397 return Math.min(100, Math.round((worst.found / worst.required) * 100));398 }399 }400 const ds = dataShards();401 if (!ds || ds <= 0) return 0;402 return Math.min(100, Math.round((scanned() / ds) * 100));403 }404405 function stripeProgressSummary():406 | { ready: number; total: number; worstFound: number; worstRequired: number }407 | null {408 const stripes = buffer.stripeReadiness();409 if (stripes.length === 0) return null;410 const ready = stripes.filter((s) => s.isReady).length;411 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);412 return {413 ready,414 total: stripes.length,415 worstFound: worst.found,416 worstRequired: worst.required,417 };418 }419420 return (421 <div class="peach-overlay-view ov-enter-right" style="display:flex">422 <div class="peach-overlay-header">423 <button424 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"425 onClick={props.onBack}426 aria-label="Back"427 >428 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"429 stroke="currentColor" stroke-width="1.1"430 stroke-linecap="round" stroke-linejoin="round">431 <path d="M6 1L1 6L6 11" />432 </svg>433 </button>434 <span class="peach-overlay-title">restore from codex</span>435 <div class="peach-overlay-spacer"></div>436 </div>437438 <div class="peach-settings-body peach-codex-body">439 <Show when={phase() === "choose"}>440 <div class="peach-codex-restore-choose">441 <h2 class="peach-codex-restore-headline">restore from codex.</h2>442 <p class="peach-codex-restore-sub">443 how do you have your codex?444 </p>445446 <button447 class="peach-codex-restore-option"448 onClick={() => void startCameraScan()}449 >450 <div class="peach-codex-restore-option-icon">451 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"452 stroke="currentColor" stroke-width="1.5"453 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">454 <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" />455 <circle cx="12" cy="13" r="3.5" />456 </svg>457 </div>458 <div class="peach-codex-restore-option-content">459 <span class="peach-codex-restore-option-title">460 scan printed pages461 </span>462 <span class="peach-codex-restore-option-sub">463 point your camera at each code464 </span>465 </div>466 </button>467468 <button469 class="peach-codex-restore-option"470 onClick={() => void openPdfPicker()}471 >472 <div class="peach-codex-restore-option-icon">473 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"474 stroke="currentColor" stroke-width="1.5"475 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">476 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />477 <path d="M14 3v5h5" />478 <path d="M9 14h6" />479 <path d="M9 17h4" />480 </svg>481 </div>482 <div class="peach-codex-restore-option-content">483 <span class="peach-codex-restore-option-title">484 import PDF file485 </span>486 <span class="peach-codex-restore-option-sub">487 select your codex PDF from a USB drive or folder488 </span>489 </div>490 </button>491492 <p class="peach-codex-restore-guidance">493 have both the printed pages and the PDF? the PDF import is494 faster and more reliable — use it if you can.495 </p>496497 <Show when={pdfError()}>498 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">499 {pdfError()}500 </p>501 </Show>502 </div>503 </Show>504505 <input506 ref={(el) => (pdfFileInput = el)}507 type="file"508 accept=".pdf,application/pdf"509 style={{ display: "none" }}510 onChange={(e) => void handlePDFSelected(e)}511 />512513 <Show when={phase() === "pdf-shortfall"}>514 <div class="peach-settings-group peach-codex-pdf-shortfall">515 <p class="peach-codex-restore-headline">pdf import needs another pass.</p>516 <p class="peach-codex-pdf-shortfall-copy">517 The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.518 {pdfShortfall()?.ready === pdfShortfall()?.total519 ? " all stripes are ready now."520 : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}521 </p>522 <p class="peach-codex-pdf-shortfall-copy">523 try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.524 </p>525 <div class="peach-codex-pdf-shortfall-actions">526 <button527 class="peach-btn peach-btn--accent peach-btn--md"528 onClick={() => retryPdfImport()}529 >530 try PDF again531 </button>532 <button533 class="peach-btn peach-btn--ghost peach-btn--md"534 onClick={() => void startCameraScan()}535 >536 use camera537 </button>538 </div>539 </div>540 </Show>541542 <Show543 when={544 phase() === "scanning" ||545 phase() === "ready" ||546 phase() === "restoring"547 }548 >549 <div class="peach-codex-scanner">550 <video551 ref={(el) => (videoEl = el)}552 class="peach-codex-video"553 autoplay554 playsinline555 muted556 />557 <Show when={cameraError()}>558 <div class="peach-codex-camera-overlay">559 <p class="peach-hint peach-hint-error">{cameraError()}</p>560 </div>561 </Show>562 </div>563 </Show>564565 <Show when={phase() === "pdf-import"}>566 <div class="peach-settings-group peach-codex-pdf-import">567 <p class="peach-codex-restore-headline">reading your codex…</p>568 <Show when={pdfProgress()}>569 {(p) => (570 <>571 <div class="peach-codex-progress-track">572 <div573 class="peach-codex-progress-fill"574 style={{575 width: `${Math.round((p().page / p().total) * 100)}%`,576 }}577 />578 </div>579 <p class="peach-codex-progress-label">580 page {p().page} of {p().total}581 </p>582 </>583 )}584 </Show>585 </div>586 </Show>587588 <Show589 when={590 phase() === "scanning" ||591 phase() === "ready" ||592 phase() === "restoring"593 }594 >595 <div class="peach-settings-group peach-codex-progress-group">596 <div class="peach-codex-progress-track">597 <div598 class="peach-codex-progress-fill"599 style={{ width: `${progressPercent()}%` }}600 />601 </div>602 <p class="peach-codex-progress-label">603 <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>604 {(summary) => (605 <span>606 {summary().ready} / {summary().total} stripes ready607 </span>608 )}609 </Show>610 <Show when={verifyCode()}>611 <span class="peach-codex-verify-tag">{verifyCode()}</span>612 </Show>613 </p>614 <Show when={stripeProgressSummary()}>615 {(summary) => (616 <p class="peach-codex-hint peach-codex-worst-stripe">617 least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes618 </p>619 )}620 </Show>621 <p class="peach-codex-hint">{hint()}</p>622 <Show when={scanWarning()}>623 <p class="peach-hint peach-hint--warning peach-codex-warning">624 {scanWarning()}625 </p>626 </Show>627 </div>628 </Show>629630 <Show when={phase() === "ready" || phase() === "restoring"}>631 <div class="peach-settings-group peach-codex-passphrase-group">632 <label class="peach-settings-label" for="codex-passphrase">633 master password634 </label>635 <input636 id="codex-passphrase"637 type="password"638 class="peach-underline-input"639 autocomplete="current-password"640 autocorrect="off"641 autocapitalize="none"642 spellcheck={false}643 disabled={phase() === "restoring"}644 value={passphrase()}645 onInput={(e) => {646 setPassphrase((e.target as HTMLInputElement).value);647 if (restoreError()) setRestoreError(null);648 }}649 onKeyDown={(e) => {650 if (e.key === "Enter" && phase() === "ready") {651 void handleRestore();652 }653 }}654 />655 <Show when={restoreError()}>656 <p class="peach-hint peach-hint-error">{restoreError()}</p>657 </Show>658 <button659 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"660 disabled={phase() === "restoring" || passphrase().length === 0}661 onClick={() => void handleRestore()}662 >663 {phase() === "restoring" ? "restoring…" : "restore vault"}664 </button>665 </div>666 </Show>667668 <Show when={phase() === "success"}>669 <div class="peach-settings-group peach-codex-done">670 <p class="peach-hint peach-hint--success">✓ vault restored</p>671 <p class="peach-codex-meta">672 {restoredCount()}{" "}673 {restoredCount() === 1 ? "entry" : "entries"} recovered.674 </p>675 <p class="peach-codex-footnote">676 your codex has been read and your vault is unlocked.677 </p>678 </div>679 </Show>680681 <Show when={phase() !== "success"}>682 <button683 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"684 onClick={props.onBack}685 >686 cancel687 </button>688 </Show>689 </div>690 </div>691 );692}