1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +17 '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +18 '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19 '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20 '</svg>'21)}`;222324let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26 if (_zxcvbn) return _zxcvbn;27 const mod = await import("zxcvbn");28 _zxcvbn = mod.default;29 return _zxcvbn;30}3132interface LockProps {33 hasVault: boolean;34 ready: boolean;35 isPro?: boolean;36 373839 forceRecoveryWords?: boolean;40 onUnlock: () => void | Promise<void>;41 424344 onBackToOnboarding?: () => void;45 4647 onOpenCodexRestore?: () => void;48}4950type RestoreSurface = "restore" | null;51type RecoverSurface = "words" | "new-password" | null;5253export function LockView(props: LockProps) {54 const [password, setPassword] = createSignal("");55 const [confirmPassword, setConfirmPassword] = createSignal("");56 const [error, setError] = createSignal("");57 const [loading, setLoading] = createSignal(false);58 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);59 const [showPassword, setShowPassword] = createSignal(false);60 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);61 62 63 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6465 const MIN_LENGTH = 12;66 const MIN_ZXCVBN_SCORE = 2;6768 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);69 const [biometricAvailable, setBiometricAvailable] = createSignal(false);70 const [biometricBusy, setBiometricBusy] = createSignal(false);7172 const canCreate = createMemo(73 () =>74 password().length >= MIN_LENGTH &&75 confirmPassword().length >= MIN_LENGTH &&76 password() === confirmPassword() &&77 strengthScore() !== null &&78 strengthScore()! >= MIN_ZXCVBN_SCORE79 );8081 const passwordStrength = createMemo(() => {82 const score = strengthScore();83 if (score === null) return null;84 if (score <= 1) return "weak";85 if (score === 2) return "fair";86 if (score === 3) return "good";87 return "strong";88 });8990 const createBlockerMessage = createMemo(() => {91 if (!password()) return null;92 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;93 if (!confirmPassword()) return "confirm your password";94 if (password() !== confirmPassword()) return "passwords don't match";95 if (strengthScore() === null) return "checking strength…";96 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";97 return null;98 });99100 101 createMemo(() => {102 const value = password();103 if (!value) { setStrengthScore(null); return; }104 getZxcvbn().then((zxcvbn) => {105 if (password() === value) setStrengthScore(zxcvbn(value).score);106 }).catch(() => {107 108 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);109 });110 });111112 async function handleUnlock() {113 if (!password().trim()) return;114 setLoading(true);115 setError("");116 const response = await sendMessage({117 type: "UNLOCK",118 payload: { password: password() },119 });120 const result = response as { ok: boolean; error?: string };121 if (result.ok) {122 await props.onUnlock();123 setLoading(false);124 return;125 }126 setError(getDisplayError(result.error, "incorrect password"));127 setPassword("");128 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;129 passwordField?.classList.add("shake");130 setTimeout(() => passwordField?.classList.remove("shake"), 300);131 setLoading(false);132 }133134 async function checkBiometricAvailable(): Promise<void> {135 if (!props.hasVault || !isNativePlatform()) return;136 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");137 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");138 if (!keystore || !biometric) return;139 try {140 const stored = await keystore.hasMasterKey();141 if (!stored.exists) return;142 const avail = await biometric.isAvailable();143 if (!avail.available) return;144 setBiometricAvailable(true);145 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);146 } catch { }147 }148149 async function handleBiometricUnlock(): Promise<void> {150 if (biometricBusy()) return;151 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");152 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");153 if (!biometric || !keystore) return;154 setBiometricBusy(true);155 setError("");156 try {157 await biometric.authenticate({158 title: "Unlock Peach",159 subtitle: "Use biometrics to access your vault",160 negativeButtonText: "Use Password",161 });162 const { masterKeyHex } = await keystore.retrieveMasterKey();163 const res = (await sendMessage({164 type: "UNLOCK_WITH_KEY",165 payload: { masterKeyHex },166 })) as { ok: boolean; error?: string };167 if (res.ok) {168 await props.onUnlock();169 return;170 }171 if (res.error === "biometric_key_invalidated") {172 await sendMessage({173 type: "UPDATE_SETTINGS",174 payload: { biometricsEnabled: false },175 });176 setBiometricAvailable(false);177 setError("biometric key expired — re-enable in settings");178 } else {179 setError("biometric unlock failed");180 }181 } catch (e) {182 const reason = e instanceof Error ? e.message : String(e);183 if (reason.includes("user_cancelled")) return;184 if (reason.includes("biometric_key_invalidated")) {185 await sendMessage({186 type: "UPDATE_SETTINGS",187 payload: { biometricsEnabled: false },188 });189 setBiometricAvailable(false);190 setError("biometric key expired — re-enable in settings");191 return;192 }193 if (reason.includes("biometric_lockout")) {194 setError("biometric locked out — use your password");195 }196 } finally {197 setBiometricBusy(false);198 }199 }200201 onMount(() => { void checkBiometricAvailable(); });202203 async function handleCreate() {204 if (!password().trim()) return;205 if (password().length < MIN_LENGTH) {206 setError(`password must be at least ${MIN_LENGTH} characters`);207 return;208 }209 if (password() !== confirmPassword()) {210 setError("passwords do not match");211 return;212 }213 const score = strengthScore();214 if (score === null || score < MIN_ZXCVBN_SCORE) {215 setError("password is too weak — try a longer or more varied passphrase");216 return;217 }218 setLoading(true);219 setError("");220 const response = await sendMessage({221 type: "CREATE_VAULT",222 payload: { password: password() },223 });224 const result = response as { ok: boolean; error?: string };225 if (result.ok) {226 227 228 229 230 231 try {232 await chrome.storage.local?.set({ peach_recovery_pending: "1" });233 } catch { }234 setShowRecoveryWords(true);235 setLoading(false);236 return;237 }238 setError(getDisplayError(result.error, "failed to create vault"));239 setLoading(false);240 }241242 function openRestore(surface: Exclude<RestoreSurface, null>) {243 if (!props.ready || loading()) return;244 setPassword("");245 setConfirmPassword("");246 setError("");247 setRestoreSurface(surface);248 }249250 251 return (252 <Switch>253 {}254 <Match when={restoreSurface() === "restore"}>255 <RestoreTabView256 hasVault={props.hasVault}257 onBack={() => setRestoreSurface(null)}258 onUnlock={async () => {259 setRestoreSurface(null);260 await props.onUnlock();261 }}262 />263 </Match>264265 {}266 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>267 <RecoveryWordDisplayView268 onDone={props.onUnlock}269 />270 </Match>271272 {}273 <Match when={recoverSurface() === "words"}>274 <RecoveryWordEntryView275 onCancel={() => setRecoverSurface(null)}276 onRecoveryComplete={props.onUnlock}277 />278 </Match>279280 {}281 <Match when={props.hasVault}>282 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">283 <header class="peach-screen-header lock-screen-header">284 <div class="peach-wordmark-stack">285 <PeachWordmark class="peach-wordmark-wrap" />286 <Show when={props.isPro}>287 <span class="peach-pro-mini-badge">pro</span>288 </Show>289 </div>290 </header>291292 <div class="peach-lock-hero peach-lock-hero--unlock">293 <div class="peach-lock-hero-art" aria-hidden="true">294 <PeachMark class="peach-lock-hero-mark" />295 </div>296 <span class="peach-lock-eyebrow">vault locked</span>297 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">298 unlock299 <br />300 your vault.301 </h1>302 </div>303304 <LockRuneCanvas />305306 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">307 <label class="peach-label" for="unlock-password">308 master password309 </label>310 <input311 id="unlock-password"312 class="peach-underline-input peach-unlock-input lock-password-input"313 type="password"314 placeholder="••••••••••••"315 autocomplete="current-password"316 value={password()}317 disabled={!props.ready}318 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}319 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320 onKeyDown={(event) => {321 if (event.key !== "Enter" || !props.ready) return;322 void handleUnlock();323 }}324 />325326 <Show when={error()}>327 <p class="peach-hint peach-hint-error">{error()}</p>328 </Show>329330 <button331 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"332 onClick={() => void handleUnlock()}333 disabled={!props.ready || loading()}334 >335 {loading() ? "unlocking..." : "unlock"}336 </button>337338 <Show when={biometricAvailable()}>339 <button340 class="peach-biometric-btn"341 type="button"342 onClick={() => void handleBiometricUnlock()}343 disabled={biometricBusy() || loading()}344 aria-label="Unlock with biometrics"345 >346 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">347 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>348 <path d="M15 17v-3"/>349 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>350 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>351 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>352 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>353 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>354 </svg>355 {biometricBusy() ? "verifying…" : "use biometrics"}356 </button>357 </Show>358359 <button360 class="peach-lock-recover-link"361 type="button"362 onClick={() => setRecoverSurface("words")}363 >364 forgot your password?365 </button>366 </section>367 </section>368 </Match>369370 {}371 <Match when={true}>372 <section373 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"374 data-motion-phase="enter"375 >376 <header class="peach-screen-header peach-newvault-header">377 <Show when={props.onBackToOnboarding}>378 <button379 class="peach-newvault-back"380 type="button"381 onClick={() => props.onBackToOnboarding?.()}382 aria-label="Back to onboarding"383 >384 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">385 <path d="M7 2L3 5.5L7 9" />386 </svg>387 back388 </button>389 </Show>390 <div class="peach-wordmark-stack">391 <PeachWordmark class="peach-wordmark-wrap" />392 <Show when={props.isPro}>393 <span class="peach-pro-mini-badge">pro</span>394 </Show>395 </div>396 </header>397398 <div class="peach-newvault-mark" aria-hidden="true">399 <PeachMark class="peach-newvault-mark-art" />400 </div>401402 <div class="peach-newvault-card">403 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>404 <hr class="peach-newvault-divider" />405406 <div class="peach-newvault-field">407 <div class="peach-newvault-label-row">408 <label class="peach-label" for="master-password">409 master password410 </label>411 <button412 class="peach-lock-reveal-btn"413 type="button"414 onClick={() => setShowPassword((value) => !value)}415 aria-label={showPassword() ? "Hide password" : "Show password"}416 >417 <Show418 when={showPassword()}419 fallback={420 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">421 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />422 <circle cx="7" cy="5" r="1.75" />423 </svg>424 }425 >426 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">427 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />428 <path d="M3.5 5.5L2.5 7.5" />429 <path d="M7 6.5V8.5" />430 <path d="M10.5 5.5L11.5 7.5" />431 </svg>432 </Show>433 {showPassword() ? "hide" : "show"}434 </button>435 </div>436 <input437 id="master-password"438 class="peach-underline-input peach-newvault-input lock-password-input"439 type={showPassword() ? "text" : "password"}440 placeholder="••••••••••••"441 autocomplete="new-password"442 value={password()}443 disabled={!props.ready}444 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}445 onKeyDown={(event) => {446 if (event.key !== "Enter" || !props.ready) return;447 void handleCreate();448 }}449 />450 <Show451 when={password().length > 0 && passwordStrength() !== null}452 fallback={453 <p class="peach-newvault-hint">at least 12 characters</p>454 }455 >456 <div class="peach-password-strength">457 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>458 <div class="peach-strength-fill"></div>459 </div>460 <span class="peach-strength-label">{passwordStrength()}</span>461 </div>462 </Show>463 </div>464465 <div class="peach-newvault-field">466 <label class="peach-label" for="confirm-password">467 confirm password468 </label>469 <input470 id="confirm-password"471 class="peach-underline-input peach-newvault-input"472 type={showPassword() ? "text" : "password"}473 placeholder="••••••••••••"474 autocomplete="new-password"475 value={confirmPassword()}476 disabled={!props.ready}477 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}478 onKeyDown={(event) => {479 if (event.key === "Enter" && props.ready) void handleCreate();480 }}481 />482 </div>483484 <Show when={error()}>485 <p class="peach-hint peach-hint-error">{error()}</p>486 </Show>487488 <button489 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"490 onClick={() => void handleCreate()}491 disabled={!props.ready || loading() || !canCreate()}492 aria-live="polite"493 >494 <Show495 when={!canCreate() && createBlockerMessage()}496 fallback={loading() ? "creating..." : "create vault"}497 >498 {createBlockerMessage()}499 </Show>500 </button>501502 <p class="peach-newvault-warning">503 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>504 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>505 </p>506 </div>507508 <div class="peach-newvault-footer">509 <p class="peach-hint">Already have a backup?</p>510 <button511 class="peach-lock-restore-link"512 onClick={() => void openRestore("restore")}513 disabled={!props.ready}514 >515 › restore from backup516 </button>517 <Show when={props.onOpenCodexRestore}>518 <button519 class="peach-lock-restore-link"520 onClick={() => props.onOpenCodexRestore?.()}521 disabled={!props.ready}522 >523 › restore from peach codex524 </button>525 </Show>526 </div>527 </section>528 </Match>529 </Switch>530 );531}532533534535type RestoreTab = "symbols" | "csv";536537function tabLabel(tab: RestoreTab): string {538 return (539 { symbols: "PeachScript", csv: "CSV" }[tab]540 );541}542543function RestoreTabView(props: {544 hasVault: boolean;545 onBack: () => void;546 onUnlock: () => void | Promise<void>;547}) {548 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");549 const [showIntro, setShowIntro] = createSignal(false);550 const [pendingUnlock] = createSignal(false);551552 async function handleRestoreSuccess() {553 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);554 if (!seen) {555 setShowIntro(true);556 } else {557 await props.onUnlock();558 }559 }560561 async function dismissIntro() {562 await storageSet("peach_shown_restore_intro", "true").catch(() => {});563 setShowIntro(false);564 await props.onUnlock();565 }566567 return (568 <section569 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"570 data-motion-phase="enter"571 >572 <RuneBackground opacity={0.05} />573574 <header class="peach-screen-header lock-screen-header">575 <PeachWordmark class="peach-wordmark-wrap" />576 <button class="peach-lock-restore-link" onClick={props.onBack}>577 ‹ back578 </button>579 </header>580581 <div class="peach-newvault-card peach-restore-tabcard">582 <div class="peach-restore-tabs" role="tablist">583 <For each={["symbols", "csv"] as RestoreTab[]}>584 {(tab) => (585 <button586 class="peach-restore-tab"587 classList={{ "is-active": activeTab() === tab }}588 onClick={() => setActiveTab(tab)}589 role="tab"590 aria-selected={activeTab() === tab}591 >592 {tabLabel(tab)}593 </button>594 )}595 </For>596 </div>597 <div class="peach-restore-content">598 <Show when={activeTab() === "symbols"}>599 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />600 </Show>601 <Show when={activeTab() === "csv"}>602 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603 </Show>604 </div>605 </div>606607 {}608 <Show when={showIntro()}>609 <div class="peach-restore-intro-overlay">610 <div class="peach-restore-intro-card">611 <p class="peach-restore-intro-headline">welcome to peach.</p>612 <div class="peach-restore-intro-body">613 <p>Your vault lives only on this device. No server has a copy.</p>614 <p>The inline bubble fills passwords automatically when you visit sites.</p>615 <p>Export your vault from Settings before switching devices.</p>616 </div>617 <button618 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"619 onClick={() => void dismissIntro()}620 disabled={pendingUnlock()}621 >622 got it623 </button>624 </div>625 </div>626 </Show>627 </section>628 );629}630631632633function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {634 const [scriptInput, setScriptInput] = createSignal("");635 const [backupPass, setBackupPass] = createSignal("");636 const [working, setWorking] = createSignal(false);637 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);638 const [error, setError] = createSignal<string | null>(null);639 let canvasRef: HTMLCanvasElement | undefined;640 let animFrameId = 0;641 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];642643 const scriptValid = createMemo(() => isPeachScript(scriptInput()));644645 function stopAnim() {646 cancelAnimationFrame(animFrameId);647 animFrameId = 0;648 }649650 function startChaos(canvas: HTMLCanvasElement) {651 const ctx = canvas.getContext("2d")!;652 const W = canvas.width;653 const H = canvas.height;654 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>655 String.fromCodePoint(0xe000 + i)656 );657 if (runes.length === 0) {658 runes = Array.from({ length: 60 }, () => ({659 x: Math.random() * W,660 y: Math.random() * H,661 vx: (Math.random() - 0.5) * 3,662 vy: (Math.random() - 0.5) * 3,663 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,664 size: 14 + Math.random() * 20,665 angle: Math.random() * Math.PI * 2,666 va: (Math.random() - 0.5) * 0.1,667 }));668 }669670 function draw() {671 ctx.clearRect(0, 0, W, H);672 for (const r of runes) {673 r.x = (r.x + r.vx + W) % W;674 r.y = (r.y + r.vy + H) % H;675 r.angle += r.va;676 ctx.save();677 ctx.translate(r.x, r.y);678 ctx.rotate(r.angle);679 ctx.font = `${r.size}px PeachScript, monospace`;680 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);681 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;682 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";683 ctx.shadowBlur = 8;684 ctx.fillText(r.char, 0, 0);685 ctx.restore();686 }687 animFrameId = requestAnimationFrame(draw);688 }689 draw();690 }691692 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {693 stopAnim();694 const ctx = canvas.getContext("2d")!;695 const W = canvas.width;696 const H = canvas.height;697 const cols = 8;698 const rows = 6;699 const cellW = W / cols;700 const cellH = H / rows;701 const targets = runes.slice(0, cols * rows).map((_, i) => ({702 x: (i % cols) * cellW + cellW / 2,703 y: Math.floor(i / cols) * cellH + cellH / 2,704 }));705 const start = performance.now();706 const DURATION = 400;707708 function drawSnap(now: number) {709 const t = Math.min((now - start) / DURATION, 1);710 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;711 ctx.clearRect(0, 0, W, H);712 runes.slice(0, targets.length).forEach((r, i) => {713 const tx = targets[i]!.x;714 const ty = targets[i]!.y;715 const cx = r.x + (tx - r.x) * ease;716 const cy = r.y + (ty - r.y) * ease;717 ctx.font = `16px PeachScript, monospace`;718 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;719 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";720 ctx.shadowBlur = 6;721 ctx.fillText(r.char, cx - 8, cy + 8);722 });723 if (t < 1) {724 animFrameId = requestAnimationFrame(drawSnap);725 } else {726 onDone();727 }728 }729 animFrameId = requestAnimationFrame(drawSnap);730 }731732 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {733 stopAnim();734 const ctx = canvas.getContext("2d")!;735 const W = canvas.width;736 const H = canvas.height;737 const start = performance.now();738 const DURATION = 900;739740 function drawBloom(now: number) {741 const t = Math.min((now - start) / DURATION, 1);742 ctx.clearRect(0, 0, W, H);743 744 const runeAlpha = 1 - Math.min(t / 0.4, 1);745 if (runeAlpha > 0) {746 for (const r of runes.slice(0, 48)) {747 ctx.font = `16px PeachScript, monospace`;748 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;749 ctx.fillText(r.char, r.x - 8, r.y + 8);750 }751 }752 753 const markT = Math.max(0, (t - 0.2) / 0.5);754 const markScale = Math.min(markT * 1.2, 1);755 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;756 if (markAlpha > 0) {757 ctx.save();758 ctx.globalAlpha = markAlpha;759 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;760 ctx.shadowBlur = 30;761 ctx.translate(W / 2, H / 2);762 ctx.scale(markScale, markScale);763 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);764 ctx.restore();765 }766 if (t < 1) {767 animFrameId = requestAnimationFrame(drawBloom);768 } else {769 onDone();770 }771 }772 animFrameId = requestAnimationFrame(drawBloom);773 }774775 onCleanup(() => stopAnim());776777 async function handleRestore() {778 if (!scriptValid() || !backupPass().trim() || working()) return;779 setError(null);780 setWorking(true);781 setAnimating("chaos");782783 const canvas = canvasRef;784 if (canvas) startChaos(canvas);785786 try {787 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));788 const payload = await parsePayload(bytes, backupPass());789790 if (!props.hasVault) {791 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });792 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");793 }794795 const rr = await sendMessage({796 type: "RESTORE_ENTRIES",797 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },798 });799 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");800801 if (payload.license?.key) {802 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});803 }804805 setAnimating("snap");806 stopAnim();807 if (canvas) {808 snapToGrid(canvas, () => {809 setAnimating("bloom");810 bloom(canvas, async () => {811 setAnimating(null);812 await props.onSuccess();813 });814 });815 } else {816 await props.onSuccess();817 }818 } catch (err) {819 stopAnim();820 setAnimating(null);821 const code = err instanceof Error ? err.message : String(err);822 setError(823 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"824 ? "That passphrase didn't work."825 : code === "CREATE_VAULT_FAILED"826 ? "Couldn't create a new local vault."827 : "Could not read this backup.",828 );829 } finally {830 setWorking(false);831 }832 }833834 return (835 <div class="peach-rt-symbols">836 <Show when={animating() !== null}>837 <canvas838 ref={(el) => { canvasRef = el; }}839 class="peach-rt-anim-canvas"840 width={280}841 height={200}842 />843 </Show>844 <Show when={animating() === null}>845 <textarea846 class="peach-restore-symbols-textarea"847 placeholder="paste peachscript…"848 rows={3}849 value={scriptInput()}850 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}851 />852 <div class="peach-rt-divider">— or —</div>853 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">854 open .peach file855 <input856 type="file"857 accept=".peach,text/plain"858 style="display:none"859 onChange={async (e) => {860 const file = (e.currentTarget as HTMLInputElement).files?.[0];861 if (!file) return;862 try {863 setScriptInput(await file.text());864 } catch {865 setError("Couldn't read file.");866 } finally {867 (e.currentTarget as HTMLInputElement).value = "";868 }869 }}870 />871 </label>872 <input873 class="peach-restore-symbols-pass"874 type="password"875 placeholder="backup password"876 value={backupPass()}877 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}878 onKeyDown={(e) => {879 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {880 e.preventDefault();881 void handleRestore();882 }883 }}884 />885 <button886 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"887 onClick={() => void handleRestore()}888 disabled={!scriptValid() || !backupPass().trim() || working()}889 >890 restore vault →891 </button>892 <Show when={error()}>893 <p class="peach-rt-error">{error()}</p>894 </Show>895 </Show>896 </div>897 );898}899900901902function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {903 const [importing, setImporting] = createSignal(false);904 const [error, setError] = createSignal<string | null>(null);905906 async function handleFile(e: Event) {907 const input = e.currentTarget as HTMLInputElement;908 const file = input.files?.[0];909 input.value = "";910 if (!file) return;911 setImporting(true);912 setError(null);913 try {914 const text = await file.text();915 const parsed = await parseImportSource(text);916 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");917918 if (props.hasVault === false) {919 920 921 922 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });923 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");924 }925926 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });927 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");928 if (parsed.licenseKey) {929 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});930 }931 await props.onSuccess();932 } catch (err) {933 const code = err instanceof Error ? err.message : String(err);934 setError(935 code === "NO_ENTRIES" ? "No entries found in this file." :936 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :937 "Import failed. Check the file format and try again."938 );939 } finally {940 setImporting(false);941 }942 }943944 return (945 <div class="peach-rt-csv">946 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>947 {importing() ? "importing…" : "open csv file"}948 <input949 type="file"950 accept=".csv,.json,.peach,application/x-peach"951 style="display:none"952 onChange={handleFile}953 disabled={importing()}954 />955 </label>956 <p class="peach-rt-csv-sources">957 Imports from Bitwarden, 1Password, Proton Pass,<br />958 Chrome, Safari, and Edge.959 </p>960 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>961 <Show when={error()}>962 <p class="peach-rt-error">{error()}</p>963 </Show>964 </div>965 );966}967968969970function getDisplayError(error: string | undefined, fallback: string): string {971 if (!error) return fallback;972 if (973 error === "SERVICE_WORKER_UNAVAILABLE" ||974 error.includes("Receiving end does not exist") ||975 error.includes("message port closed")976 ) {977 return "background service unavailable";978 }979 return error;980}