12345678910111213import { createSignal, onCleanup, onMount, Show } from "solid-js";14import { sendMessage } from "../../shared/bridge";15import {16 ChunkBuffer,17 parseRawScan,18 reconstructAndVerify,19} from "../codex/restore";20import { startScanner } from "../codex/scanner";21import type { ScannerHandle } from "../codex/scanner";22import type { CodexPayload } from "../../shared/codex-types";2324interface CodexRestoreViewProps {25 onBack: () => void;26 onRestoreSuccess: () => void;27}2829type Phase = "scanning" | "ready" | "restoring" | "success" | "error";3031export function CodexRestoreView(props: CodexRestoreViewProps) {32 33 34 35 36 const buffer = new ChunkBuffer();37 let videoEl: HTMLVideoElement | undefined;38 let scanner: ScannerHandle | null = null;3940 const [phase, setPhase] = createSignal<Phase>("scanning");41 const [scanned, setScanned] = createSignal(0);42 const [totalShards, setTotalShards] = createSignal<number | null>(null);43 const [dataShards, setDataShards] = createSignal<number | null>(null);44 const [verifyCode, setVerifyCode] = createSignal<string | null>(null);45 const [hint, setHint] = createSignal(46 "point your camera at the first code on page 1",47 );48 const [cameraError, setCameraError] = createSignal<string | null>(null);49 const [scanWarning, setScanWarning] = createSignal<string | null>(null);50 51 52 53 const [pendingPayload, setPendingPayload] = createSignal<CodexPayload | null>(54 null,55 );56 const [passphrase, setPassphrase] = createSignal("");57 const [restoreError, setRestoreError] = createSignal<string | null>(null);58 const [restoredCount, setRestoredCount] = createSignal<number | null>(null);5960 function refreshFromBuffer() {61 setScanned(buffer.size());62 const ctx = buffer.getContext();63 if (ctx) {64 setTotalShards(ctx.total);65 setDataShards(ctx.dataShards);66 setVerifyCode(ctx.verifyCode);67 }68 if (buffer.isReady() && phase() === "scanning") {69 setPhase("ready");70 setHint("scan complete. enter your master password to restore.");71 } else if (phase() === "scanning") {72 const ds = ctx?.dataShards;73 if (ds) {74 const remaining = Math.max(ds - buffer.size(), 0);75 setHint(76 remaining > 077 ? `${remaining} more ${remaining === 1 ? "code" : "codes"} needed`78 : "almost there…",79 );80 }81 }82 }8384 function explainScanReason(reason: string): string {85 switch (reason) {86 case "foreign_codex":87 return "that code belongs to a different Codex.";88 case "bad_integrity":89 return "a code's checksum failed — try again.";90 case "duplicate":91 return ""; 92 case "parse_error":93 return "could not read that code.";94 default:95 return "";96 }97 }9899 async function handleScan(raw: string) {100 const parsed = parseRawScan(raw);101 if ("error" in parsed) {102 103 104 return;105 }106107 const result = await buffer.add(parsed);108 refreshFromBuffer();109 if (!result.accepted && result.reason) {110 const msg = explainScanReason(result.reason);111 if (msg) {112 setScanWarning(msg);113 114 setTimeout(() => setScanWarning(null), 2500);115 }116 }117 }118119 function handleScannerError(err: Error) {120 121 setCameraError(122 err.name === "NotAllowedError"123 ? "camera access was denied. allow camera permission and reopen this screen."124 : err.message || "camera failed to start.",125 );126 }127128 onMount(async () => {129 if (!videoEl) return;130 try {131 scanner = await startScanner({132 videoEl,133 onScan: (text) => void handleScan(text),134 onError: (err) => handleScannerError(err),135 });136 } catch (err) {137 handleScannerError(err instanceof Error ? err : new Error(String(err)));138 }139 });140141 onCleanup(() => {142 if (scanner) {143 scanner.stop();144 scanner = null;145 }146 147 148 149 150 if (videoEl?.srcObject) {151 const stream = videoEl.srcObject as MediaStream;152 stream.getTracks().forEach((t) => t.stop());153 videoEl.srcObject = null;154 }155 });156157 function explainRestoreError(error: string | undefined): string {158 if (!error) return "restoration failed. please try again.";159 if (error.includes("WRONG_PASSPHRASE") || error.includes("wrong"))160 return "wrong master password. try again.";161 if (error.includes("INVALID_VAULT_BLOB"))162 return "the reconstructed vault is unreadable. please re-scan the Codex.";163 if (error.includes("FINGERPRINT_MISMATCH"))164 return "vault integrity check failed. please re-scan the Codex.";165 return error;166 }167168 async function handleRestore() {169 if (phase() === "restoring") return;170 const pw = passphrase().trim();171 if (!pw) {172 setRestoreError("enter your master password.");173 return;174 }175176 setPhase("restoring");177 setRestoreError(null);178179 let payload = pendingPayload();180 if (!payload) {181 try {182 payload = await reconstructAndVerify(buffer);183 setPendingPayload(payload);184 } catch (err) {185 const message =186 err instanceof Error187 ? err.message188 : "the scanned codes could not be reassembled.";189 setRestoreError(message);190 setPhase("ready");191 return;192 }193 }194195 const res = (await sendMessage({196 type: "RESTORE_FROM_CODEX" as any,197 payload: {198 vaultBlob: payload.vaultBlob,199 timestamp: payload.timestamp,200 verifyCode: payload.verifyCode,201 vaultFingerprint: payload.vaultFingerprint,202 entryCount: payload.entryCount,203 masterPassphrase: pw,204 },205 })) as {206 ok: boolean;207 data?: { entryCount: number; verifyCode: string };208 error?: string;209 };210211 if (res.ok && res.data) {212 setRestoredCount(res.data.entryCount);213 setPhase("success");214 215 216 if (scanner) {217 scanner.stop();218 scanner = null;219 }220 221 222 setTimeout(() => props.onRestoreSuccess(), 1500);223 } else {224 setRestoreError(explainRestoreError(res.error));225 setPhase("ready");226 }227 }228229 function progressPercent(): number {230 const ds = dataShards();231 if (!ds || ds <= 0) return 0;232 return Math.min(100, Math.round((scanned() / ds) * 100));233 }234235 return (236 <div class="peach-overlay-view ov-enter-right" style="display:flex">237 <div class="peach-overlay-header">238 <button239 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"240 onClick={props.onBack}241 aria-label="Back"242 >243 <svg width="7" height="12" viewBox="0 0 7 12" fill="none"244 stroke="currentColor" stroke-width="1.1"245 stroke-linecap="round" stroke-linejoin="round">246 <path d="M6 1L1 6L6 11" />247 </svg>248 </button>249 <span class="peach-overlay-title">restore from codex</span>250 <div class="peach-overlay-spacer"></div>251 </div>252253 <div class="peach-settings-body peach-codex-body">254 <Show when={phase() !== "success"}>255 <div class="peach-codex-scanner">256 <video257 ref={(el) => (videoEl = el)}258 class="peach-codex-video"259 autoplay260 playsinline261 muted262 />263 <Show when={cameraError()}>264 <div class="peach-codex-camera-overlay">265 <p class="peach-hint peach-hint-error">{cameraError()}</p>266 </div>267 </Show>268 </div>269 </Show>270271 <Show when={phase() === "scanning" || phase() === "ready" || phase() === "restoring"}>272 <div class="peach-settings-group peach-codex-progress-group">273 <div class="peach-codex-progress-track">274 <div275 class="peach-codex-progress-fill"276 style={{ width: `${progressPercent()}%` }}277 />278 </div>279 <p class="peach-codex-progress-label">280 <Show281 when={totalShards() !== null}282 fallback={<span>{scanned()} codes scanned</span>}283 >284 <span>285 {scanned()} / {dataShards()} codes286 </span>287 </Show>288 <Show when={verifyCode()}>289 <span class="peach-codex-verify-tag">290 {verifyCode()}291 </span>292 </Show>293 </p>294 <p class="peach-codex-hint">{hint()}</p>295 <Show when={scanWarning()}>296 <p class="peach-hint peach-hint--warning peach-codex-warning">297 {scanWarning()}298 </p>299 </Show>300 </div>301 </Show>302303 <Show when={phase() === "ready" || phase() === "restoring"}>304 <div class="peach-settings-group peach-codex-passphrase-group">305 <label class="peach-settings-label" for="codex-passphrase">306 master password307 </label>308 <input309 id="codex-passphrase"310 type="password"311 class="peach-underline-input"312 autocomplete="current-password"313 autocorrect="off"314 autocapitalize="none"315 spellcheck={false}316 disabled={phase() === "restoring"}317 value={passphrase()}318 onInput={(e) => {319 setPassphrase((e.target as HTMLInputElement).value);320 if (restoreError()) setRestoreError(null);321 }}322 onKeyDown={(e) => {323 if (e.key === "Enter" && phase() === "ready") {324 void handleRestore();325 }326 }}327 />328 <Show when={restoreError()}>329 <p class="peach-hint peach-hint-error">{restoreError()}</p>330 </Show>331 <button332 class="peach-btn peach-btn--accent peach-btn--md peach-btn--full"333 disabled={phase() === "restoring" || passphrase().length === 0}334 onClick={() => void handleRestore()}335 >336 {phase() === "restoring" ? "restoring…" : "restore vault"}337 </button>338 </div>339 </Show>340341 <Show when={phase() === "success"}>342 <div class="peach-settings-group peach-codex-done">343 <p class="peach-hint peach-hint--success">✓ vault restored</p>344 <p class="peach-codex-meta">345 {restoredCount()}{" "}346 {restoredCount() === 1 ? "entry" : "entries"} recovered.347 </p>348 <p class="peach-codex-footnote">349 your Codex has been read and your vault is unlocked.350 </p>351 </div>352 </Show>353354 <Show when={phase() !== "success"}>355 <button356 class="peach-btn peach-btn--ghost peach-btn--sm peach-btn--full peach-codex-cancel-btn"357 onClick={props.onBack}358 >359 cancel360 </button>361 </Show>362 </div>363 </div>364 );365}