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 240 240" fill="none">' +17 '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18 '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21 '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22 '</svg>'23)}`;242526let _zxcvbn: ((password: string) => { score: number }) | null = null;27async function getZxcvbn() {28 if (_zxcvbn) return _zxcvbn;29 const mod = await import("zxcvbn");30 _zxcvbn = mod.default;31 return _zxcvbn;32}3334interface LockProps {35 hasVault: boolean;36 ready: boolean;37 isPro?: boolean;38 394041 forceRecoveryWords?: boolean;42 onUnlock: () => void | Promise<void>;43 444546 onBackToOnboarding?: () => void;47 4849 onOpenCodexRestore?: () => void;50}5152type RestoreSurface = "restore" | null;53type RecoverSurface = "words" | "new-password" | null;5455export function LockView(props: LockProps) {56 const [password, setPassword] = createSignal("");57 const [confirmPassword, setConfirmPassword] = createSignal("");58 const [error, setError] = createSignal("");59 const [loading, setLoading] = createSignal(false);60 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);61 const [showPassword, setShowPassword] = createSignal(false);62 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);63 64 65 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6667 const MIN_LENGTH = 12;68 const MIN_ZXCVBN_SCORE = 2;6970 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);71 const [biometricAvailable, setBiometricAvailable] = createSignal(false);72 const [biometricBusy, setBiometricBusy] = createSignal(false);7374 const canCreate = createMemo(75 () =>76 password().length >= MIN_LENGTH &&77 confirmPassword().length >= MIN_LENGTH &&78 password() === confirmPassword() &&79 strengthScore() !== null &&80 strengthScore()! >= MIN_ZXCVBN_SCORE81 );8283 const passwordStrength = createMemo(() => {84 const score = strengthScore();85 if (score === null) return null;86 if (score <= 1) return "weak";87 if (score === 2) return "fair";88 if (score === 3) return "good";89 return "strong";90 });9192 const createBlockerMessage = createMemo(() => {93 if (!password()) return null;94 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;95 if (!confirmPassword()) return "confirm your password";96 if (password() !== confirmPassword()) return "passwords don't match";97 if (strengthScore() === null) return "checking strength…";98 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";99 return null;100 });101102 103 createMemo(() => {104 const value = password();105 if (!value) { setStrengthScore(null); return; }106 getZxcvbn().then((zxcvbn) => {107 if (password() === value) setStrengthScore(zxcvbn(value).score);108 }).catch(() => {109 110 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);111 });112 });113114 async function handleUnlock() {115 if (!password().trim()) return;116 setLoading(true);117 setError("");118 const response = await sendMessage({119 type: "UNLOCK",120 payload: { password: password() },121 });122 const result = response as { ok: boolean; error?: string };123 if (result.ok) {124 await props.onUnlock();125 setLoading(false);126 return;127 }128 setError(getDisplayError(result.error, "incorrect password"));129 setPassword("");130 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;131 passwordField?.classList.add("shake");132 setTimeout(() => passwordField?.classList.remove("shake"), 300);133 setLoading(false);134 }135136 async function checkBiometricAvailable(): Promise<void> {137 if (!props.hasVault || !isNativePlatform()) return;138 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");139 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");140 if (!keystore || !biometric) return;141 try {142 const stored = await keystore.hasMasterKey();143 if (!stored.exists) return;144 const avail = await biometric.isAvailable();145 if (!avail.available) return;146 setBiometricAvailable(true);147 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);148 } catch { }149 }150151 async function handleBiometricUnlock(): Promise<void> {152 if (biometricBusy()) return;153 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");154 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");155 if (!biometric || !keystore) return;156 setBiometricBusy(true);157 setError("");158 try {159 await biometric.authenticate({160 title: "Unlock Peach",161 subtitle: "Use biometrics to access your vault",162 negativeButtonText: "Use Password",163 });164 const { masterKeyHex } = await keystore.retrieveMasterKey();165 const res = (await sendMessage({166 type: "UNLOCK_WITH_KEY",167 payload: { masterKeyHex },168 })) as { ok: boolean; error?: string };169 if (res.ok) {170 await props.onUnlock();171 return;172 }173 if (res.error === "biometric_key_invalidated") {174 await sendMessage({175 type: "UPDATE_SETTINGS",176 payload: { biometricsEnabled: false },177 });178 setBiometricAvailable(false);179 setError("biometric key expired — re-enable in settings");180 } else {181 setError("biometric unlock failed");182 }183 } catch (e) {184 const reason = e instanceof Error ? e.message : String(e);185 if (reason.includes("user_cancelled")) return;186 if (reason.includes("biometric_key_invalidated")) {187 await sendMessage({188 type: "UPDATE_SETTINGS",189 payload: { biometricsEnabled: false },190 });191 setBiometricAvailable(false);192 setError("biometric key expired — re-enable in settings");193 return;194 }195 if (reason.includes("biometric_lockout")) {196 setError("biometric locked out — use your password");197 }198 } finally {199 setBiometricBusy(false);200 }201 }202203 onMount(() => { void checkBiometricAvailable(); });204205 async function handleCreate() {206 if (!password().trim()) return;207 if (password().length < MIN_LENGTH) {208 setError(`password must be at least ${MIN_LENGTH} characters`);209 return;210 }211 if (password() !== confirmPassword()) {212 setError("passwords do not match");213 return;214 }215 const score = strengthScore();216 if (score === null || score < MIN_ZXCVBN_SCORE) {217 setError("password is too weak — try a longer or more varied passphrase");218 return;219 }220 setLoading(true);221 setError("");222 const response = await sendMessage({223 type: "CREATE_VAULT",224 payload: { password: password() },225 });226 const result = response as { ok: boolean; error?: string };227 if (result.ok) {228 229 230 231 232 233 try {234 await chrome.storage.local?.set({ peach_recovery_pending: "1" });235 } catch { }236 setShowRecoveryWords(true);237 setLoading(false);238 return;239 }240 setError(getDisplayError(result.error, "failed to create vault"));241 setLoading(false);242 }243244 function openRestore(surface: Exclude<RestoreSurface, null>) {245 if (!props.ready || loading()) return;246 setPassword("");247 setConfirmPassword("");248 setError("");249 setRestoreSurface(surface);250 }251252 253 return (254 <Switch>255 {}256 <Match when={restoreSurface() === "restore"}>257 <RestoreTabView258 hasVault={props.hasVault}259 onBack={() => setRestoreSurface(null)}260 onUnlock={async () => {261 setRestoreSurface(null);262 await props.onUnlock();263 }}264 />265 </Match>266267 {}268 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>269 <RecoveryWordDisplayView270 onDone={props.onUnlock}271 />272 </Match>273274 {}275 <Match when={recoverSurface() === "words"}>276 <RecoveryWordEntryView277 onCancel={() => setRecoverSurface(null)}278 onRecoveryComplete={props.onUnlock}279 />280 </Match>281282 {}283 <Match when={props.hasVault}>284 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">285 <header class="peach-screen-header lock-screen-header">286 <div class="peach-wordmark-stack">287 <PeachWordmark class="peach-wordmark-wrap" />288 <Show when={props.isPro}>289 <span class="peach-pro-mini-badge">pro</span>290 </Show>291 </div>292 </header>293294 <div class="peach-lock-hero peach-lock-hero--unlock">295 <div class="peach-lock-hero-art" aria-hidden="true">296 <PeachMark class="peach-lock-hero-mark" />297 </div>298 <span class="peach-lock-eyebrow">vault locked</span>299 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">300 unlock301 <br />302 your vault.303 </h1>304 </div>305306 <LockRuneCanvas />307308 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">309 <label class="peach-label" for="unlock-password">310 master password311 </label>312 <input313 id="unlock-password"314 class="peach-underline-input peach-unlock-input lock-password-input"315 type="password"316 placeholder="••••••••••••"317 autocomplete="current-password"318 value={password()}319 disabled={!props.ready}320 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}321 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}322 onKeyDown={(event) => {323 if (event.key !== "Enter" || !props.ready) return;324 void handleUnlock();325 }}326 />327328 <Show when={error()}>329 <p class="peach-hint peach-hint-error">{error()}</p>330 </Show>331332 <button333 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"334 onClick={() => void handleUnlock()}335 disabled={!props.ready || loading()}336 >337 {loading() ? "unlocking..." : "unlock"}338 </button>339340 <Show when={biometricAvailable()}>341 <button342 class="peach-biometric-btn"343 type="button"344 onClick={() => void handleBiometricUnlock()}345 disabled={biometricBusy() || loading()}346 aria-label="Unlock with biometrics"347 >348 <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">349 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>350 <path d="M15 17v-3"/>351 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>352 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>353 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>354 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>355 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>356 </svg>357 {biometricBusy() ? "verifying…" : "use biometrics"}358 </button>359 </Show>360361 <button362 class="peach-lock-recover-link"363 type="button"364 onClick={() => setRecoverSurface("words")}365 >366 forgot your password?367 </button>368 </section>369 </section>370 </Match>371372 {}373 <Match when={true}>374 <section375 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"376 data-motion-phase="enter"377 >378 <header class="peach-screen-header peach-newvault-header">379 <Show when={props.onBackToOnboarding}>380 <button381 class="peach-newvault-back"382 type="button"383 onClick={() => props.onBackToOnboarding?.()}384 aria-label="Back to onboarding"385 >386 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">387 <path d="M7 2L3 5.5L7 9" />388 </svg>389 back390 </button>391 </Show>392 <div class="peach-wordmark-stack">393 <PeachWordmark class="peach-wordmark-wrap" />394 <Show when={props.isPro}>395 <span class="peach-pro-mini-badge">pro</span>396 </Show>397 </div>398 </header>399400 <div class="peach-newvault-mark" aria-hidden="true">401 <PeachMark class="peach-newvault-mark-art" />402 </div>403404 <div class="peach-newvault-card">405 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>406 <hr class="peach-newvault-divider" />407408 <div class="peach-newvault-field">409 <div class="peach-newvault-label-row">410 <label class="peach-label" for="master-password">411 master password412 </label>413 <button414 class="peach-lock-reveal-btn"415 type="button"416 onClick={() => setShowPassword((value) => !value)}417 aria-label={showPassword() ? "Hide password" : "Show password"}418 >419 <Show420 when={showPassword()}421 fallback={422 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">423 <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" />424 <circle cx="7" cy="5" r="1.75" />425 </svg>426 }427 >428 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">429 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />430 <path d="M3.5 5.5L2.5 7.5" />431 <path d="M7 6.5V8.5" />432 <path d="M10.5 5.5L11.5 7.5" />433 </svg>434 </Show>435 {showPassword() ? "hide" : "show"}436 </button>437 </div>438 <input439 id="master-password"440 class="peach-underline-input peach-newvault-input lock-password-input"441 type={showPassword() ? "text" : "password"}442 placeholder="••••••••••••"443 autocomplete="new-password"444 value={password()}445 disabled={!props.ready}446 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}447 onKeyDown={(event) => {448 if (event.key !== "Enter" || !props.ready) return;449 void handleCreate();450 }}451 />452 <Show453 when={password().length > 0 && passwordStrength() !== null}454 fallback={455 <p class="peach-newvault-hint">at least 12 characters</p>456 }457 >458 <div class="peach-password-strength">459 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>460 <div class="peach-strength-fill"></div>461 </div>462 <span class="peach-strength-label">{passwordStrength()}</span>463 </div>464 </Show>465 </div>466467 <div class="peach-newvault-field">468 <label class="peach-label" for="confirm-password">469 confirm password470 </label>471 <input472 id="confirm-password"473 class="peach-underline-input peach-newvault-input"474 type={showPassword() ? "text" : "password"}475 placeholder="••••••••••••"476 autocomplete="new-password"477 value={confirmPassword()}478 disabled={!props.ready}479 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}480 onKeyDown={(event) => {481 if (event.key === "Enter" && props.ready) void handleCreate();482 }}483 />484 </div>485486 <Show when={error()}>487 <p class="peach-hint peach-hint-error">{error()}</p>488 </Show>489490 <button491 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"492 onClick={() => void handleCreate()}493 disabled={!props.ready || loading() || !canCreate()}494 aria-live="polite"495 >496 <Show497 when={!canCreate() && createBlockerMessage()}498 fallback={loading() ? "creating..." : "create vault"}499 >500 {createBlockerMessage()}501 </Show>502 </button>503504 <p class="peach-newvault-warning">505 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>506 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>507 </p>508 </div>509510 <div class="peach-newvault-footer">511 <p class="peach-hint">Already have a backup?</p>512 <button513 class="peach-lock-restore-link"514 onClick={() => void openRestore("restore")}515 disabled={!props.ready}516 >517 › restore from backup518 </button>519 <Show when={props.onOpenCodexRestore}>520 <button521 class="peach-lock-restore-link"522 onClick={() => props.onOpenCodexRestore?.()}523 disabled={!props.ready}524 >525 › restore from peach codex526 </button>527 </Show>528 </div>529 </section>530 </Match>531 </Switch>532 );533}534535536537type RestoreTab = "symbols" | "csv";538539function tabLabel(tab: RestoreTab): string {540 return (541 { symbols: "PeachScript", csv: "CSV" }[tab]542 );543}544545function RestoreTabView(props: {546 hasVault: boolean;547 onBack: () => void;548 onUnlock: () => void | Promise<void>;549}) {550 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");551 const [showIntro, setShowIntro] = createSignal(false);552 const [pendingUnlock] = createSignal(false);553554 async function handleRestoreSuccess() {555 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);556 if (!seen) {557 setShowIntro(true);558 } else {559 await props.onUnlock();560 }561 }562563 async function dismissIntro() {564 await storageSet("peach_shown_restore_intro", "true").catch(() => {});565 setShowIntro(false);566 await props.onUnlock();567 }568569 return (570 <section571 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"572 data-motion-phase="enter"573 >574 <RuneBackground opacity={0.05} />575576 <header class="peach-screen-header lock-screen-header">577 <PeachWordmark class="peach-wordmark-wrap" />578 <button class="peach-lock-restore-link" onClick={props.onBack}>579 ‹ back580 </button>581 </header>582583 <div class="peach-newvault-card peach-restore-tabcard">584 <div class="peach-restore-tabs" role="tablist">585 <For each={["symbols", "csv"] as RestoreTab[]}>586 {(tab) => (587 <button588 class="peach-restore-tab"589 classList={{ "is-active": activeTab() === tab }}590 onClick={() => setActiveTab(tab)}591 role="tab"592 aria-selected={activeTab() === tab}593 >594 {tabLabel(tab)}595 </button>596 )}597 </For>598 </div>599 <div class="peach-restore-content">600 <Show when={activeTab() === "symbols"}>601 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />602 </Show>603 <Show when={activeTab() === "csv"}>604 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />605 </Show>606 </div>607 </div>608609 {}610 <Show when={showIntro()}>611 <div class="peach-restore-intro-overlay">612 <div class="peach-restore-intro-card">613 <p class="peach-restore-intro-headline">welcome to peach.</p>614 <div class="peach-restore-intro-body">615 <p>Your vault lives only on this device. No server has a copy.</p>616 <p>The inline bubble fills passwords automatically when you visit sites.</p>617 <p>Export your vault from Settings before switching devices.</p>618 </div>619 <button620 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"621 onClick={() => void dismissIntro()}622 disabled={pendingUnlock()}623 >624 got it625 </button>626 </div>627 </div>628 </Show>629 </section>630 );631}632633634635function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {636 const [scriptInput, setScriptInput] = createSignal("");637 const [backupPass, setBackupPass] = createSignal("");638 const [working, setWorking] = createSignal(false);639 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);640 const [error, setError] = createSignal<string | null>(null);641 let canvasRef: HTMLCanvasElement | undefined;642 let animFrameId = 0;643 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];644645 const scriptValid = createMemo(() => isPeachScript(scriptInput()));646647 function stopAnim() {648 cancelAnimationFrame(animFrameId);649 animFrameId = 0;650 }651652 function startChaos(canvas: HTMLCanvasElement) {653 const ctx = canvas.getContext("2d")!;654 const W = canvas.width;655 const H = canvas.height;656 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>657 String.fromCodePoint(0xe000 + i)658 );659 if (runes.length === 0) {660 runes = Array.from({ length: 60 }, () => ({661 x: Math.random() * W,662 y: Math.random() * H,663 vx: (Math.random() - 0.5) * 3,664 vy: (Math.random() - 0.5) * 3,665 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,666 size: 14 + Math.random() * 20,667 angle: Math.random() * Math.PI * 2,668 va: (Math.random() - 0.5) * 0.1,669 }));670 }671672 function draw() {673 ctx.clearRect(0, 0, W, H);674 for (const r of runes) {675 r.x = (r.x + r.vx + W) % W;676 r.y = (r.y + r.vy + H) % H;677 r.angle += r.va;678 ctx.save();679 ctx.translate(r.x, r.y);680 ctx.rotate(r.angle);681 ctx.font = `${r.size}px PeachScript, monospace`;682 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);683 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;684 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";685 ctx.shadowBlur = 8;686 ctx.fillText(r.char, 0, 0);687 ctx.restore();688 }689 animFrameId = requestAnimationFrame(draw);690 }691 draw();692 }693694 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {695 stopAnim();696 const ctx = canvas.getContext("2d")!;697 const W = canvas.width;698 const H = canvas.height;699 const cols = 8;700 const rows = 6;701 const cellW = W / cols;702 const cellH = H / rows;703 const targets = runes.slice(0, cols * rows).map((_, i) => ({704 x: (i % cols) * cellW + cellW / 2,705 y: Math.floor(i / cols) * cellH + cellH / 2,706 }));707 const start = performance.now();708 const DURATION = 400;709710 function drawSnap(now: number) {711 const t = Math.min((now - start) / DURATION, 1);712 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;713 ctx.clearRect(0, 0, W, H);714 runes.slice(0, targets.length).forEach((r, i) => {715 const tx = targets[i]!.x;716 const ty = targets[i]!.y;717 const cx = r.x + (tx - r.x) * ease;718 const cy = r.y + (ty - r.y) * ease;719 ctx.font = `16px PeachScript, monospace`;720 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;721 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";722 ctx.shadowBlur = 6;723 ctx.fillText(r.char, cx - 8, cy + 8);724 });725 if (t < 1) {726 animFrameId = requestAnimationFrame(drawSnap);727 } else {728 onDone();729 }730 }731 animFrameId = requestAnimationFrame(drawSnap);732 }733734 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {735 stopAnim();736 const ctx = canvas.getContext("2d")!;737 const W = canvas.width;738 const H = canvas.height;739 const start = performance.now();740 const DURATION = 900;741742 function drawBloom(now: number) {743 const t = Math.min((now - start) / DURATION, 1);744 ctx.clearRect(0, 0, W, H);745 746 const runeAlpha = 1 - Math.min(t / 0.4, 1);747 if (runeAlpha > 0) {748 for (const r of runes.slice(0, 48)) {749 ctx.font = `16px PeachScript, monospace`;750 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;751 ctx.fillText(r.char, r.x - 8, r.y + 8);752 }753 }754 755 const markT = Math.max(0, (t - 0.2) / 0.5);756 const markScale = Math.min(markT * 1.2, 1);757 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;758 if (markAlpha > 0) {759 ctx.save();760 ctx.globalAlpha = markAlpha;761 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;762 ctx.shadowBlur = 30;763 ctx.translate(W / 2, H / 2);764 ctx.scale(markScale, markScale);765 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);766 ctx.restore();767 }768 if (t < 1) {769 animFrameId = requestAnimationFrame(drawBloom);770 } else {771 onDone();772 }773 }774 animFrameId = requestAnimationFrame(drawBloom);775 }776777 onCleanup(() => stopAnim());778779 async function handleRestore() {780 if (!scriptValid() || !backupPass().trim() || working()) return;781 setError(null);782 setWorking(true);783 setAnimating("chaos");784785 const canvas = canvasRef;786 if (canvas) startChaos(canvas);787788 try {789 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));790 const payload = await parsePayload(bytes, backupPass());791792 if (!props.hasVault) {793 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });794 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");795 }796797 const rr = await sendMessage({798 type: "RESTORE_ENTRIES",799 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },800 });801 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");802803 if (payload.license?.key) {804 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});805 }806807 setAnimating("snap");808 stopAnim();809 if (canvas) {810 snapToGrid(canvas, () => {811 setAnimating("bloom");812 bloom(canvas, async () => {813 setAnimating(null);814 await props.onSuccess();815 });816 });817 } else {818 await props.onSuccess();819 }820 } catch (err) {821 stopAnim();822 setAnimating(null);823 const code = err instanceof Error ? err.message : String(err);824 setError(825 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"826 ? "That passphrase didn't work."827 : code === "CREATE_VAULT_FAILED"828 ? "Couldn't create a new local vault."829 : "Could not read this backup.",830 );831 } finally {832 setWorking(false);833 }834 }835836 return (837 <div class="peach-rt-symbols">838 <Show when={animating() !== null}>839 <canvas840 ref={(el) => { canvasRef = el; }}841 class="peach-rt-anim-canvas"842 width={280}843 height={200}844 />845 </Show>846 <Show when={animating() === null}>847 <textarea848 class="peach-restore-symbols-textarea"849 placeholder="paste peachscript…"850 rows={3}851 value={scriptInput()}852 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}853 />854 <div class="peach-rt-divider">— or —</div>855 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">856 open .peach file857 <input858 type="file"859 accept=".peach,text/plain"860 style="display:none"861 onChange={async (e) => {862 const file = (e.currentTarget as HTMLInputElement).files?.[0];863 if (!file) return;864 try {865 setScriptInput(await file.text());866 } catch {867 setError("Couldn't read file.");868 } finally {869 (e.currentTarget as HTMLInputElement).value = "";870 }871 }}872 />873 </label>874 <input875 class="peach-restore-symbols-pass"876 type="password"877 placeholder="backup password"878 value={backupPass()}879 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}880 onKeyDown={(e) => {881 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {882 e.preventDefault();883 void handleRestore();884 }885 }}886 />887 <button888 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"889 onClick={() => void handleRestore()}890 disabled={!scriptValid() || !backupPass().trim() || working()}891 >892 restore vault →893 </button>894 <Show when={error()}>895 <p class="peach-rt-error">{error()}</p>896 </Show>897 </Show>898 </div>899 );900}901902903904function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {905 const [importing, setImporting] = createSignal(false);906 const [error, setError] = createSignal<string | null>(null);907908 async function handleFile(e: Event) {909 const input = e.currentTarget as HTMLInputElement;910 const file = input.files?.[0];911 input.value = "";912 if (!file) return;913 setImporting(true);914 setError(null);915 try {916 const text = await file.text();917 const parsed = await parseImportSource(text);918 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");919920 if (props.hasVault === false) {921 922 923 924 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });925 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");926 }927928 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });929 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");930 if (parsed.licenseKey) {931 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});932 }933 await props.onSuccess();934 } catch (err) {935 const code = err instanceof Error ? err.message : String(err);936 setError(937 code === "NO_ENTRIES" ? "No entries found in this file." :938 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :939 "Import failed. Check the file format and try again."940 );941 } finally {942 setImporting(false);943 }944 }945946 return (947 <div class="peach-rt-csv">948 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>949 {importing() ? "importing…" : "open csv file"}950 <input951 type="file"952 accept=".csv,.json,.peach,application/x-peach"953 style="display:none"954 onChange={handleFile}955 disabled={importing()}956 />957 </label>958 <p class="peach-rt-csv-sources">959 Imports from Bitwarden, 1Password, Proton Pass,<br />960 Chrome, Safari, and Edge.961 </p>962 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>963 <Show when={error()}>964 <p class="peach-rt-error">{error()}</p>965 </Show>966 </div>967 );968}969970971972function getDisplayError(error: string | undefined, fallback: string): string {973 if (!error) return fallback;974 if (975 error === "SERVICE_WORKER_UNAVAILABLE" ||976 error.includes("Receiving end does not exist") ||977 error.includes("message port closed")978 ) {979 return "background service unavailable";980 }981 return error;982}