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().trim();337 if (!pw) {338 setRestoreError("enter your master password.");339 return;340 }341342 setPhase("restoring");343 setRestoreError(null);344345 let payload = pendingPayload();346 if (!payload) {347 try {348 payload = await reconstructAndVerify(buffer);349 setPendingPayload(payload);350 } catch (err) {351 const message =352 err instanceof Error353 ? err.message354 : "the scanned codes could not be reassembled.";355 setRestoreError(message);356 setPhase("ready");357 return;358 }359 }360361 const res = (await sendMessage({362 type: "RESTORE_FROM_CODEX",363 payload: {364 vaultBlob: payload.vaultBlob,365 timestamp: payload.timestamp,366 verifyCode: payload.verifyCode,367 vaultFingerprint: payload.vaultFingerprint,368 entryCount: payload.entryCount,369 masterPassphrase: pw,370 },371 })) as {372 ok: boolean;373 data?: { entryCount: number; verifyCode: string };374 error?: string;375 };376377 if (res.ok && res.data) {378 setRestoredCount(res.data.entryCount);379 setPhase("success");380 if (scanner) {381 scanner.stop();382 scanner = null;383 }384 setTimeout(() => props.onRestoreSuccess(), 1500);385 } else {386 setRestoreError(explainRestoreError(res.error));387 setPhase("ready");388 }389 }390391 function progressPercent(): number {392 const stripes = buffer.stripeReadiness();393 if (stripes.length > 0) {394 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);395 if (worst.required > 0) {396 return Math.min(100, Math.round((worst.found / worst.required) * 100));397 }398 }399 const ds = dataShards();400 if (!ds || ds <= 0) return 0;401 return Math.min(100, Math.round((scanned() / ds) * 100));402 }403404 function stripeProgressSummary():405 | { ready: number; total: number; worstFound: number; worstRequired: number }406 | null {407 const stripes = buffer.stripeReadiness();408 if (stripes.length === 0) return null;409 const ready = stripes.filter((s) => s.isReady).length;410 const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);411 return {412 ready,413 total: stripes.length,414 worstFound: worst.found,415 worstRequired: worst.required,416 };417 }418419 return (420 <div class="peach-overlay-view ov-enter-right" style="display:flex">421 <div class="peach-overlay-header">422 <button423 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"424 onClick={props.onBack}425 aria-label="Back"426 >427 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"428 stroke="currentColor" stroke-width="1.1"429 stroke-linecap="round" stroke-linejoin="round">430 <path d="M6 1L1 6L6 11" />431 </svg>432 </button>433 <span class="peach-overlay-title">restore from codex</span>434 <div class="peach-overlay-spacer"></div>435 </div>436437 <div class="peach-settings-body peach-codex-body">438 <Show when={phase() === "choose"}>439 <div class="peach-codex-restore-choose">440 <h2 class="peach-codex-restore-headline">restore from codex.</h2>441 <p class="peach-codex-restore-sub">442 how do you have your codex?443 </p>444445 <button446 class="peach-codex-restore-option"447 onClick={() => void startCameraScan()}448 >449 <div class="peach-codex-restore-option-icon">450 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"451 stroke="currentColor" stroke-width="1.5"452 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">453 <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" />454 <circle cx="12" cy="13" r="3.5" />455 </svg>456 </div>457 <div class="peach-codex-restore-option-content">458 <span class="peach-codex-restore-option-title">459 scan printed pages460 </span>461 <span class="peach-codex-restore-option-sub">462 point your camera at each code463 </span>464 </div>465 </button>466467 <button468 class="peach-codex-restore-option"469 onClick={() => void openPdfPicker()}470 >471 <div class="peach-codex-restore-option-icon">472 <svg width="22" height="22" viewBox="0 0 24 24" fill="none"473 stroke="currentColor" stroke-width="1.5"474 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">475 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />476 <path d="M14 3v5h5" />477 <path d="M9 14h6" />478 <path d="M9 17h4" />479 </svg>480 </div>481 <div class="peach-codex-restore-option-content">482 <span class="peach-codex-restore-option-title">483 import PDF file484 </span>485 <span class="peach-codex-restore-option-sub">486 select your codex PDF from a USB drive or folder487 </span>488 </div>489 </button>490491 <p class="peach-codex-restore-guidance">492 have both the printed pages and the PDF? the PDF import is493 faster and more reliable — use it if you can.494 </p>495496 <Show when={pdfError()}>497 <p class="peach-hint peach-hint-error peach-codex-restore-guidance">498 {pdfError()}499 </p>500 </Show>501 </div>502 </Show>503504 <input505 ref={(el) => (pdfFileInput = el)}506 type="file"507 accept=".pdf,application/pdf"508 style={{ display: "none" }}509 onChange={(e) => void handlePDFSelected(e)}510 />511512 <Show when={phase() === "pdf-shortfall"}>513 <div class="peach-settings-group peach-codex-pdf-shortfall">514 <p class="peach-codex-restore-headline">pdf import needs another pass.</p>515 <p class="peach-codex-pdf-shortfall-copy">516 The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.517 {pdfShortfall()?.ready === pdfShortfall()?.total518 ? " all stripes are ready now."519 : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}520 </p>521 <p class="peach-codex-pdf-shortfall-copy">522 try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.523 </p>524 <div class="peach-codex-pdf-shortfall-actions">525 <button526 class="peach-btn peach-btn--accent peach-btn--md"527 onClick={() => retryPdfImport()}528 >529 try PDF again530 </button>531 <button532 class="peach-btn peach-btn--ghost peach-btn--md"533 onClick={() => void startCameraScan()}534 >535 use camera536 </button>537 </div>538 </div>539 </Show>540541 <Show542 when={543 phase() === "scanning" ||544 phase() === "ready" ||545 phase() === "restoring"546 }547 >548 <div class="peach-codex-scanner">549 <video550 ref={(el) => (videoEl = el)}551 class="peach-codex-video"552 autoplay553 playsinline554 muted555 />556 <Show when={cameraError()}>557 <div class="peach-codex-camera-overlay">558 <p class="peach-hint peach-hint-error">{cameraError()}</p>559 </div>560 </Show>561 </div>562 </Show>563564 <Show when={phase() === "pdf-import"}>565 <div class="peach-settings-group peach-codex-pdf-import">566 <p class="peach-codex-restore-headline">reading your codex…</p>567 <Show when={pdfProgress()}>568 {(p) => (569 <>570 <div class="peach-codex-progress-track">571 <div572 class="peach-codex-progress-fill"573 style={{574 width: `${Math.round((p().page / p().total) * 100)}%`,575 }}576 />577 </div>578 <p class="peach-codex-progress-label">579 page {p().page} of {p().total}580 </p>581 </>582 )}583 </Show>584 </div>585 </Show>586587 <Show588 when={589 phase() === "scanning" ||590 phase() === "ready" ||591 phase() === "restoring"592 }593 >594 <div class="peach-settings-group peach-codex-progress-group">595 <div class="peach-codex-progress-track">596 <div597 class="peach-codex-progress-fill"598 style={{ width: `${progressPercent()}%` }}599 />600 </div>601 <p class="peach-codex-progress-label">602 <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>603 {(summary) => (604 <span>605 {summary().ready} / {summary().total} stripes ready606 </span>607 )}608 </Show>609 <Show when={verifyCode()}>610 <span class="peach-codex-verify-tag">{verifyCode()}</span>611 </Show>612 </p>613 <Show when={stripeProgressSummary()}>614 {(summary) => (615 <p class="peach-codex-hint peach-codex-worst-stripe">616 least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes617 </p>618 )}619 </Show>620 <p class="peach-codex-hint">{hint()}</p>621 <Show when={scanWarning()}>622 <p class="peach-hint peach-hint--warning peach-codex-warning">623 {scanWarning()}624 </p>625 </Show>626 </div>627 </Show>628629 <Show when={phase() === "ready" || phase() === "restoring"}>630 <div class="peach-settings-group peach-codex-passphrase-group">631 <label class="peach-settings-label" for="codex-passphrase">632 master password633 </label>634 <input635 id="codex-passphrase"636 type="password"637 class="peach-underline-input"638 autocomplete="current-password"639 autocorrect="off"640 autocapitalize="none"641 spellcheck={false}642 disabled={phase() === "restoring"}643 value={passphrase()}644 onInput={(e) => {645 setPassphrase((e.target as HTMLInputElement).value);646 if (restoreError()) setRestoreError(null);647 }}648 onKeyDown={(e) => {649 if (e.key === "Enter" && phase() === "ready") {650 void handleRestore();651 }652 }}653 />654 <Show when={restoreError()}>655 <p class="peach-hint peach-hint-error">{restoreError()}</p>656 </Show>657 <button658 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"659 disabled={phase() === "restoring" || passphrase().length === 0}660 onClick={() => void handleRestore()}661 >662 {phase() === "restoring" ? "restoring…" : "restore vault"}663 </button>664 </div>665 </Show>666667 <Show when={phase() === "success"}>668 <div class="peach-settings-group peach-codex-done">669 <p class="peach-hint peach-hint--success">✓ vault restored</p>670 <p class="peach-codex-meta">671 {restoredCount()}{" "}672 {restoredCount() === 1 ? "entry" : "entries"} recovered.673 </p>674 <p class="peach-codex-footnote">675 your codex has been read and your vault is unlocked.676 </p>677 </div>678 </Show>679680 <Show when={phase() !== "success"}>681 <button682 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"683 onClick={props.onBack}684 >685 cancel686 </button>687 </Show>688 </div>689 </div>690 );691}