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 async function openPdfPicker() {183 if (autoPickPdf) {184 pdfFileInput?.click();185 return;186 }187 const canDetach =188 typeof chrome !== "undefined" &&189 typeof chrome.windows?.create === "function" &&190 typeof chrome.runtime?.getURL === "function";191 if (!canDetach) {192 pdfFileInput?.click();193 return;194 }195 try {196 197 198 199 200 const popupHtml =201 chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";202 203 204 205 206 await chrome.windows.create({207 url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),208 type: "popup",209 width: 320,210 height: 550,211 });212 window.close();213 } catch (err) {214 console.warn("[codex] failed to detach popup for PDF import:", err);215 pdfFileInput?.click();216 }217 }218219 async function handlePDFSelected(e: Event) {220 const input = e.currentTarget as HTMLInputElement;221 const file = input.files?.[0];222 223 input.value = "";224 if (!file) return;225226 if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {227 setPdfError("please select a Peach Codex PDF file.");228 return;229 }230231 setPhase("pdf-import");232 setPdfProgress(null);233 setPdfError(null);234 setPdfShortfall(null);235236 try {237 238 239 const { importCodexPDF } = await import("../codex/pdf-import");240 const result = await importCodexPDF(file, (page, total) => {241 setPdfProgress({ page, total });242 });243244 if (result.envelopes.length === 0) {245 setPdfError(246 "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",247 );248 setPhase("choose");249 return;250 }251252 for (const envelope of result.envelopes) {253 await buffer.add(envelope);254 }255 refreshFromBuffer();256257 if (!buffer.isReady()) {258 const stripes = buffer.stripeReadiness();259 const ready = stripes.filter((s) => s.isReady).length;260 const missing = stripes.reduce(261 (sum, s) => sum + Math.max(0, s.required - s.found),262 0,263 );264 setPdfShortfall({265 found: buffer.size(),266 missing,267 ready,268 total: stripes.length,269 });270 setPhase("pdf-shortfall");271 return;272 }273 } catch (err) {274 setPdfError(275 `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,276 );277 setPhase("choose");278 }279 }280281 function retryPdfImport() {282 setPdfShortfall(null);283 void openPdfPicker();284 }285286 287 288 289 290 291 const autoPickPdf = (() => {292 try {293 return new URLSearchParams(window.location.search).get("codex") === "pdf";294 } catch {295 return false;296 }297 })();298299 onMount(() => {300 if (autoPickPdf) {301 302 303 setTimeout(() => pdfFileInput?.click(), 50);304 }305 });306307 onCleanup(() => {308 if (scanner) {309 scanner.stop();310 scanner = null;311 }312 313 314 315 316 if (videoEl?.srcObject) {317 const stream = videoEl.srcObject as MediaStream;318 stream.getTracks().forEach((t) => t.stop());319 videoEl.srcObject = null;320 }321 });322323 function explainRestoreError(error: string | undefined): string {324 if (!error) return "restoration failed. please try again.";325 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))326 return "wrong master password. try again.";327 if (error.includes("INVALID_VAULT_BLOB"))328 return "the reconstructed vault is unreadable. please re-scan the Codex.";329 if (error.includes("FINGERPRINT_MISMATCH"))330 return "vault integrity check failed. please re-scan the Codex.";331 return error;332 }333334 async function handleRestore() {335 if (phase() === "restoring") return;336 const pw = passphrase();337 338 339 340 if (!pw.trim()) {341 setRestoreError("enter your master password.");342 return;343 }344345 setPhase("restoring");346 setRestoreError(null);347348 let payload = pendingPayload();349 if (!payload) {350 try {351 payload = await reconstructAndVerify(buffer);352 setPendingPayload(payload);353 } catch (err) {354 const message =355 err instanceof Error356 ? err.message357 : "the scanned codes could not be reassembled.";358 setRestoreError(message);359 setPhase("ready");360 return;361 }362 }363364 const res = (await sendMessage({365 type: "RESTORE_FROM_CODEX",366 payload: {367 vaultBlob: payload.vaultBlob,368 timestamp: payload.timestamp,369 verifyCode: payload.verifyCode,370 vaultFingerprint: payload.vaultFingerprint,371 entryCount: payload.entryCount,372 masterPassphrase: pw,373 },374 })) as {375 ok: boolean;376 data?: { entryCount: number; verifyCode: string };377 error?: string;378 };379380 if (res.ok && res.data) {381 setRestoredCount(res.data.entryCount);382 setPhase("success");383 if (scanner) {384 scanner.stop();385 scanner = null;386 }387 setTimeout(() => props.onRestoreSuccess(), 1500);388 } else {389 setRestoreError(explainRestoreError(res.error));390 setPhase("ready");391 }392 }393394 function progressPercent(): number {395 const stripes = buffer.stripeReadiness();396 if (stripes.length > 0) {397 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);398 if (worst.required > 0) {399 return Math.min(100, Math.round((worst.found / worst.required) * 100));400 }401 }402 const ds = dataShards();403 if (!ds || ds <= 0) return 0;404 return Math.min(100, Math.round((scanned() / ds) * 100));405 }406407 function stripeProgressSummary():408 | { ready: number; total: number; worstFound: number; worstRequired: number }409 | null {410 const stripes = buffer.stripeReadiness();411 if (stripes.length === 0) return null;412 const ready = stripes.filter((s) => s.isReady).length;413 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);414 return {415 ready,416 total: stripes.length,417 worstFound: worst.found,418 worstRequired: worst.required,419 };420 }421422 return (423 <div class="peach-overlay-view ov-enter-right" style="display:flex">424 <div class="peach-overlay-header">425 <button426 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"427 onClick={props.onBack}428 aria-label="Back"429 >430 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"431 stroke="currentColor" stroke-width="1.1"432 stroke-linecap="round" stroke-linejoin="round">433 <path d="M6 1L1 6L6 11" />434 </svg>435 </button>436 <span class="peach-overlay-title">restore from codex</span>437 <div class="peach-overlay-spacer"></div>438 </div>439440 <div class="peach-settings-body peach-codex-body">441 <Show when={phase() === "choose"}>442 <div class="peach-codex-restore-choose">443 <h2 class="peach-codex-restore-headline">restore from codex.</h2>444 <p class="peach-codex-restore-sub">445 how do you have your codex?446 </p>447448 <button449 class="peach-codex-restore-option"450 onClick={() => void startCameraScan()}451 >452 <div class="peach-codex-restore-option-icon">453 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"454 stroke="currentColor" stroke-width="1.5"455 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">456 <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" />457 <circle cx="12" cy="13" r="3.5" />458 </svg>459 </div>460 <div class="peach-codex-restore-option-content">461 <span class="peach-codex-restore-option-title">462 scan printed pages463 </span>464 <span class="peach-codex-restore-option-sub">465 point your camera at each code466 </span>467 </div>468 </button>469470 <button471 class="peach-codex-restore-option"472 onClick={() => void openPdfPicker()}473 >474 <div class="peach-codex-restore-option-icon">475 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"476 stroke="currentColor" stroke-width="1.5"477 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">478 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />479 <path d="M14 3v5h5" />480 <path d="M9 14h6" />481 <path d="M9 17h4" />482 </svg>483 </div>484 <div class="peach-codex-restore-option-content">485 <span class="peach-codex-restore-option-title">486 import PDF file487 </span>488 <span class="peach-codex-restore-option-sub">489 select your codex PDF from a USB drive or folder490 </span>491 </div>492 </button>493494 <p class="peach-codex-restore-guidance">495 have both the printed pages and the PDF? the PDF import is496 faster and more reliable — use it if you can.497 </p>498499 <Show when={pdfError()}>500 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">501 {pdfError()}502 </p>503 </Show>504 </div>505 </Show>506507 <input508 ref={(el) => (pdfFileInput = el)}509 type="file"510 accept=".pdf,application/pdf"511 style={{ display: "none" }}512 onChange={(e) => void handlePDFSelected(e)}513 />514515 <Show when={phase() === "pdf-shortfall"}>516 <div class="peach-settings-group peach-codex-pdf-shortfall">517 <p class="peach-codex-restore-headline">pdf import needs another pass.</p>518 <p class="peach-codex-pdf-shortfall-copy">519 The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.520 {pdfShortfall()?.ready === pdfShortfall()?.total521 ? " all stripes are ready now."522 : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}523 </p>524 <p class="peach-codex-pdf-shortfall-copy">525 try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.526 </p>527 <div class="peach-codex-pdf-shortfall-actions">528 <button529 class="peach-btn peach-btn--accent peach-btn--md"530 onClick={() => retryPdfImport()}531 >532 try PDF again533 </button>534 <button535 class="peach-btn peach-btn--ghost peach-btn--md"536 onClick={() => void startCameraScan()}537 >538 use camera539 </button>540 </div>541 </div>542 </Show>543544 <Show545 when={546 phase() === "scanning" ||547 phase() === "ready" ||548 phase() === "restoring"549 }550 >551 <div class="peach-codex-scanner">552 <video553 ref={(el) => (videoEl = el)}554 class="peach-codex-video"555 autoplay556 playsinline557 muted558 />559 <Show when={cameraError()}>560 <div class="peach-codex-camera-overlay">561 <p class="peach-hint peach-hint-error">{cameraError()}</p>562 </div>563 </Show>564 </div>565 </Show>566567 <Show when={phase() === "pdf-import"}>568 <div class="peach-settings-group peach-codex-pdf-import">569 <p class="peach-codex-restore-headline">reading your codex…</p>570 <Show when={pdfProgress()}>571 {(p) => (572 <>573 <div class="peach-codex-progress-track">574 <div575 class="peach-codex-progress-fill"576 style={{577 width: `${Math.round((p().page / p().total) * 100)}%`,578 }}579 />580 </div>581 <p class="peach-codex-progress-label">582 page {p().page} of {p().total}583 </p>584 </>585 )}586 </Show>587 </div>588 </Show>589590 <Show591 when={592 phase() === "scanning" ||593 phase() === "ready" ||594 phase() === "restoring"595 }596 >597 <div class="peach-settings-group peach-codex-progress-group">598 <div class="peach-codex-progress-track">599 <div600 class="peach-codex-progress-fill"601 style={{ width: `${progressPercent()}%` }}602 />603 </div>604 <p class="peach-codex-progress-label">605 <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>606 {(summary) => (607 <span>608 {summary().ready} / {summary().total} stripes ready609 </span>610 )}611 </Show>612 <Show when={verifyCode()}>613 <span class="peach-codex-verify-tag">{verifyCode()}</span>614 </Show>615 </p>616 <Show when={stripeProgressSummary()}>617 {(summary) => (618 <p class="peach-codex-hint peach-codex-worst-stripe">619 least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes620 </p>621 )}622 </Show>623 <p class="peach-codex-hint">{hint()}</p>624 <Show when={scanWarning()}>625 <p class="peach-hint peach-hint--warning peach-codex-warning">626 {scanWarning()}627 </p>628 </Show>629 </div>630 </Show>631632 <Show when={phase() === "ready" || phase() === "restoring"}>633 <div class="peach-settings-group peach-codex-passphrase-group">634 <label class="peach-settings-label" for="codex-passphrase">635 master password636 </label>637 <input638 id="codex-passphrase"639 type="password"640 class="peach-underline-input"641 autocomplete="current-password"642 autocorrect="off"643 autocapitalize="none"644 spellcheck={false}645 disabled={phase() === "restoring"}646 value={passphrase()}647 onInput={(e) => {648 setPassphrase((e.target as HTMLInputElement).value);649 if (restoreError()) setRestoreError(null);650 }}651 onKeyDown={(e) => {652 if (e.key === "Enter" && phase() === "ready") {653 void handleRestore();654 }655 }}656 />657 <Show when={restoreError()}>658 <p class="peach-hint peach-hint-error">{restoreError()}</p>659 </Show>660 <button661 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"662 disabled={phase() === "restoring" || passphrase().length === 0}663 onClick={() => void handleRestore()}664 >665 {phase() === "restoring" ? "restoring…" : "restore vault"}666 </button>667 </div>668 </Show>669670 <Show when={phase() === "success"}>671 <div class="peach-settings-group peach-codex-done">672 <p class="peach-hint peach-hint--success">✓ vault restored</p>673 <p class="peach-codex-meta">674 {restoredCount()}{" "}675 {restoredCount() === 1 ? "entry" : "entries"} recovered.676 </p>677 <p class="peach-codex-footnote">678 your codex has been read and your vault is unlocked.679 </p>680 </div>681 </Show>682683 <Show when={phase() !== "success"}>684 <button685 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"686 onClick={props.onBack}687 >688 cancel689 </button>690 </Show>691 </div>692 </div>693 );694}