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 4748 onOpenCodexRestore?: () => void;49}5051type RestoreSurface = "restore" | null;52type RecoverSurface = "words" | "new-password" | null;5354export function LockView(props: LockProps) {55 const [password, setPassword] = createSignal("");56 const [confirmPassword, setConfirmPassword] = createSignal("");57 const [error, setError] = createSignal("");58 const [loading, setLoading] = createSignal(false);59 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);60 const [showPassword, setShowPassword] = createSignal(false);61 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);62 63 64 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);65 const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);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" | "phone";538539function tabLabel(tab: RestoreTab): string {540 return (541 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[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, setPendingUnlock] = 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", "phone"] 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 <Show when={activeTab() === "phone"}>607 <RestoreFromPhone onSuccess={handleRestoreSuccess} />608 </Show>609 </div>610 </div>611612 {}613 <Show when={showIntro()}>614 <div class="peach-restore-intro-overlay">615 <div class="peach-restore-intro-card">616 <p class="peach-restore-intro-headline">welcome to peach.</p>617 <div class="peach-restore-intro-body">618 <p>Your vault lives only on this device. No server has a copy.</p>619 <p>The inline bubble fills passwords automatically when you visit sites.</p>620 <p>Export your vault from Settings before switching devices.</p>621 </div>622 <button623 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"624 onClick={() => void dismissIntro()}625 disabled={pendingUnlock()}626 >627 got it628 </button>629 </div>630 </div>631 </Show>632 </section>633 );634}635636637638function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {639 const [scriptInput, setScriptInput] = createSignal("");640 const [backupPass, setBackupPass] = createSignal("");641 const [working, setWorking] = createSignal(false);642 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);643 const [error, setError] = createSignal<string | null>(null);644 let canvasRef: HTMLCanvasElement | undefined;645 let animFrameId = 0;646 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];647648 const scriptValid = createMemo(() => isPeachScript(scriptInput()));649650 function stopAnim() {651 cancelAnimationFrame(animFrameId);652 animFrameId = 0;653 }654655 function startChaos(canvas: HTMLCanvasElement) {656 const ctx = canvas.getContext("2d")!;657 const W = canvas.width;658 const H = canvas.height;659 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>660 String.fromCodePoint(0xe000 + i)661 );662 if (runes.length === 0) {663 runes = Array.from({ length: 60 }, () => ({664 x: Math.random() * W,665 y: Math.random() * H,666 vx: (Math.random() - 0.5) * 3,667 vy: (Math.random() - 0.5) * 3,668 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,669 size: 14 + Math.random() * 20,670 angle: Math.random() * Math.PI * 2,671 va: (Math.random() - 0.5) * 0.1,672 }));673 }674675 function draw() {676 ctx.clearRect(0, 0, W, H);677 for (const r of runes) {678 r.x = (r.x + r.vx + W) % W;679 r.y = (r.y + r.vy + H) % H;680 r.angle += r.va;681 ctx.save();682 ctx.translate(r.x, r.y);683 ctx.rotate(r.angle);684 ctx.font = `${r.size}px PeachScript, monospace`;685 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);686 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;687 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";688 ctx.shadowBlur = 8;689 ctx.fillText(r.char, 0, 0);690 ctx.restore();691 }692 animFrameId = requestAnimationFrame(draw);693 }694 draw();695 }696697 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {698 stopAnim();699 const ctx = canvas.getContext("2d")!;700 const W = canvas.width;701 const H = canvas.height;702 const cols = 8;703 const rows = 6;704 const cellW = W / cols;705 const cellH = H / rows;706 const targets = runes.slice(0, cols * rows).map((_, i) => ({707 x: (i % cols) * cellW + cellW / 2,708 y: Math.floor(i / cols) * cellH + cellH / 2,709 }));710 const start = performance.now();711 const DURATION = 400;712713 function drawSnap(now: number) {714 const t = Math.min((now - start) / DURATION, 1);715 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;716 ctx.clearRect(0, 0, W, H);717 runes.slice(0, targets.length).forEach((r, i) => {718 const tx = targets[i]!.x;719 const ty = targets[i]!.y;720 const cx = r.x + (tx - r.x) * ease;721 const cy = r.y + (ty - r.y) * ease;722 ctx.font = `16px PeachScript, monospace`;723 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;724 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";725 ctx.shadowBlur = 6;726 ctx.fillText(r.char, cx - 8, cy + 8);727 });728 if (t < 1) {729 animFrameId = requestAnimationFrame(drawSnap);730 } else {731 onDone();732 }733 }734 animFrameId = requestAnimationFrame(drawSnap);735 }736737 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {738 stopAnim();739 const ctx = canvas.getContext("2d")!;740 const W = canvas.width;741 const H = canvas.height;742 const start = performance.now();743 const DURATION = 900;744745 function drawBloom(now: number) {746 const t = Math.min((now - start) / DURATION, 1);747 ctx.clearRect(0, 0, W, H);748 749 const runeAlpha = 1 - Math.min(t / 0.4, 1);750 if (runeAlpha > 0) {751 for (const r of runes.slice(0, 48)) {752 ctx.font = `16px PeachScript, monospace`;753 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;754 ctx.fillText(r.char, r.x - 8, r.y + 8);755 }756 }757 758 const markT = Math.max(0, (t - 0.2) / 0.5);759 const markScale = Math.min(markT * 1.2, 1);760 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;761 if (markAlpha > 0) {762 ctx.save();763 ctx.globalAlpha = markAlpha;764 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;765 ctx.shadowBlur = 30;766 ctx.translate(W / 2, H / 2);767 ctx.scale(markScale, markScale);768 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);769 ctx.restore();770 }771 if (t < 1) {772 animFrameId = requestAnimationFrame(drawBloom);773 } else {774 onDone();775 }776 }777 animFrameId = requestAnimationFrame(drawBloom);778 }779780 onCleanup(() => stopAnim());781782 async function handleRestore() {783 if (!scriptValid() || !backupPass().trim() || working()) return;784 setError(null);785 setWorking(true);786 setAnimating("chaos");787788 const canvas = canvasRef;789 if (canvas) startChaos(canvas);790791 try {792 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));793 const payload = await parsePayload(bytes, backupPass());794795 if (!props.hasVault) {796 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });797 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");798 }799800 const rr = await sendMessage({801 type: "RESTORE_ENTRIES",802 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },803 });804 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");805806 if (payload.license?.key) {807 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});808 }809810 setAnimating("snap");811 stopAnim();812 if (canvas) {813 snapToGrid(canvas, () => {814 setAnimating("bloom");815 bloom(canvas, async () => {816 setAnimating(null);817 await props.onSuccess();818 });819 });820 } else {821 await props.onSuccess();822 }823 } catch (err) {824 stopAnim();825 setAnimating(null);826 const code = err instanceof Error ? err.message : String(err);827 setError(828 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"829 ? "That passphrase didn't work."830 : code === "CREATE_VAULT_FAILED"831 ? "Couldn't create a new local vault."832 : "Could not read this backup.",833 );834 } finally {835 setWorking(false);836 }837 }838839 return (840 <div class="peach-rt-symbols">841 <Show when={animating() !== null}>842 <canvas843 ref={(el) => { canvasRef = el; }}844 class="peach-rt-anim-canvas"845 width={280}846 height={200}847 />848 </Show>849 <Show when={animating() === null}>850 <textarea851 class="peach-restore-symbols-textarea"852 placeholder="paste peachscript…"853 rows={3}854 value={scriptInput()}855 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}856 />857 <div class="peach-rt-divider">— or —</div>858 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">859 open .peach file860 <input861 type="file"862 accept=".peach,text/plain"863 style="display:none"864 onChange={async (e) => {865 const file = (e.currentTarget as HTMLInputElement).files?.[0];866 if (!file) return;867 try {868 setScriptInput(await file.text());869 } catch {870 setError("Couldn't read file.");871 } finally {872 (e.currentTarget as HTMLInputElement).value = "";873 }874 }}875 />876 </label>877 <input878 class="peach-restore-symbols-pass"879 type="password"880 placeholder="backup password"881 value={backupPass()}882 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}883 onKeyDown={(e) => {884 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {885 e.preventDefault();886 void handleRestore();887 }888 }}889 />890 <button891 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"892 onClick={() => void handleRestore()}893 disabled={!scriptValid() || !backupPass().trim() || working()}894 >895 restore vault →896 </button>897 <Show when={error()}>898 <p class="peach-rt-error">{error()}</p>899 </Show>900 </Show>901 </div>902 );903}904905906907function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {908 const [importing, setImporting] = createSignal(false);909 const [error, setError] = createSignal<string | null>(null);910911 async function handleFile(e: Event) {912 const input = e.currentTarget as HTMLInputElement;913 const file = input.files?.[0];914 input.value = "";915 if (!file) return;916 setImporting(true);917 setError(null);918 try {919 const text = await file.text();920 const parsed = await parseImportSource(text);921 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");922923 if (props.hasVault === false) {924 925 926 927 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });928 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");929 }930931 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });932 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");933 if (parsed.licenseKey) {934 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});935 }936 await props.onSuccess();937 } catch (err) {938 const code = err instanceof Error ? err.message : String(err);939 setError(940 code === "NO_ENTRIES" ? "No entries found in this file." :941 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :942 "Import failed. Check the file format and try again."943 );944 } finally {945 setImporting(false);946 }947 }948949 return (950 <div class="peach-rt-csv">951 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>952 {importing() ? "importing…" : "open csv file"}953 <input954 type="file"955 accept=".csv,.json,.peach,application/x-peach"956 style="display:none"957 onChange={handleFile}958 disabled={importing()}959 />960 </label>961 <p class="peach-rt-csv-sources">962 Imports from Bitwarden, 1Password, Proton Pass,<br />963 Chrome, Safari, and Edge.964 </p>965 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>966 <Show when={error()}>967 <p class="peach-rt-error">{error()}</p>968 </Show>969 </div>970 );971}972973974975type PhoneState = "waiting" | "connected" | "trust" | "error";976977function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {978 const [state, setState] = createSignal<PhoneState>("waiting");979 const [code, setCode] = createSignal<string | null>(null);980 const [qrUrl, setQrUrl] = createSignal<string | null>(null);981 const [expired, setExpired] = createSignal(false);982 const [deviceName, setDeviceName] = createSignal<string | null>(null);983 const [accepting, setAccepting] = createSignal(false);984 const [serverError, setServerError] = createSignal<string | null>(null);985 let expireTimer: ReturnType<typeof setTimeout> | null = null;986987 function clearExpire() {988 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }989 }990991 async function startPairing() {992 setExpired(false);993 setCode(null);994 setQrUrl(null);995 setServerError(null);996 clearExpire();997 const res = await sendMessage({ type: "START_PAIRING_SERVER" });998 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };999 if (!r.ok || !r.data) {1000 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1001 return;1002 }1003 const token = r.data.token;1004 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1005 setCode(formatted);1006 const qrData = `peach://handoff?token=${token}`;1007 try {1008 const QRCode = (await import("qrcode")).default;1009 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1010 setQrUrl(url);1011 } catch {1012 1013 }1014 const remaining = Math.max(0, r.data.expiresAt - Date.now());1015 expireTimer = setTimeout(() => {1016 setCode(null);1017 setQrUrl(null);1018 setExpired(true);1019 }, remaining);1020 }10211022 function handleRuntimeMsg(msg: unknown) {1023 const m = msg as { type?: string; payload?: { deviceName?: string } };1024 if (m?.type === "PAIRING_COMPLETED") {1025 clearExpire();1026 setDeviceName(m.payload?.deviceName ?? "your phone");1027 setState("trust");1028 }1029 }10301031 onMount(() => {1032 void startPairing();1033 1034 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1035 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1036 }1037 });10381039 onCleanup(() => {1040 clearExpire();1041 1042 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1043 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1044 }1045 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1046 });10471048 async function handleAccept(trust: boolean) {1049 setAccepting(true);1050 if (trust) {1051 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1052 }1053 await props.onSuccess();1054 }10551056 return (1057 <div class="peach-rt-phone">1058 <Show when={state() === "waiting"}>1059 <Show when={!expired()}>1060 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1061 <p class="peach-rt-phone-code">{code()}</p>1062 <Show when={qrUrl() !== null}>1063 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1064 </Show>1065 <p class="peach-rt-phone-hint">1066 Open Peach on your phone and tap<br />1067 Settings → Send to desktop.<br />1068 Same WiFi required.1069 </p>1070 </Show>1071 <Show when={serverError()}>1072 <p class="peach-rt-error">{serverError()}</p>1073 </Show>1074 </Show>1075 <Show when={expired()}>1076 <p class="peach-rt-phone-hint">Code expired.</p>1077 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1078 Generate new code1079 </button>1080 </Show>1081 </Show>10821083 <Show when={state() === "connected"}>1084 <p class="peach-rt-phone-hint">1085 Connected to {deviceName() ?? "your phone"}.<br />1086 Receiving vault…1087 </p>1088 </Show>10891090 <Show when={state() === "trust"}>1091 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1092 Vault received from {deviceName() ?? "your phone"}.<br />1093 Trust this network for sync?1094 </p>1095 <button1096 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1097 onClick={() => void handleAccept(true)}1098 disabled={accepting()}1099 >1100 Yes, trust this network1101 </button>1102 <button1103 class="peach-btn peach-btn--ghost peach-btn--sm"1104 style="margin-top:6px"1105 onClick={() => void handleAccept(false)}1106 disabled={accepting()}1107 >1108 Not now1109 </button>1110 </Show>11111112 <Show when={state() === "error"}>1113 <p class="peach-rt-phone-hint">1114 Could not connect. Check that both devices<br />1115 are on the same WiFi network.1116 </p>1117 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1118 Try again1119 </button>1120 </Show>1121 </div>1122 );1123}1124112511261127function getDisplayError(error: string | undefined, fallback: string): string {1128 if (!error) return fallback;1129 if (1130 error === "SERVICE_WORKER_UNAVAILABLE" ||1131 error.includes("Receiving end does not exist") ||1132 error.includes("message port closed")1133 ) {1134 return "background service unavailable";1135 }1136 return error;1137}