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 { ImportView } from "./Import";9import { storageGet, storageSet } from "../../shared/storage";10import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";11import { parsePayload } from "../../crypto/peachPayload";12import { parseImportSource } from "../../shared/import";13import { isNativePlatform, nativePlugin } from "../../shared/native";1415const peachMarkImg = new Image();16peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(17 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +18 '<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"/>' +19 '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +20 '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +21 '</svg>'22)}`;232425let _zxcvbn: ((password: string) => { score: number }) | null = null;26async function getZxcvbn() {27 if (_zxcvbn) return _zxcvbn;28 const mod = await import("zxcvbn");29 _zxcvbn = mod.default;30 return _zxcvbn;31}3233interface LockProps {34 hasVault: boolean;35 ready: boolean;36 isPro?: boolean;37 383940 forceRecoveryWords?: boolean;41 onUnlock: () => void | Promise<void>;42 434445 onBackToOnboarding?: () => void;46}4748type RestoreSurface = "restore" | null;49type RecoverSurface = "words" | "new-password" | null;5051export function LockView(props: LockProps) {52 const [password, setPassword] = createSignal("");53 const [confirmPassword, setConfirmPassword] = createSignal("");54 const [error, setError] = createSignal("");55 const [loading, setLoading] = createSignal(false);56 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);57 const [showPassword, setShowPassword] = createSignal(false);58 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);59 60 61 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);62 const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);6364 const MIN_LENGTH = 12;65 const MIN_ZXCVBN_SCORE = 2;6667 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);68 const [biometricAvailable, setBiometricAvailable] = createSignal(false);69 const [biometricBusy, setBiometricBusy] = createSignal(false);7071 const canCreate = createMemo(72 () =>73 password().length >= MIN_LENGTH &&74 confirmPassword().length >= MIN_LENGTH &&75 password() === confirmPassword() &&76 strengthScore() !== null &&77 strengthScore()! >= MIN_ZXCVBN_SCORE78 );7980 const passwordStrength = createMemo(() => {81 const score = strengthScore();82 if (score === null) return null;83 if (score <= 1) return "weak";84 if (score === 2) return "fair";85 if (score === 3) return "good";86 return "strong";87 });8889 const createBlockerMessage = createMemo(() => {90 if (!password()) return null;91 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;92 if (!confirmPassword()) return "confirm your password";93 if (password() !== confirmPassword()) return "passwords don't match";94 if (strengthScore() === null) return "checking strength…";95 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";96 return null;97 });9899 100 createMemo(() => {101 const value = password();102 if (!value) { setStrengthScore(null); return; }103 getZxcvbn().then((zxcvbn) => {104 if (password() === value) setStrengthScore(zxcvbn(value).score);105 }).catch(() => {106 107 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);108 });109 });110111 async function handleUnlock() {112 if (!password().trim()) return;113 setLoading(true);114 setError("");115 const response = await sendMessage({116 type: "UNLOCK",117 payload: { password: password() },118 });119 const result = response as { ok: boolean; error?: string };120 if (result.ok) {121 await props.onUnlock();122 setLoading(false);123 return;124 }125 setError(getDisplayError(result.error, "incorrect password"));126 setPassword("");127 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;128 passwordField?.classList.add("shake");129 setTimeout(() => passwordField?.classList.remove("shake"), 300);130 setLoading(false);131 }132133 async function checkBiometricAvailable(): Promise<void> {134 if (!props.hasVault || !isNativePlatform()) return;135 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");136 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");137 if (!keystore || !biometric) return;138 try {139 const stored = await keystore.hasMasterKey();140 if (!stored.exists) return;141 const avail = await biometric.isAvailable();142 if (!avail.available) return;143 setBiometricAvailable(true);144 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);145 } catch { }146 }147148 async function handleBiometricUnlock(): Promise<void> {149 if (biometricBusy()) return;150 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");151 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");152 if (!biometric || !keystore) return;153 setBiometricBusy(true);154 setError("");155 try {156 await biometric.authenticate({157 title: "Unlock Peach",158 subtitle: "Use biometrics to access your vault",159 negativeButtonText: "Use Password",160 });161 const { masterKeyHex } = await keystore.retrieveMasterKey();162 const res = (await sendMessage({163 type: "UNLOCK_WITH_KEY",164 payload: { masterKeyHex },165 })) as { ok: boolean; error?: string };166 if (res.ok) {167 await props.onUnlock();168 return;169 }170 if (res.error === "biometric_key_invalidated") {171 await sendMessage({172 type: "UPDATE_SETTINGS",173 payload: { biometricsEnabled: false },174 });175 setBiometricAvailable(false);176 setError("biometric key expired — re-enable in settings");177 } else {178 setError("biometric unlock failed");179 }180 } catch (e) {181 const reason = e instanceof Error ? e.message : String(e);182 if (reason.includes("user_cancelled")) return;183 if (reason.includes("biometric_key_invalidated")) {184 await sendMessage({185 type: "UPDATE_SETTINGS",186 payload: { biometricsEnabled: false },187 });188 setBiometricAvailable(false);189 setError("biometric key expired — re-enable in settings");190 return;191 }192 if (reason.includes("biometric_lockout")) {193 setError("biometric locked out — use your password");194 }195 } finally {196 setBiometricBusy(false);197 }198 }199200 onMount(() => { void checkBiometricAvailable(); });201202 async function handleCreate() {203 if (!password().trim()) return;204 if (password().length < MIN_LENGTH) {205 setError(`password must be at least ${MIN_LENGTH} characters`);206 return;207 }208 if (password() !== confirmPassword()) {209 setError("passwords do not match");210 return;211 }212 const score = strengthScore();213 if (score === null || score < MIN_ZXCVBN_SCORE) {214 setError("password is too weak — try a longer or more varied passphrase");215 return;216 }217 setLoading(true);218 setError("");219 const response = await sendMessage({220 type: "CREATE_VAULT",221 payload: { password: password() },222 });223 const result = response as { ok: boolean; error?: string };224 if (result.ok) {225 226 227 228 229 230 try {231 await chrome.storage.local?.set({ peach_recovery_pending: "1" });232 } catch { }233 setShowRecoveryWords(true);234 setLoading(false);235 return;236 }237 setError(getDisplayError(result.error, "failed to create vault"));238 setLoading(false);239 }240241 function openRestore(surface: Exclude<RestoreSurface, null>) {242 if (!props.ready || loading()) return;243 setPassword("");244 setConfirmPassword("");245 setError("");246 setRestoreSurface(surface);247 }248249 250 return (251 <Switch>252 {}253 <Match when={restoreSurface() === "restore"}>254 <RestoreTabView255 hasVault={props.hasVault}256 onBack={() => setRestoreSurface(null)}257 onUnlock={async () => {258 setRestoreSurface(null);259 await props.onUnlock();260 }}261 />262 </Match>263264 {}265 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>266 <RecoveryWordDisplayView267 onDone={props.onUnlock}268 />269 </Match>270271 {}272 <Match when={recoverSurface() === "words"}>273 <RecoveryWordEntryView274 onCancel={() => setRecoverSurface(null)}275 onRecoveryComplete={props.onUnlock}276 />277 </Match>278279 {}280 <Match when={props.hasVault}>281 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">282 <header class="peach-screen-header lock-screen-header">283 <div class="peach-wordmark-stack">284 <PeachWordmark class="peach-wordmark-wrap" />285 <Show when={props.isPro}>286 <span class="peach-pro-mini-badge">pro</span>287 </Show>288 </div>289 </header>290291 <div class="peach-lock-hero peach-lock-hero--unlock">292 <div class="peach-lock-hero-art" aria-hidden="true">293 <PeachMark class="peach-lock-hero-mark" />294 </div>295 <span class="peach-lock-eyebrow">vault locked</span>296 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">297 unlock298 <br />299 your vault.300 </h1>301 </div>302303 <LockRuneCanvas />304305 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">306 <label class="peach-label" for="unlock-password">307 master password308 </label>309 <input310 id="unlock-password"311 class="peach-underline-input peach-unlock-input lock-password-input"312 type="password"313 placeholder="••••••••••••"314 autocomplete="current-password"315 value={password()}316 disabled={!props.ready}317 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}318 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}319 onKeyDown={(event) => {320 if (event.key !== "Enter" || !props.ready) return;321 void handleUnlock();322 }}323 />324325 <Show when={error()}>326 <p class="peach-hint peach-hint-error">{error()}</p>327 </Show>328329 <button330 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"331 onClick={() => void handleUnlock()}332 disabled={!props.ready || loading()}333 >334 {loading() ? "unlocking..." : "unlock"}335 </button>336337 <Show when={biometricAvailable()}>338 <button339 class="peach-biometric-btn"340 type="button"341 onClick={() => void handleBiometricUnlock()}342 disabled={biometricBusy() || loading()}343 aria-label="Unlock with biometrics"344 >345 <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">346 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>347 <path d="M15 17v-3"/>348 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>349 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>350 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>351 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>352 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>353 </svg>354 {biometricBusy() ? "verifying…" : "use biometrics"}355 </button>356 </Show>357358 <button359 class="peach-lock-recover-link"360 type="button"361 onClick={() => setRecoverSurface("words")}362 >363 forgot your password?364 </button>365 </section>366 </section>367 </Match>368369 {}370 <Match when={true}>371 <section372 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"373 data-motion-phase="enter"374 >375 <header class="peach-screen-header peach-newvault-header">376 <Show when={props.onBackToOnboarding}>377 <button378 class="peach-newvault-back"379 type="button"380 onClick={() => props.onBackToOnboarding?.()}381 aria-label="Back to onboarding"382 >383 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">384 <path d="M7 2L3 5.5L7 9" />385 </svg>386 back387 </button>388 </Show>389 <div class="peach-wordmark-stack">390 <PeachWordmark class="peach-wordmark-wrap" />391 <Show when={props.isPro}>392 <span class="peach-pro-mini-badge">pro</span>393 </Show>394 </div>395 </header>396397 <div class="peach-newvault-mark" aria-hidden="true">398 <PeachMark class="peach-newvault-mark-art" />399 </div>400401 <div class="peach-newvault-card">402 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>403 <hr class="peach-newvault-divider" />404405 <div class="peach-newvault-field">406 <div class="peach-newvault-label-row">407 <label class="peach-label" for="master-password">408 master password409 </label>410 <button411 class="peach-lock-reveal-btn"412 type="button"413 onClick={() => setShowPassword((value) => !value)}414 aria-label={showPassword() ? "Hide password" : "Show password"}415 >416 <Show417 when={showPassword()}418 fallback={419 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">420 <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" />421 <circle cx="7" cy="5" r="1.75" />422 </svg>423 }424 >425 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">426 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />427 <path d="M3.5 5.5L2.5 7.5" />428 <path d="M7 6.5V8.5" />429 <path d="M10.5 5.5L11.5 7.5" />430 </svg>431 </Show>432 {showPassword() ? "hide" : "show"}433 </button>434 </div>435 <input436 id="master-password"437 class="peach-underline-input peach-newvault-input lock-password-input"438 type={showPassword() ? "text" : "password"}439 placeholder="••••••••••••"440 autocomplete="new-password"441 value={password()}442 disabled={!props.ready}443 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}444 onKeyDown={(event) => {445 if (event.key !== "Enter" || !props.ready) return;446 void handleCreate();447 }}448 />449 <Show450 when={password().length > 0 && passwordStrength() !== null}451 fallback={452 <p class="peach-newvault-hint">at least 12 characters</p>453 }454 >455 <div class="peach-password-strength">456 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>457 <div class="peach-strength-fill"></div>458 </div>459 <span class="peach-strength-label">{passwordStrength()}</span>460 </div>461 </Show>462 </div>463464 <div class="peach-newvault-field">465 <label class="peach-label" for="confirm-password">466 confirm password467 </label>468 <input469 id="confirm-password"470 class="peach-underline-input peach-newvault-input"471 type={showPassword() ? "text" : "password"}472 placeholder="••••••••••••"473 autocomplete="new-password"474 value={confirmPassword()}475 disabled={!props.ready}476 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}477 onKeyDown={(event) => {478 if (event.key === "Enter" && props.ready) void handleCreate();479 }}480 />481 </div>482483 <Show when={error()}>484 <p class="peach-hint peach-hint-error">{error()}</p>485 </Show>486487 <button488 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"489 onClick={() => void handleCreate()}490 disabled={!props.ready || loading() || !canCreate()}491 aria-live="polite"492 >493 <Show494 when={!canCreate() && createBlockerMessage()}495 fallback={loading() ? "creating..." : "create vault"}496 >497 {createBlockerMessage()}498 </Show>499 </button>500501 <p class="peach-newvault-warning">502 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>503 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>504 </p>505 </div>506507 <div class="peach-newvault-footer">508 <p class="peach-hint">Already have a backup?</p>509 <button510 class="peach-lock-restore-link"511 onClick={() => void openRestore("restore")}512 disabled={!props.ready}513 >514 › restore from backup515 </button>516 </div>517 </section>518 </Match>519 </Switch>520 );521}522523524525type RestoreTab = "symbols" | "csv" | "phone";526527function tabLabel(tab: RestoreTab): string {528 return (529 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]530 );531}532533function RestoreTabView(props: {534 hasVault: boolean;535 onBack: () => void;536 onUnlock: () => void | Promise<void>;537}) {538 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");539 const [showIntro, setShowIntro] = createSignal(false);540 const [pendingUnlock, setPendingUnlock] = createSignal(false);541542 async function handleRestoreSuccess() {543 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);544 if (!seen) {545 setShowIntro(true);546 } else {547 await props.onUnlock();548 }549 }550551 async function dismissIntro() {552 await storageSet("peach_shown_restore_intro", "true").catch(() => {});553 setShowIntro(false);554 await props.onUnlock();555 }556557 return (558 <section559 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"560 data-motion-phase="enter"561 >562 <RuneBackground opacity={0.05} />563564 <header class="peach-screen-header lock-screen-header">565 <PeachWordmark class="peach-wordmark-wrap" />566 <button class="peach-lock-restore-link" onClick={props.onBack}>567 ‹ back568 </button>569 </header>570571 <div class="peach-newvault-card peach-restore-tabcard">572 <div class="peach-restore-tabs" role="tablist">573 <For each={["symbols", "csv", "phone"] as RestoreTab[]}>574 {(tab) => (575 <button576 class="peach-restore-tab"577 classList={{ "is-active": activeTab() === tab }}578 onClick={() => setActiveTab(tab)}579 role="tab"580 aria-selected={activeTab() === tab}581 >582 {tabLabel(tab)}583 </button>584 )}585 </For>586 </div>587 <div class="peach-restore-content">588 <Show when={activeTab() === "symbols"}>589 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />590 </Show>591 <Show when={activeTab() === "csv"}>592 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />593 </Show>594 <Show when={activeTab() === "phone"}>595 <RestoreFromPhone onSuccess={handleRestoreSuccess} />596 </Show>597 </div>598 </div>599600 {}601 <Show when={showIntro()}>602 <div class="peach-restore-intro-overlay">603 <div class="peach-restore-intro-card">604 <p class="peach-restore-intro-headline">welcome to peach.</p>605 <div class="peach-restore-intro-body">606 <p>Your vault lives only on this device. No server has a copy.</p>607 <p>The inline bubble fills passwords automatically when you visit sites.</p>608 <p>Export your vault from Settings before switching devices.</p>609 </div>610 <button611 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"612 onClick={() => void dismissIntro()}613 disabled={pendingUnlock()}614 >615 got it616 </button>617 </div>618 </div>619 </Show>620 </section>621 );622}623624625626function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {627 const [scriptInput, setScriptInput] = createSignal("");628 const [backupPass, setBackupPass] = createSignal("");629 const [working, setWorking] = createSignal(false);630 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);631 const [error, setError] = createSignal<string | null>(null);632 let canvasRef: HTMLCanvasElement | undefined;633 let animFrameId = 0;634 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];635636 const scriptValid = createMemo(() => isPeachScript(scriptInput()));637638 function stopAnim() {639 cancelAnimationFrame(animFrameId);640 animFrameId = 0;641 }642643 function startChaos(canvas: HTMLCanvasElement) {644 const ctx = canvas.getContext("2d")!;645 const W = canvas.width;646 const H = canvas.height;647 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>648 String.fromCodePoint(0xe000 + i)649 );650 if (runes.length === 0) {651 runes = Array.from({ length: 60 }, () => ({652 x: Math.random() * W,653 y: Math.random() * H,654 vx: (Math.random() - 0.5) * 3,655 vy: (Math.random() - 0.5) * 3,656 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,657 size: 14 + Math.random() * 20,658 angle: Math.random() * Math.PI * 2,659 va: (Math.random() - 0.5) * 0.1,660 }));661 }662663 function draw() {664 ctx.clearRect(0, 0, W, H);665 for (const r of runes) {666 r.x = (r.x + r.vx + W) % W;667 r.y = (r.y + r.vy + H) % H;668 r.angle += r.va;669 ctx.save();670 ctx.translate(r.x, r.y);671 ctx.rotate(r.angle);672 ctx.font = `${r.size}px PeachScript, monospace`;673 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);674 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;675 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";676 ctx.shadowBlur = 8;677 ctx.fillText(r.char, 0, 0);678 ctx.restore();679 }680 animFrameId = requestAnimationFrame(draw);681 }682 draw();683 }684685 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {686 stopAnim();687 const ctx = canvas.getContext("2d")!;688 const W = canvas.width;689 const H = canvas.height;690 const cols = 8;691 const rows = 6;692 const cellW = W / cols;693 const cellH = H / rows;694 const targets = runes.slice(0, cols * rows).map((_, i) => ({695 x: (i % cols) * cellW + cellW / 2,696 y: Math.floor(i / cols) * cellH + cellH / 2,697 }));698 const start = performance.now();699 const DURATION = 400;700701 function drawSnap(now: number) {702 const t = Math.min((now - start) / DURATION, 1);703 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;704 ctx.clearRect(0, 0, W, H);705 runes.slice(0, targets.length).forEach((r, i) => {706 const tx = targets[i]!.x;707 const ty = targets[i]!.y;708 const cx = r.x + (tx - r.x) * ease;709 const cy = r.y + (ty - r.y) * ease;710 ctx.font = `16px PeachScript, monospace`;711 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;712 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";713 ctx.shadowBlur = 6;714 ctx.fillText(r.char, cx - 8, cy + 8);715 });716 if (t < 1) {717 animFrameId = requestAnimationFrame(drawSnap);718 } else {719 onDone();720 }721 }722 animFrameId = requestAnimationFrame(drawSnap);723 }724725 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {726 stopAnim();727 const ctx = canvas.getContext("2d")!;728 const W = canvas.width;729 const H = canvas.height;730 const start = performance.now();731 const DURATION = 900;732733 function drawBloom(now: number) {734 const t = Math.min((now - start) / DURATION, 1);735 ctx.clearRect(0, 0, W, H);736 737 const runeAlpha = 1 - Math.min(t / 0.4, 1);738 if (runeAlpha > 0) {739 for (const r of runes.slice(0, 48)) {740 ctx.font = `16px PeachScript, monospace`;741 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;742 ctx.fillText(r.char, r.x - 8, r.y + 8);743 }744 }745 746 const markT = Math.max(0, (t - 0.2) / 0.5);747 const markScale = Math.min(markT * 1.2, 1);748 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;749 if (markAlpha > 0) {750 ctx.save();751 ctx.globalAlpha = markAlpha;752 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;753 ctx.shadowBlur = 30;754 ctx.translate(W / 2, H / 2);755 ctx.scale(markScale, markScale);756 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);757 ctx.restore();758 }759 if (t < 1) {760 animFrameId = requestAnimationFrame(drawBloom);761 } else {762 onDone();763 }764 }765 animFrameId = requestAnimationFrame(drawBloom);766 }767768 onCleanup(() => stopAnim());769770 async function handleRestore() {771 if (!scriptValid() || !backupPass().trim() || working()) return;772 setError(null);773 setWorking(true);774 setAnimating("chaos");775776 const canvas = canvasRef;777 if (canvas) startChaos(canvas);778779 try {780 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));781 const payload = await parsePayload(bytes, backupPass());782783 if (!props.hasVault) {784 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });785 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");786 }787788 const rr = await sendMessage({789 type: "RESTORE_ENTRIES",790 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },791 });792 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");793794 if (payload.license?.key) {795 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});796 }797798 setAnimating("snap");799 stopAnim();800 if (canvas) {801 snapToGrid(canvas, () => {802 setAnimating("bloom");803 bloom(canvas, async () => {804 setAnimating(null);805 await props.onSuccess();806 });807 });808 } else {809 await props.onSuccess();810 }811 } catch (err) {812 stopAnim();813 setAnimating(null);814 const code = err instanceof Error ? err.message : String(err);815 setError(816 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"817 ? "That passphrase didn't work."818 : code === "CREATE_VAULT_FAILED"819 ? "Couldn't create a new local vault."820 : "Could not read this backup.",821 );822 } finally {823 setWorking(false);824 }825 }826827 return (828 <div class="peach-rt-symbols">829 <Show when={animating() !== null}>830 <canvas831 ref={(el) => { canvasRef = el; }}832 class="peach-rt-anim-canvas"833 width={280}834 height={200}835 />836 </Show>837 <Show when={animating() === null}>838 <textarea839 class="peach-restore-symbols-textarea"840 placeholder="paste peachscript…"841 rows={3}842 value={scriptInput()}843 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}844 />845 <div class="peach-rt-divider">— or —</div>846 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">847 open .peach file848 <input849 type="file"850 accept=".peach,text/plain"851 style="display:none"852 onChange={async (e) => {853 const file = (e.currentTarget as HTMLInputElement).files?.[0];854 if (!file) return;855 try {856 setScriptInput(await file.text());857 } catch {858 setError("Couldn't read file.");859 } finally {860 (e.currentTarget as HTMLInputElement).value = "";861 }862 }}863 />864 </label>865 <input866 class="peach-restore-symbols-pass"867 type="password"868 placeholder="backup password"869 value={backupPass()}870 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}871 onKeyDown={(e) => {872 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {873 e.preventDefault();874 void handleRestore();875 }876 }}877 />878 <button879 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"880 onClick={() => void handleRestore()}881 disabled={!scriptValid() || !backupPass().trim() || working()}882 >883 restore vault →884 </button>885 <Show when={error()}>886 <p class="peach-rt-error">{error()}</p>887 </Show>888 </Show>889 </div>890 );891}892893894895function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {896 const [importing, setImporting] = createSignal(false);897 const [error, setError] = createSignal<string | null>(null);898899 async function handleFile(e: Event) {900 const input = e.currentTarget as HTMLInputElement;901 const file = input.files?.[0];902 input.value = "";903 if (!file) return;904 setImporting(true);905 setError(null);906 try {907 const text = await file.text();908 const parsed = await parseImportSource(text);909 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");910911 if (props.hasVault === false) {912 913 914 915 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });916 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");917 }918919 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });920 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");921 if (parsed.licenseKey) {922 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});923 }924 await props.onSuccess();925 } catch (err) {926 const code = err instanceof Error ? err.message : String(err);927 setError(928 code === "NO_ENTRIES" ? "No entries found in this file." :929 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :930 "Import failed. Check the file format and try again."931 );932 } finally {933 setImporting(false);934 }935 }936937 return (938 <div class="peach-rt-csv">939 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>940 {importing() ? "importing…" : "open csv file"}941 <input942 type="file"943 accept=".csv,.json,.peach,application/x-peach"944 style="display:none"945 onChange={handleFile}946 disabled={importing()}947 />948 </label>949 <p class="peach-rt-csv-sources">950 Imports from Bitwarden, 1Password, Proton Pass,<br />951 Chrome, Safari, and Edge.952 </p>953 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>954 <Show when={error()}>955 <p class="peach-rt-error">{error()}</p>956 </Show>957 </div>958 );959}960961962963type PhoneState = "waiting" | "connected" | "trust" | "error";964965function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {966 const [state, setState] = createSignal<PhoneState>("waiting");967 const [code, setCode] = createSignal<string | null>(null);968 const [qrUrl, setQrUrl] = createSignal<string | null>(null);969 const [expired, setExpired] = createSignal(false);970 const [deviceName, setDeviceName] = createSignal<string | null>(null);971 const [accepting, setAccepting] = createSignal(false);972 const [serverError, setServerError] = createSignal<string | null>(null);973 let expireTimer: ReturnType<typeof setTimeout> | null = null;974975 function clearExpire() {976 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }977 }978979 async function startPairing() {980 setExpired(false);981 setCode(null);982 setQrUrl(null);983 setServerError(null);984 clearExpire();985 const res = await sendMessage({ type: "START_PAIRING_SERVER" });986 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };987 if (!r.ok || !r.data) {988 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");989 return;990 }991 const token = r.data.token;992 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;993 setCode(formatted);994 const qrData = `peach://handoff?token=${token}`;995 try {996 const QRCode = (await import("qrcode")).default;997 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });998 setQrUrl(url);999 } catch {1000 1001 }1002 const remaining = Math.max(0, r.data.expiresAt - Date.now());1003 expireTimer = setTimeout(() => {1004 setCode(null);1005 setQrUrl(null);1006 setExpired(true);1007 }, remaining);1008 }10091010 function handleRuntimeMsg(msg: unknown) {1011 const m = msg as { type?: string; payload?: { deviceName?: string } };1012 if (m?.type === "PAIRING_COMPLETED") {1013 clearExpire();1014 setDeviceName(m.payload?.deviceName ?? "your phone");1015 setState("trust");1016 }1017 }10181019 onMount(() => {1020 void startPairing();1021 1022 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1023 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1024 }1025 });10261027 onCleanup(() => {1028 clearExpire();1029 1030 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1031 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1032 }1033 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1034 });10351036 async function handleAccept(trust: boolean) {1037 setAccepting(true);1038 if (trust) {1039 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1040 }1041 await props.onSuccess();1042 }10431044 return (1045 <div class="peach-rt-phone">1046 <Show when={state() === "waiting"}>1047 <Show when={!expired()}>1048 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1049 <p class="peach-rt-phone-code">{code()}</p>1050 <Show when={qrUrl() !== null}>1051 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1052 </Show>1053 <p class="peach-rt-phone-hint">1054 Open Peach on your phone and tap<br />1055 Settings → Send to desktop.<br />1056 Same WiFi required.1057 </p>1058 </Show>1059 <Show when={serverError()}>1060 <p class="peach-rt-error">{serverError()}</p>1061 </Show>1062 </Show>1063 <Show when={expired()}>1064 <p class="peach-rt-phone-hint">Code expired.</p>1065 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1066 Generate new code1067 </button>1068 </Show>1069 </Show>10701071 <Show when={state() === "connected"}>1072 <p class="peach-rt-phone-hint">1073 Connected to {deviceName() ?? "your phone"}.<br />1074 Receiving vault…1075 </p>1076 </Show>10771078 <Show when={state() === "trust"}>1079 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1080 Vault received from {deviceName() ?? "your phone"}.<br />1081 Trust this network for sync?1082 </p>1083 <button1084 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1085 onClick={() => void handleAccept(true)}1086 disabled={accepting()}1087 >1088 Yes, trust this network1089 </button>1090 <button1091 class="peach-btn peach-btn--ghost peach-btn--sm"1092 style="margin-top:6px"1093 onClick={() => void handleAccept(false)}1094 disabled={accepting()}1095 >1096 Not now1097 </button>1098 </Show>10991100 <Show when={state() === "error"}>1101 <p class="peach-rt-phone-hint">1102 Could not connect. Check that both devices<br />1103 are on the same WiFi network.1104 </p>1105 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1106 Try again1107 </button>1108 </Show>1109 </div>1110 );1111}1112111311141115function getDisplayError(error: string | undefined, fallback: string): string {1116 if (!error) return fallback;1117 if (1118 error === "SERVICE_WORKER_UNAVAILABLE" ||1119 error.includes("Receiving end does not exist") ||1120 error.includes("message port closed")1121 ) {1122 return "background service unavailable";1123 }1124 return error;1125}