12345678910111213141516import { createSignal, onCleanup, onMount, Show } from "solid-js";17import { sendMessage } from "../../shared/bridge";18import {19 ChunkBuffer,20 createScanGate,21 parseRawScan,22 reconstructAndVerify,23} from "../codex/restore";24import { startScanner } from "../codex/scanner";25import type { ScannerHandle } from "../codex/scanner";26import type { CodexPayload } from "../../shared/codex-types";2728interface CodexRestoreViewProps {29 onBack: () => void;30 onRestoreSuccess: () => void;31}3233type Phase =34 | "choose"35 | "scanning"36 | "pdf-import"37 | "pdf-shortfall"38 | "ready"39 | "restoring"40 | "success"41 | "error";4243export function CodexRestoreView(props: CodexRestoreViewProps) {44 45 46 47 48 const buffer = new ChunkBuffer();49 let videoEl: HTMLVideoElement | undefined;50 let scanner: ScannerHandle | null = null;51 let pdfFileInput: HTMLInputElement | undefined;5253 const [phase, setPhase] = createSignal<Phase>("choose");54 const [scanned, setScanned] = createSignal(0);55 const [dataShards, setDataShards] = createSignal<number | null>(null);56 const [verifyCode, setVerifyCode] = createSignal<string | null>(null);57 const [hint, setHint] = createSignal(58 "point your camera at the first code on page 1",59 );60 const [cameraError, setCameraError] = createSignal<string | null>(null);61 const [scanWarning, setScanWarning] = createSignal<string | null>(null);62 const [pdfProgress, setPdfProgress] = createSignal<{63 page: number;64 total: number;65 } | null>(null);66 const [pdfError, setPdfError] = createSignal<string | null>(null);67 const [pdfShortfall, setPdfShortfall] = createSignal<{68 found: number;69 missing: number;70 ready: number;71 total: number;72 } | null>(null);73 74 75 76 const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(77 null,78 );79 const [passphrase, setPassphrase] = createSignal("");80 const [restoreError, setRestoreError] = createSignal<string | null>(null);81 const [restoredCount, setRestoredCount] = createSignal<number | null>(null);8283 function refreshFromBuffer() {84 setScanned(buffer.size());85 const ctx = buffer.getContext();86 if (ctx) {87 setDataShards(ctx.dataShards);88 setVerifyCode(ctx.verifyCode);89 }90 if (91 buffer.isReady() &&92 (phase() === "scanning" || phase() === "pdf-import" || phase() === "pdf-shortfall")93 ) {94 setPhase("ready");95 setHint("scan complete. enter your master password to restore.");96 setPdfShortfall(null);97 98 99 if (scanner) {100 scanner.stop();101 scanner = null;102 }103 } else if (phase() === "scanning") {104 const ds = ctx?.dataShards;105 if (ds) {106 const remaining = Math.max(ds - buffer.size(), 0);107 setHint(108 remaining > 0109 ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`110 : "almost there…",111 );112 }113 }114 }115116 function explainScanReason(reason: string): string {117 switch (reason) {118 case "foreign_codex":119 return "that code belongs to a different Codex.";120 case "bad_integrity":121 return "a code's checksum failed — try again.";122 case "duplicate":123 return ""; 124 case "parse_error":125 return "could not read that code.";126 default:127 return "";128 }129 }130131 async function handleScan(raw: string) {132 const parsed = parseRawScan(raw);133 if ("error" in parsed) {134 135 136 return;137 }138139 const result = await buffer.add(parsed);140 refreshFromBuffer();141 if (!result.accepted && result.reason) {142 const msg = explainScanReason(result.reason);143 if (msg) {144 setScanWarning(msg);145 146 setTimeout(() => setScanWarning(null), 2500);147 }148 }149 }150151 function handleScannerError(err: Error) {152 setCameraError(153 err.name === "NotAllowedError"154 ? "camera access was denied. allow camera permission and reopen this screen."155 : err.message || "camera failed to start.",156 );157 }158159 async function startCameraScan() {160 setPhase("scanning");161 setCameraError(null);162 setPdfShortfall(null);163 164 await Promise.resolve();165 if (!videoEl) return;166 try {167 168 169 const gate = createScanGate((text) => handleScan(text));170 scanner = await startScanner({171 videoEl,172 onScan: (text) => gate.onScan(text),173 onError: (err) => handleScannerError(err),174 });175 } catch (err) {176 handleScannerError(err instanceof Error ? err : new Error(String(err)));177 }178 }179180 181 182 183 184 185 186 187 async function openPdfPicker() {188 if (autoPickPdf) {189 pdfFileInput?.click();190 return;191 }192 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {193 pdfFileInput?.click();194 return;195 }196 try {197 198 199 200 const res = (await sendMessage({201 type: "OPEN_DETACHED_POPUP",202 payload: { route: "codex-pdf" },203 })) as { ok: boolean; data?: { detached?: boolean } };204 if (!res?.data?.detached) {205 pdfFileInput?.click();206 return;207 }208 209 210 } catch (err) {211 console.warn("[codex] failed to detach popup for PDF import:", err);212 pdfFileInput?.click();213 }214 }215216 async function handlePDFSelected(e: Event) {217 const input = e.currentTarget as HTMLInputElement;218 const file = input.files?.[0];219 220 input.value = "";221 if (!file) return;222223 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {224 setPdfError("please select a Peach Codex PDF file.");225 return;226 }227228 setPhase("pdf-import");229 setPdfProgress(null);230 setPdfError(null);231 setPdfShortfall(null);232233 try {234 235 236 const { importCodexPDF } = await import("../codex/pdf-import");237 const result = await importCodexPDF(file, (page, total) => {238 setPdfProgress({ page, total });239 });240241 if (result.envelopes.length === 0) {242 243 244 245 setPdfError(246 result.errors.length > 0247 ? result.errors[0]!248 : "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",249 );250 setPhase("choose");251 return;252 }253254 for (const envelope of result.envelopes) {255 await buffer.add(envelope);256 }257 refreshFromBuffer();258259 if (!buffer.isReady()) {260 const stripes = buffer.stripeReadiness();261 const ready = stripes.filter((s) => s.isReady).length;262 const missing = stripes.reduce(263 (sum, s) => sum + Math.max(0, s.required - s.found),264 0,265 );266 setPdfShortfall({267 found: buffer.size(),268 missing,269 ready,270 total: stripes.length,271 });272 setPhase("pdf-shortfall");273 return;274 }275 } catch (err) {276 setPdfError(277 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,278 );279 setPhase("choose");280 }281 }282283 function retryPdfImport() {284 setPdfShortfall(null);285 void openPdfPicker();286 }287288 289 290 291 292 293 const autoPickPdf = (() => {294 try {295 return new URLSearchParams(window.location.search).get("codex") === "pdf";296 } catch {297 return false;298 }299 })();300301 onMount(() => {302 if (autoPickPdf) {303 304 305 setTimeout(() => pdfFileInput?.click(), 50);306 }307 });308309 onCleanup(() => {310 if (scanner) {311 scanner.stop();312 scanner = null;313 }314 315 316 317 318 if (videoEl?.srcObject) {319 const stream = videoEl.srcObject as MediaStream;320 stream.getTracks().forEach((t) => t.stop());321 videoEl.srcObject = null;322 }323 });324325 function explainRestoreError(error: string | undefined): string {326 if (!error) return "restoration failed. please try again.";327 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))328 return "wrong master password. try again.";329 if (error.includes("INVALID_VAULT_BLOB"))330 return "the reconstructed vault is unreadable. please re-scan the Codex.";331 if (error.includes("FINGERPRINT_MISMATCH"))332 return "vault integrity check failed. please re-scan the Codex.";333 334 return "restoration failed. please try again.";335 }336337 async function handleRestore() {338 if (phase() === "restoring") return;339 const pw = passphrase();340 341 342 343 if (!pw.trim()) {344 setRestoreError("enter your master password.");345 return;346 }347348 setPhase("restoring");349 setRestoreError(null);350351 let payload = pendingPayload();352 if (!payload) {353 try {354 payload = await reconstructAndVerify(buffer);355 setPendingPayload(payload);356 } catch (err) {357 const message =358 err instanceof Error359 ? err.message360 : "the scanned codes could not be reassembled.";361 setRestoreError(message);362 setPhase("ready");363 return;364 }365 }366367 const res = (await sendMessage({368 type: "RESTORE_FROM_CODEX",369 payload: {370 vaultBlob: payload.vaultBlob,371 timestamp: payload.timestamp,372 verifyCode: payload.verifyCode,373 vaultFingerprint: payload.vaultFingerprint,374 entryCount: payload.entryCount,375 masterPassphrase: pw,376 },377 })) as {378 ok: boolean;379 data?: { entryCount: number; verifyCode: string };380 error?: string;381 };382383 if (res.ok && res.data) {384 setRestoredCount(res.data.entryCount);385 setPhase("success");386 if (scanner) {387 scanner.stop();388 scanner = null;389 }390 setTimeout(() => props.onRestoreSuccess(), 1500);391 } else {392 setRestoreError(explainRestoreError(res.error));393 setPhase("ready");394 }395 }396397 function progressPercent(): number {398 const stripes = buffer.stripeReadiness();399 if (stripes.length > 0) {400 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);401 if (worst.required > 0) {402 return Math.min(100, Math.round((worst.found / worst.required) * 100));403 }404 }405 const ds = dataShards();406 if (!ds || ds <= 0) return 0;407 return Math.min(100, Math.round((scanned() / ds) * 100));408 }409410 function stripeProgressSummary():411 | { ready: number; total: number; worstFound: number; worstRequired: number }412 | null {413 const stripes = buffer.stripeReadiness();414 if (stripes.length === 0) return null;415 const ready = stripes.filter((s) => s.isReady).length;416 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);417 return {418 ready,419 total: stripes.length,420 worstFound: worst.found,421 worstRequired: worst.required,422 };423 }424425 return (426 <div class="peach-overlay-view ov-enter-right" style="display:flex">427 <div class="peach-overlay-header">428 <button429 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"430 onClick={props.onBack}431 aria-label="Back"432 >433 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"434 stroke="currentColor" stroke-width="1.1"435 stroke-linecap="round" stroke-linejoin="round">436 <path d="M6 1L1 6L6 11" />437 </svg>438 </button>439 <span class="peach-overlay-title">restore from codex</span>440 <div class="peach-overlay-spacer"></div>441 </div>442443 <div class="peach-settings-body peach-codex-body">444 <Show when={phase() === "choose"}>445 <div class="peach-codex-restore-choose">446 <h2 class="peach-codex-restore-headline">restore from codex.</h2>447 <p class="peach-codex-restore-sub">448 how do you have your codex?449 </p>450451 <button452 class="peach-codex-restore-option"453 onClick={() => void startCameraScan()}454 >455 <div class="peach-codex-restore-option-icon">456 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"457 stroke="currentColor" stroke-width="1.5"458 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">459 <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" />460 <circle cx="12" cy="13" r="3.5" />461 </svg>462 </div>463 <div class="peach-codex-restore-option-content">464 <span class="peach-codex-restore-option-title">465 scan printed pages466 </span>467 <span class="peach-codex-restore-option-sub">468 point your camera at each code469 </span>470 </div>471 </button>472473 <button474 class="peach-codex-restore-option"475 onClick={() => void openPdfPicker()}476 >477 <div class="peach-codex-restore-option-icon">478 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"479 stroke="currentColor" stroke-width="1.5"480 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">481 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />482 <path d="M14 3v5h5" />483 <path d="M9 14h6" />484 <path d="M9 17h4" />485 </svg>486 </div>487 <div class="peach-codex-restore-option-content">488 <span class="peach-codex-restore-option-title">489 import PDF file490 </span>491 <span class="peach-codex-restore-option-sub">492 select your codex PDF from a USB drive or folder493 </span>494 </div>495 </button>496497 <p class="peach-codex-restore-guidance">498 have both the printed pages and the PDF? the PDF import is499 faster and more reliable — use it if you can.500 </p>501502 <Show when={pdfError()}>503 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">504 {pdfError()}505 </p>506 </Show>507 </div>508 </Show>509510 <input511 ref={(el) => (pdfFileInput = el)}512 type="file"513 accept=".pdf,application/pdf"514 style={{ display: "none" }}515 onChange={(e) => void handlePDFSelected(e)}516 />517518 <Show when={phase() === "pdf-shortfall"}>519 <div class="peach-settings-group peach-codex-pdf-shortfall">520 <p class="peach-codex-restore-headline">pdf import needs another pass.</p>521 <p class="peach-codex-pdf-shortfall-copy">522 The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.523 {pdfShortfall()?.ready === pdfShortfall()?.total524 ? " all stripes are ready now."525 : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}526 </p>527 <p class="peach-codex-pdf-shortfall-copy">528 try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.529 </p>530 <div class="peach-codex-pdf-shortfall-actions">531 <button532 class="peach-btn peach-btn--accent peach-btn--md"533 onClick={() => retryPdfImport()}534 >535 try PDF again536 </button>537 <button538 class="peach-btn peach-btn--ghost peach-btn--md"539 onClick={() => void startCameraScan()}540 >541 use camera542 </button>543 </div>544 </div>545 </Show>546547 <Show548 when={549 phase() === "scanning" ||550 phase() === "ready" ||551 phase() === "restoring"552 }553 >554 <div class="peach-codex-scanner">555 <video556 ref={(el) => (videoEl = el)}557 class="peach-codex-video"558 autoplay559 playsinline560 muted561 />562 <Show when={cameraError()}>563 <div class="peach-codex-camera-overlay">564 <p class="peach-hint peach-hint-error">{cameraError()}</p>565 </div>566 </Show>567 </div>568 </Show>569570 <Show when={phase() === "pdf-import"}>571 <div class="peach-settings-group peach-codex-pdf-import">572 <p class="peach-codex-restore-headline">reading your codex…</p>573 <Show when={pdfProgress()}>574 {(p) => (575 <>576 <div class="peach-codex-progress-track">577 <div578 class="peach-codex-progress-fill"579 style={{580 width: `${Math.round((p().page / p().total) * 100)}%`,581 }}582 />583 </div>584 <p class="peach-codex-progress-label">585 page {p().page} of {p().total}586 </p>587 </>588 )}589 </Show>590 </div>591 </Show>592593 <Show594 when={595 phase() === "scanning" ||596 phase() === "ready" ||597 phase() === "restoring"598 }599 >600 <div class="peach-settings-group peach-codex-progress-group">601 <div class="peach-codex-progress-track">602 <div603 class="peach-codex-progress-fill"604 style={{ width: `${progressPercent()}%` }}605 />606 </div>607 <p class="peach-codex-progress-label">608 <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>609 {(summary) => (610 <span>611 {summary().ready} / {summary().total} stripes ready612 </span>613 )}614 </Show>615 <Show when={verifyCode()}>616 <span class="peach-codex-verify-tag">{verifyCode()}</span>617 </Show>618 </p>619 <Show when={stripeProgressSummary()}>620 {(summary) => (621 <p class="peach-codex-hint peach-codex-worst-stripe">622 least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes623 </p>624 )}625 </Show>626 <p class="peach-codex-hint">{hint()}</p>627 <Show when={scanWarning()}>628 <p class="peach-hint peach-hint--warning peach-codex-warning">629 {scanWarning()}630 </p>631 </Show>632 </div>633 </Show>634635 <Show when={phase() === "ready" || phase() === "restoring"}>636 <div class="peach-settings-group peach-codex-passphrase-group">637 <label class="peach-settings-label" for="codex-passphrase">638 master password639 </label>640 <input641 id="codex-passphrase"642 type="password"643 class="peach-underline-input"644 autocomplete="current-password"645 autocorrect="off"646 autocapitalize="none"647 spellcheck={false}648 disabled={phase() === "restoring"}649 value={passphrase()}650 onInput={(e) => {651 setPassphrase((e.target as HTMLInputElement).value);652 if (restoreError()) setRestoreError(null);653 }}654 onKeyDown={(e) => {655 if (e.key === "Enter" && phase() === "ready") {656 void handleRestore();657 }658 }}659 />660 <Show when={restoreError()}>661 <p class="peach-hint peach-hint-error">{restoreError()}</p>662 </Show>663 <button664 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"665 disabled={phase() === "restoring" || passphrase().length === 0}666 onClick={() => void handleRestore()}667 >668 {phase() === "restoring" ? "restoring…" : "restore vault"}669 </button>670 </div>671 </Show>672673 <Show when={phase() === "success"}>674 <div class="peach-settings-group peach-codex-done">675 <p class="peach-hint peach-hint--success">✓ vault restored</p>676 <p class="peach-codex-meta">677 {restoredCount()}{" "}678 {restoredCount() === 1 ? "entry" : "entries"} recovered.679 </p>680 <p class="peach-codex-footnote">681 your codex has been read and your vault is unlocked.682 </p>683 </div>684 </Show>685686 <Show when={phase() !== "success"}>687 <button688 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"689 onClick={props.onBack}690 >691 cancel692 </button>693 </Show>694 </div>695 </div>696 );697}