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 return error;334 }335336 async function handleRestore() {337 if (phase() === "restoring") return;338 const pw = passphrase();339 340 341 342 if (!pw.trim()) {343 setRestoreError("enter your master password.");344 return;345 }346347 setPhase("restoring");348 setRestoreError(null);349350 let payload = pendingPayload();351 if (!payload) {352 try {353 payload = await reconstructAndVerify(buffer);354 setPendingPayload(payload);355 } catch (err) {356 const message =357 err instanceof Error358 ? err.message359 : "the scanned codes could not be reassembled.";360 setRestoreError(message);361 setPhase("ready");362 return;363 }364 }365366 const res = (await sendMessage({367 type: "RESTORE_FROM_CODEX",368 payload: {369 vaultBlob: payload.vaultBlob,370 timestamp: payload.timestamp,371 verifyCode: payload.verifyCode,372 vaultFingerprint: payload.vaultFingerprint,373 entryCount: payload.entryCount,374 masterPassphrase: pw,375 },376 })) as {377 ok: boolean;378 data?: { entryCount: number; verifyCode: string };379 error?: string;380 };381382 if (res.ok && res.data) {383 setRestoredCount(res.data.entryCount);384 setPhase("success");385 if (scanner) {386 scanner.stop();387 scanner = null;388 }389 setTimeout(() => props.onRestoreSuccess(), 1500);390 } else {391 setRestoreError(explainRestoreError(res.error));392 setPhase("ready");393 }394 }395396 function progressPercent(): number {397 const stripes = buffer.stripeReadiness();398 if (stripes.length > 0) {399 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);400 if (worst.required > 0) {401 return Math.min(100, Math.round((worst.found / worst.required) * 100));402 }403 }404 const ds = dataShards();405 if (!ds || ds <= 0) return 0;406 return Math.min(100, Math.round((scanned() / ds) * 100));407 }408409 function stripeProgressSummary():410 | { ready: number; total: number; worstFound: number; worstRequired: number }411 | null {412 const stripes = buffer.stripeReadiness();413 if (stripes.length === 0) return null;414 const ready = stripes.filter((s) => s.isReady).length;415 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);416 return {417 ready,418 total: stripes.length,419 worstFound: worst.found,420 worstRequired: worst.required,421 };422 }423424 return (425 <div class="peach-overlay-view ov-enter-right" style="display:flex">426 <div class="peach-overlay-header">427 <button428 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"429 onClick={props.onBack}430 aria-label="Back"431 >432 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"433 stroke="currentColor" stroke-width="1.1"434 stroke-linecap="round" stroke-linejoin="round">435 <path d="M6 1L1 6L6 11" />436 </svg>437 </button>438 <span class="peach-overlay-title">restore from codex</span>439 <div class="peach-overlay-spacer"></div>440 </div>441442 <div class="peach-settings-body peach-codex-body">443 <Show when={phase() === "choose"}>444 <div class="peach-codex-restore-choose">445 <h2 class="peach-codex-restore-headline">restore from codex.</h2>446 <p class="peach-codex-restore-sub">447 how do you have your codex?448 </p>449450 <button451 class="peach-codex-restore-option"452 onClick={() => void startCameraScan()}453 >454 <div class="peach-codex-restore-option-icon">455 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"456 stroke="currentColor" stroke-width="1.5"457 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">458 <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" />459 <circle cx="12" cy="13" r="3.5" />460 </svg>461 </div>462 <div class="peach-codex-restore-option-content">463 <span class="peach-codex-restore-option-title">464 scan printed pages465 </span>466 <span class="peach-codex-restore-option-sub">467 point your camera at each code468 </span>469 </div>470 </button>471472 <button473 class="peach-codex-restore-option"474 onClick={() => void openPdfPicker()}475 >476 <div class="peach-codex-restore-option-icon">477 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"478 stroke="currentColor" stroke-width="1.5"479 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">480 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />481 <path d="M14 3v5h5" />482 <path d="M9 14h6" />483 <path d="M9 17h4" />484 </svg>485 </div>486 <div class="peach-codex-restore-option-content">487 <span class="peach-codex-restore-option-title">488 import PDF file489 </span>490 <span class="peach-codex-restore-option-sub">491 select your codex PDF from a USB drive or folder492 </span>493 </div>494 </button>495496 <p class="peach-codex-restore-guidance">497 have both the printed pages and the PDF? the PDF import is498 faster and more reliable — use it if you can.499 </p>500501 <Show when={pdfError()}>502 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">503 {pdfError()}504 </p>505 </Show>506 </div>507 </Show>508509 <input510 ref={(el) => (pdfFileInput = el)}511 type="file"512 accept=".pdf,application/pdf"513 style={{ display: "none" }}514 onChange={(e) => void handlePDFSelected(e)}515 />516517 <Show when={phase() === "pdf-shortfall"}>518 <div class="peach-settings-group peach-codex-pdf-shortfall">519 <p class="peach-codex-restore-headline">pdf import needs another pass.</p>520 <p class="peach-codex-pdf-shortfall-copy">521 The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.522 {pdfShortfall()?.ready === pdfShortfall()?.total523 ? " all stripes are ready now."524 : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}525 </p>526 <p class="peach-codex-pdf-shortfall-copy">527 try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.528 </p>529 <div class="peach-codex-pdf-shortfall-actions">530 <button531 class="peach-btn peach-btn--accent peach-btn--md"532 onClick={() => retryPdfImport()}533 >534 try PDF again535 </button>536 <button537 class="peach-btn peach-btn--ghost peach-btn--md"538 onClick={() => void startCameraScan()}539 >540 use camera541 </button>542 </div>543 </div>544 </Show>545546 <Show547 when={548 phase() === "scanning" ||549 phase() === "ready" ||550 phase() === "restoring"551 }552 >553 <div class="peach-codex-scanner">554 <video555 ref={(el) => (videoEl = el)}556 class="peach-codex-video"557 autoplay558 playsinline559 muted560 />561 <Show when={cameraError()}>562 <div class="peach-codex-camera-overlay">563 <p class="peach-hint peach-hint-error">{cameraError()}</p>564 </div>565 </Show>566 </div>567 </Show>568569 <Show when={phase() === "pdf-import"}>570 <div class="peach-settings-group peach-codex-pdf-import">571 <p class="peach-codex-restore-headline">reading your codex…</p>572 <Show when={pdfProgress()}>573 {(p) => (574 <>575 <div class="peach-codex-progress-track">576 <div577 class="peach-codex-progress-fill"578 style={{579 width: `${Math.round((p().page / p().total) * 100)}%`,580 }}581 />582 </div>583 <p class="peach-codex-progress-label">584 page {p().page} of {p().total}585 </p>586 </>587 )}588 </Show>589 </div>590 </Show>591592 <Show593 when={594 phase() === "scanning" ||595 phase() === "ready" ||596 phase() === "restoring"597 }598 >599 <div class="peach-settings-group peach-codex-progress-group">600 <div class="peach-codex-progress-track">601 <div602 class="peach-codex-progress-fill"603 style={{ width: `${progressPercent()}%` }}604 />605 </div>606 <p class="peach-codex-progress-label">607 <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>608 {(summary) => (609 <span>610 {summary().ready} / {summary().total} stripes ready611 </span>612 )}613 </Show>614 <Show when={verifyCode()}>615 <span class="peach-codex-verify-tag">{verifyCode()}</span>616 </Show>617 </p>618 <Show when={stripeProgressSummary()}>619 {(summary) => (620 <p class="peach-codex-hint peach-codex-worst-stripe">621 least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes622 </p>623 )}624 </Show>625 <p class="peach-codex-hint">{hint()}</p>626 <Show when={scanWarning()}>627 <p class="peach-hint peach-hint--warning peach-codex-warning">628 {scanWarning()}629 </p>630 </Show>631 </div>632 </Show>633634 <Show when={phase() === "ready" || phase() === "restoring"}>635 <div class="peach-settings-group peach-codex-passphrase-group">636 <label class="peach-settings-label" for="codex-passphrase">637 master password638 </label>639 <input640 id="codex-passphrase"641 type="password"642 class="peach-underline-input"643 autocomplete="current-password"644 autocorrect="off"645 autocapitalize="none"646 spellcheck={false}647 disabled={phase() === "restoring"}648 value={passphrase()}649 onInput={(e) => {650 setPassphrase((e.target as HTMLInputElement).value);651 if (restoreError()) setRestoreError(null);652 }}653 onKeyDown={(e) => {654 if (e.key === "Enter" && phase() === "ready") {655 void handleRestore();656 }657 }}658 />659 <Show when={restoreError()}>660 <p class="peach-hint peach-hint-error">{restoreError()}</p>661 </Show>662 <button663 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"664 disabled={phase() === "restoring" || passphrase().length === 0}665 onClick={() => void handleRestore()}666 >667 {phase() === "restoring" ? "restoring…" : "restore vault"}668 </button>669 </div>670 </Show>671672 <Show when={phase() === "success"}>673 <div class="peach-settings-group peach-codex-done">674 <p class="peach-hint peach-hint--success">✓ vault restored</p>675 <p class="peach-codex-meta">676 {restoredCount()}{" "}677 {restoredCount() === 1 ? "entry" : "entries"} recovered.678 </p>679 <p class="peach-codex-footnote">680 your codex has been read and your vault is unlocked.681 </p>682 </div>683 </Show>684685 <Show when={phase() !== "success"}>686 <button687 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"688 onClick={props.onBack}689 >690 cancel691 </button>692 </Show>693 </div>694 </div>695 );696}