1import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { Capacitor } from "@capacitor/core";34import { sendMessage } from "../App";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { PeachMark, PeachWordmark } from "../components/PeachMark";7import { RuneBackground } from "../components/RuneBackground";8import { ImportView } from "./Import";9import { biometricUnlock, biometricAvailable } from "../../shared/biometric";10import { storageGet, storageSet } from "../../shared/storage";11import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";12import { parsePayload } from "../../crypto/peachPayload";13import { parseImportSource } from "../../shared/import";141516let _zxcvbn: ((password: string) => { score: number }) | null = null;17async function getZxcvbn() {18 if (_zxcvbn) return _zxcvbn;19 const mod = await import("zxcvbn");20 _zxcvbn = mod.default;21 return _zxcvbn;22}2324interface LockProps {25 hasVault: boolean;26 ready: boolean;27 isPro?: boolean;28 onUnlock: () => void | Promise<void>;29 303132 onBackToOnboarding?: () => void;33}3435type RestoreSurface = "restore" | null;3637export function LockView(props: LockProps) {38 const [password, setPassword] = createSignal("");39 const [confirmPassword, setConfirmPassword] = createSignal("");40 const [error, setError] = createSignal("");41 const [loading, setLoading] = createSignal(false);42 43 44 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(45 new URLSearchParams(location.search).has("detached") ? "restore" : null46 );47 const [showPassword, setShowPassword] = createSignal(false);4849 const MIN_LENGTH = 12;50 const MIN_ZXCVBN_SCORE = 2;5152 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);5354 const canCreate = createMemo(55 () =>56 password().length >= MIN_LENGTH &&57 confirmPassword().length >= MIN_LENGTH &&58 password() === confirmPassword() &&59 strengthScore() !== null &&60 strengthScore()! >= MIN_ZXCVBN_SCORE61 );6263 const passwordStrength = createMemo(() => {64 const score = strengthScore();65 if (score === null) return null;66 if (score <= 1) return "weak";67 if (score === 2) return "fair";68 if (score === 3) return "good";69 return "strong";70 });7172 const createBlockerMessage = createMemo(() => {73 if (!password()) return null;74 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;75 if (!confirmPassword()) return "confirm your password";76 if (password() !== confirmPassword()) return "passwords don't match";77 if (strengthScore() === null) return "checking strength…";78 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";79 return null;80 });8182 83 createMemo(() => {84 const value = password();85 if (!value) { setStrengthScore(null); return; }86 getZxcvbn().then((zxcvbn) => {87 if (password() === value) setStrengthScore(zxcvbn(value).score);88 }).catch(() => {89 90 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);91 });92 });9394 async function handleUnlock() {95 if (!password().trim()) return;96 setLoading(true);97 setError("");98 const response = await sendMessage({99 type: "UNLOCK",100 payload: { password: password() },101 });102 const result = response as { ok: boolean; error?: string };103 if (result.ok) {104 await props.onUnlock();105 setLoading(false);106 return;107 }108 setError(getDisplayError(result.error, "incorrect password"));109 setPassword("");110 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;111 passwordField?.classList.add("shake");112 setTimeout(() => passwordField?.classList.remove("shake"), 300);113 setLoading(false);114 }115116 async function handleCreate() {117 if (!password().trim()) return;118 if (password().length < MIN_LENGTH) {119 setError(`password must be at least ${MIN_LENGTH} characters`);120 return;121 }122 if (password() !== confirmPassword()) {123 setError("passwords do not match");124 return;125 }126 const score = strengthScore();127 if (score === null || score < MIN_ZXCVBN_SCORE) {128 setError("password is too weak — try a longer or more varied passphrase");129 return;130 }131 setLoading(true);132 setError("");133 const response = await sendMessage({134 type: "CREATE_VAULT",135 payload: { password: password() },136 });137 const result = response as { ok: boolean; error?: string };138 if (result.ok) {139 await props.onUnlock();140 setLoading(false);141 return;142 }143 setError(getDisplayError(result.error, "failed to create vault"));144 setLoading(false);145 }146147 async function openRestore(surface: Exclude<RestoreSurface, null>) {148 if (!props.ready || loading()) return;149 setPassword("");150 setConfirmPassword("");151 setError("");152153 154 155 156 157 const isDetached = new URLSearchParams(location.search).has("detached");158 if (!isDetached) {159 try {160 const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";161 await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });162 window.close();163 return;164 } catch {165 166 }167 }168169 setRestoreSurface(surface);170 }171172 173 return (174 <Switch>175 {}176 <Match when={restoreSurface() === "restore"}>177 <RestoreTabView178 hasVault={props.hasVault}179 isPro={props.isPro ?? false}180 onBack={() => setRestoreSurface(null)}181 onUnlock={async () => {182 setRestoreSurface(null);183 await props.onUnlock();184 }}185 />186 </Match>187188 {}189 <Match when={props.hasVault}>190 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">191 <header class="peach-screen-header lock-screen-header">192 <div class="peach-wordmark-stack">193 <PeachWordmark class="peach-wordmark-wrap" />194 <Show when={props.isPro}>195 <span class="peach-pro-mini-badge">pro</span>196 </Show>197 </div>198 </header>199200 <div class="peach-lock-hero peach-lock-hero--unlock">201 <div class="peach-lock-hero-art" aria-hidden="true">202 <PeachMark class="peach-lock-hero-mark" />203 </div>204 <span class="peach-lock-eyebrow">vault locked</span>205 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">206 unlock207 <br />208 your vault.209 </h1>210 </div>211212 <LockRuneCanvas />213214 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">215 <label class="peach-label" for="unlock-password">216 master password217 </label>218 <input219 id="unlock-password"220 class="peach-underline-input peach-unlock-input lock-password-input"221 type="password"222 placeholder="••••••••••••"223 autocomplete="current-password"224 value={password()}225 disabled={!props.ready}226 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}227 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}228 onKeyDown={(event) => {229 if (event.key !== "Enter" || !props.ready) return;230 void handleUnlock();231 }}232 />233234 <Show when={error()}>235 <p class="peach-hint peach-hint-error">{error()}</p>236 </Show>237238 <button239 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"240 onClick={() => void handleUnlock()}241 disabled={!props.ready || loading()}242 >243 {loading() ? "unlocking..." : "unlock"}244 </button>245246 <Show when={Capacitor.isNativePlatform()}>247 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />248 </Show>249 </section>250 </section>251 </Match>252253 {}254 <Match when={true}>255 <section256 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"257 data-motion-phase="enter"258 >259 <header class="peach-screen-header peach-newvault-header">260 <Show when={props.onBackToOnboarding}>261 <button262 class="peach-newvault-back"263 type="button"264 onClick={() => props.onBackToOnboarding?.()}265 aria-label="Back to onboarding"266 >267 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">268 <path d="M7 2L3 5.5L7 9" />269 </svg>270 back271 </button>272 </Show>273 <div class="peach-wordmark-stack">274 <PeachWordmark class="peach-wordmark-wrap" />275 <Show when={props.isPro}>276 <span class="peach-pro-mini-badge">pro</span>277 </Show>278 </div>279 </header>280281 <div class="peach-newvault-mark" aria-hidden="true">282 <PeachMark class="peach-newvault-mark-art" />283 </div>284285 <div class="peach-newvault-card">286 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>287 <hr class="peach-newvault-divider" />288289 <div class="peach-newvault-field">290 <div class="peach-newvault-label-row">291 <label class="peach-label" for="master-password">292 master password293 </label>294 <button295 class="peach-lock-reveal-btn"296 type="button"297 onClick={() => setShowPassword((value) => !value)}298 aria-label={showPassword() ? "Hide password" : "Show password"}299 >300 <Show301 when={showPassword()}302 fallback={303 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">304 <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" />305 <circle cx="7" cy="5" r="1.75" />306 </svg>307 }308 >309 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">310 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />311 <path d="M3.5 5.5L2.5 7.5" />312 <path d="M7 6.5V8.5" />313 <path d="M10.5 5.5L11.5 7.5" />314 </svg>315 </Show>316 {showPassword() ? "hide" : "show"}317 </button>318 </div>319 <input320 id="master-password"321 class="peach-underline-input peach-newvault-input lock-password-input"322 type={showPassword() ? "text" : "password"}323 placeholder="••••••••••••"324 autocomplete="new-password"325 value={password()}326 disabled={!props.ready}327 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}328 onKeyDown={(event) => {329 if (event.key !== "Enter" || !props.ready) return;330 void handleCreate();331 }}332 />333 <Show334 when={password().length > 0 && passwordStrength() !== null}335 fallback={336 <p class="peach-newvault-hint">at least 12 characters</p>337 }338 >339 <div class="peach-password-strength">340 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>341 <div class="peach-strength-fill"></div>342 </div>343 <span class="peach-strength-label">{passwordStrength()}</span>344 </div>345 </Show>346 </div>347348 <div class="peach-newvault-field">349 <label class="peach-label" for="confirm-password">350 confirm password351 </label>352 <input353 id="confirm-password"354 class="peach-underline-input peach-newvault-input"355 type={showPassword() ? "text" : "password"}356 placeholder="••••••••••••"357 autocomplete="new-password"358 value={confirmPassword()}359 disabled={!props.ready}360 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}361 onKeyDown={(event) => {362 if (event.key === "Enter" && props.ready) void handleCreate();363 }}364 />365 </div>366367 <Show when={error()}>368 <p class="peach-hint peach-hint-error">{error()}</p>369 </Show>370371 <button372 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"373 onClick={() => void handleCreate()}374 disabled={!props.ready || loading() || !canCreate()}375 aria-live="polite"376 >377 <Show378 when={!canCreate() && createBlockerMessage()}379 fallback={loading() ? "creating..." : "create vault"}380 >381 {createBlockerMessage()}382 </Show>383 </button>384385 <p class="peach-newvault-warning">386 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>387 <span>If you forget it, your vault cannot be opened. There is no reset.</span>388 </p>389 </div>390391 <div class="peach-newvault-footer">392 <p class="peach-hint">Already have a backup?</p>393 <button394 class="peach-lock-restore-link"395 onClick={() => void openRestore("restore")}396 disabled={!props.ready}397 >398 › restore from backup399 </button>400 </div>401 </section>402 </Match>403 </Switch>404 );405}406407408409type RestoreTab = "symbols" | "mayan" | "csv" | "phone";410411function tabLabel(tab: RestoreTab): string {412 return (413 { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]414 );415}416417function RestoreTabView(props: {418 hasVault: boolean;419 isPro: boolean;420 onBack: () => void;421 onUnlock: () => void | Promise<void>;422}) {423 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");424 const [showIntro, setShowIntro] = createSignal(false);425 const [pendingUnlock, setPendingUnlock] = createSignal(false);426427 const isDetachedWindow = new URLSearchParams(location.search).has("detached");428429 async function reattachPopup() {430 if (!isDetachedWindow) return;431 432 try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}433 window.close();434 }435436 async function handleRestoreSuccess() {437 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);438 if (!seen) {439 setShowIntro(true);440 } else {441 await props.onUnlock();442 await reattachPopup();443 }444 }445446 async function dismissIntro() {447 await storageSet("peach_shown_restore_intro", "true").catch(() => {});448 setShowIntro(false);449 await props.onUnlock();450 await reattachPopup();451 }452453 return (454 <section455 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"456 data-motion-phase="enter"457 >458 <RuneBackground opacity={0.05} />459460 <header class="peach-screen-header lock-screen-header">461 <PeachWordmark class="peach-wordmark-wrap" />462 <button class="peach-lock-restore-link" onClick={props.onBack}>463 ‹ back464 </button>465 </header>466467 <div class="peach-newvault-card peach-restore-tabcard">468 <div class="peach-restore-tabs" role="tablist">469 <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>470 {(tab) => (471 <button472 class="peach-restore-tab"473 classList={{ "is-active": activeTab() === tab }}474 onClick={() => setActiveTab(tab)}475 role="tab"476 aria-selected={activeTab() === tab}477 >478 {tabLabel(tab)}479 </button>480 )}481 </For>482 </div>483 <div class="peach-restore-content">484 <Show when={activeTab() === "symbols"}>485 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />486 </Show>487 <Show when={activeTab() === "mayan"}>488 <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />489 </Show>490 <Show when={activeTab() === "csv"}>491 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />492 </Show>493 <Show when={activeTab() === "phone"}>494 <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />495 </Show>496 </div>497 </div>498499 {}500 <Show when={showIntro()}>501 <div class="peach-restore-intro-overlay">502 <div class="peach-restore-intro-card">503 <p class="peach-restore-intro-headline">welcome to peach.</p>504 <div class="peach-restore-intro-body">505 <p>Your vault lives only on this device. No server has a copy.</p>506 <p>The inline bubble fills passwords automatically when you visit sites.</p>507 <p>Export your vault from Settings before switching devices.</p>508 </div>509 <button510 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"511 onClick={() => void dismissIntro()}512 disabled={pendingUnlock()}513 >514 got it515 </button>516 </div>517 </div>518 </Show>519 </section>520 );521}522523524525async function handleMayanDecode(base64: string): Promise<526 { ok: true; payload: Uint8Array } | { ok: false; reason: string }527> {528 const { decodeMayan } = await import("../../crypto/mayan");529 const img = new Image();530 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;531 await new Promise<void>((resolve, reject) => {532 img.onload = () => resolve();533 img.onerror = () => reject(new Error("Couldn't read the image."));534 img.src = dataUrl;535 });536 const canvas = document.createElement("canvas");537 538 const MAX_DIM = 1500;539 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));540 canvas.width = Math.round(img.width * scale);541 canvas.height = Math.round(img.height * scale);542 const ctx = canvas.getContext("2d");543 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };544 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);545 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);546 const payload = decodeMayan(imageData);547 if (!payload) {548 return {549 ok: false,550 reason: "Could not read this Mayan code. Try a clearer image.",551 };552 }553 return { ok: true, payload };554}555556557558function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {559 const [scriptInput, setScriptInput] = createSignal("");560 const [backupPass, setBackupPass] = createSignal("");561 const [working, setWorking] = createSignal(false);562 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);563 const [error, setError] = createSignal<string | null>(null);564 let canvasRef: HTMLCanvasElement | undefined;565 let animFrameId = 0;566 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];567568 const scriptValid = createMemo(() => isPeachScript(scriptInput()));569570 function stopAnim() {571 cancelAnimationFrame(animFrameId);572 animFrameId = 0;573 }574575 function startChaos(canvas: HTMLCanvasElement) {576 const ctx = canvas.getContext("2d")!;577 const W = canvas.width;578 const H = canvas.height;579 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>580 String.fromCodePoint(0xe000 + i)581 );582 if (runes.length === 0) {583 runes = Array.from({ length: 60 }, () => ({584 x: Math.random() * W,585 y: Math.random() * H,586 vx: (Math.random() - 0.5) * 3,587 vy: (Math.random() - 0.5) * 3,588 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,589 size: 14 + Math.random() * 20,590 angle: Math.random() * Math.PI * 2,591 va: (Math.random() - 0.5) * 0.1,592 }));593 }594595 function draw() {596 ctx.clearRect(0, 0, W, H);597 for (const r of runes) {598 r.x = (r.x + r.vx + W) % W;599 r.y = (r.y + r.vy + H) % H;600 r.angle += r.va;601 ctx.save();602 ctx.translate(r.x, r.y);603 ctx.rotate(r.angle);604 ctx.font = `${r.size}px PeachScript, monospace`;605 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);606 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;607 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";608 ctx.shadowBlur = 8;609 ctx.fillText(r.char, 0, 0);610 ctx.restore();611 }612 animFrameId = requestAnimationFrame(draw);613 }614 draw();615 }616617 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {618 stopAnim();619 const ctx = canvas.getContext("2d")!;620 const W = canvas.width;621 const H = canvas.height;622 const cols = 8;623 const rows = 6;624 const cellW = W / cols;625 const cellH = H / rows;626 const targets = runes.slice(0, cols * rows).map((_, i) => ({627 x: (i % cols) * cellW + cellW / 2,628 y: Math.floor(i / cols) * cellH + cellH / 2,629 }));630 const start = performance.now();631 const DURATION = 400;632633 function drawSnap(now: number) {634 const t = Math.min((now - start) / DURATION, 1);635 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;636 ctx.clearRect(0, 0, W, H);637 runes.slice(0, targets.length).forEach((r, i) => {638 const tx = targets[i]!.x;639 const ty = targets[i]!.y;640 const cx = r.x + (tx - r.x) * ease;641 const cy = r.y + (ty - r.y) * ease;642 ctx.font = `16px PeachScript, monospace`;643 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;644 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";645 ctx.shadowBlur = 6;646 ctx.fillText(r.char, cx - 8, cy + 8);647 });648 if (t < 1) {649 animFrameId = requestAnimationFrame(drawSnap);650 } else {651 onDone();652 }653 }654 animFrameId = requestAnimationFrame(drawSnap);655 }656657 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {658 stopAnim();659 const ctx = canvas.getContext("2d")!;660 const W = canvas.width;661 const H = canvas.height;662 const start = performance.now();663 const DURATION = 900;664665 function drawBloom(now: number) {666 const t = Math.min((now - start) / DURATION, 1);667 ctx.clearRect(0, 0, W, H);668 669 const runeAlpha = 1 - Math.min(t / 0.4, 1);670 if (runeAlpha > 0) {671 for (const r of runes.slice(0, 48)) {672 ctx.font = `16px PeachScript, monospace`;673 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;674 ctx.fillText(r.char, r.x - 8, r.y + 8);675 }676 }677 678 const markT = Math.max(0, (t - 0.2) / 0.5);679 const markScale = Math.min(markT * 1.2, 1);680 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;681 if (markAlpha > 0) {682 ctx.save();683 ctx.translate(W / 2, H / 2);684 ctx.scale(markScale, markScale);685 ctx.font = `bold 64px serif`;686 ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;687 ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;688 ctx.shadowBlur = 30;689 ctx.textAlign = "center";690 ctx.textBaseline = "middle";691 ctx.fillText("🍑", 0, 0);692 ctx.restore();693 }694 if (t < 1) {695 animFrameId = requestAnimationFrame(drawBloom);696 } else {697 onDone();698 }699 }700 animFrameId = requestAnimationFrame(drawBloom);701 }702703 onCleanup(() => stopAnim());704705 async function handleRestore() {706 if (!scriptValid() || !backupPass().trim() || working()) return;707 setError(null);708 setWorking(true);709 setAnimating("chaos");710711 const canvas = canvasRef;712 if (canvas) startChaos(canvas);713714 try {715 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));716 const payload = await parsePayload(bytes, backupPass());717718 if (!props.hasVault) {719 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });720 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");721 }722723 const rr = await sendMessage({724 type: "RESTORE_ENTRIES",725 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },726 });727 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");728729 if (payload.license?.key) {730 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});731 }732733 setAnimating("snap");734 stopAnim();735 if (canvas) {736 snapToGrid(canvas, () => {737 setAnimating("bloom");738 bloom(canvas, async () => {739 setAnimating(null);740 await props.onSuccess();741 });742 });743 } else {744 await props.onSuccess();745 }746 } catch (err) {747 stopAnim();748 setAnimating(null);749 const code = err instanceof Error ? err.message : String(err);750 setError(751 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"752 ? "That passphrase didn't work."753 : code === "CREATE_VAULT_FAILED"754 ? "Couldn't create a new local vault."755 : "Could not read this backup.",756 );757 } finally {758 setWorking(false);759 }760 }761762 return (763 <div class="peach-rt-symbols">764 <Show when={animating() !== null}>765 <canvas766 ref={(el) => { canvasRef = el; }}767 class="peach-rt-anim-canvas"768 width={280}769 height={200}770 />771 </Show>772 <Show when={animating() === null}>773 <textarea774 class="peach-restore-symbols-textarea"775 placeholder="paste PeachScript here…"776 rows={3}777 value={scriptInput()}778 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}779 />780 <div class="peach-rt-divider">— or —</div>781 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">782 open .peach file783 <input784 type="file"785 accept=".peach,text/plain"786 style="display:none"787 onChange={async (e) => {788 const file = (e.currentTarget as HTMLInputElement).files?.[0];789 if (!file) return;790 try {791 setScriptInput(await file.text());792 } catch {793 setError("Couldn't read file.");794 } finally {795 (e.currentTarget as HTMLInputElement).value = "";796 }797 }}798 />799 </label>800 <input801 class="peach-restore-symbols-pass"802 type="password"803 placeholder="backup password"804 value={backupPass()}805 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}806 onKeyDown={(e) => {807 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {808 e.preventDefault();809 void handleRestore();810 }811 }}812 />813 <button814 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"815 onClick={() => void handleRestore()}816 disabled={!scriptValid() || !backupPass().trim() || working()}817 >818 restore vault →819 </button>820 <Show when={error()}>821 <p class="peach-rt-error">{error()}</p>822 </Show>823 </Show>824 </div>825 );826}827828829830function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {831 const [decoding, setDecoding] = createSignal(false);832 const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);833 const [entryLine, setEntryLine] = createSignal<string | null>(null);834 const [backupPass, setBackupPass] = createSignal("");835 const [restoring, setRestoring] = createSignal(false);836 const [error, setError] = createSignal<string | null>(null);837838 const isAndroid = createMemo(() =>839 Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"840 );841842 async function processFile(file: File) {843 setDecoding(true);844 setDecoded(null);845 setEntryLine(null);846 setError(null);847 try {848 const { decodeMayan } = await import("../../crypto/mayan");849 const bitmap = await createImageBitmap(file);850 const MAX_DIM = 1500;851 const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));852 const canvas = document.createElement("canvas");853 canvas.width = Math.round(bitmap.width * scale);854 canvas.height = Math.round(bitmap.height * scale);855 const ctx = canvas.getContext("2d")!;856 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);857 bitmap.close();858 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);859 const payload = decodeMayan(imageData);860 if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }861 setDecoded(payload);862 } catch {863 setError("Could not read this Mayan code. Try a clearer image.");864 } finally {865 setDecoding(false);866 }867 }868869 async function handleCameraScan() {870 setError(null);871 try {872 const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");873 const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });874 if (!image.base64String) return;875 setDecoding(true);876 const result = await handleMayanDecode(image.base64String);877 setDecoding(false);878 if (!result.ok) { setError(result.reason); return; }879 setDecoded(result.payload);880 } catch (err) {881 setDecoding(false);882 const msg = err instanceof Error ? err.message : "";883 if (msg && !/cancel/i.test(msg)) setError(msg);884 }885 }886887 async function handleRestore() {888 const bytes = decoded();889 if (!bytes || !backupPass().trim() || restoring()) return;890 setRestoring(true);891 setError(null);892 try {893 const payload = await parsePayload(bytes, backupPass());894 const count = payload.entries.length;895 const date = new Date(896 Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))897 );898 const monthYear = date.getFullYear() > 2000899 ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })900 : null;901 setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);902903 if (!props.hasVault) {904 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });905 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");906 }907 const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });908 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");909 if (payload.license?.key) {910 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});911 }912 await props.onSuccess();913 } catch (err) {914 const code = err instanceof Error ? err.message : String(err);915 setError(916 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"917 ? "Wrong backup password."918 : code === "CREATE_VAULT_FAILED"919 ? "Couldn't create a new local vault."920 : "Restore failed. The backup may be corrupted.",921 );922 } finally {923 setRestoring(false);924 }925 }926927 return (928 <div class="peach-rt-mayan">929 <Show when={decoded() === null}>930 <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>931 <span class="peach-rt-upload-label">932 {decoding() ? "decoding…" : "tap to upload image"}933 </span>934 <input935 type="file"936 accept="image/*"937 style="display:none"938 onChange={(e) => {939 const file = (e.currentTarget as HTMLInputElement).files?.[0];940 if (file) void processFile(file);941 (e.currentTarget as HTMLInputElement).value = "";942 }}943 disabled={decoding()}944 />945 </label>946 <Show when={isAndroid()}>947 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>948 scan with camera949 </button>950 </Show>951 <Show when={error()}>952 <p class="peach-rt-error">{error()}</p>953 </Show>954 </Show>955 <Show when={decoded() !== null}>956 <Show when={entryLine()}>957 <p class="peach-rt-entry-line">{entryLine()}</p>958 </Show>959 <input960 class="peach-restore-symbols-pass"961 type="password"962 placeholder="backup password"963 ref={(el) => queueMicrotask(() => el.focus())}964 value={backupPass()}965 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}966 onKeyDown={(e) => {967 if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }968 }}969 />970 <button971 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"972 onClick={() => void handleRestore()}973 disabled={!backupPass().trim() || restoring()}974 >975 {restoring() ? "restoring…" : "restore vault →"}976 </button>977 <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>978 ← try another image979 </button>980 <Show when={error()}>981 <p class="peach-rt-error">{error()}</p>982 </Show>983 </Show>984 </div>985 );986}987988989990function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {991 const [importing, setImporting] = createSignal(false);992 const [error, setError] = createSignal<string | null>(null);993994 async function handleFile(e: Event) {995 const input = e.currentTarget as HTMLInputElement;996 const file = input.files?.[0];997 input.value = "";998 if (!file) return;999 setImporting(true);1000 setError(null);1001 try {1002 const text = await file.text();1003 const parsed = await parseImportSource(text);1004 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10051006 if (props.hasVault === false) {1007 1008 1009 1010 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1011 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1012 }10131014 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1015 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1016 if (parsed.licenseKey) {1017 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1018 }1019 await props.onSuccess();1020 } catch (err) {1021 const code = err instanceof Error ? err.message : String(err);1022 setError(1023 code === "NO_ENTRIES" ? "No entries found in this file." :1024 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1025 "Import failed. Check the file format and try again."1026 );1027 } finally {1028 setImporting(false);1029 }1030 }10311032 return (1033 <div class="peach-rt-csv">1034 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1035 {importing() ? "importing…" : "open CSV file"}1036 <input1037 type="file"1038 accept=".csv,.json,.peach,application/x-peach"1039 style="display:none"1040 onChange={handleFile}1041 disabled={importing()}1042 />1043 </label>1044 <p class="peach-rt-csv-sources">1045 Imports from Bitwarden, 1Password, Proton Pass,<br />1046 Chrome, Safari, and Edge.1047 </p>1048 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1049 <Show when={error()}>1050 <p class="peach-rt-error">{error()}</p>1051 </Show>1052 </div>1053 );1054}1055105610571058type PhoneState = "waiting" | "connected" | "trust" | "error";10591060function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1061 const [state, setState] = createSignal<PhoneState>("waiting");1062 const [code, setCode] = createSignal<string | null>(null);1063 const [qrUrl, setQrUrl] = createSignal<string | null>(null);1064 const [expired, setExpired] = createSignal(false);1065 const [deviceName, setDeviceName] = createSignal<string | null>(null);1066 const [accepting, setAccepting] = createSignal(false);1067 const [serverError, setServerError] = createSignal<string | null>(null);1068 let expireTimer: ReturnType<typeof setTimeout> | null = null;10691070 function clearExpire() {1071 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1072 }10731074 async function startPairing() {1075 setExpired(false);1076 setCode(null);1077 setQrUrl(null);1078 setServerError(null);1079 clearExpire();1080 const res = await sendMessage({ type: "START_PAIRING_SERVER" });1081 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1082 if (!r.ok || !r.data) {1083 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1084 return;1085 }1086 const token = r.data.token;1087 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1088 setCode(formatted);1089 const qrData = `peach://handoff?token=${token}`;1090 try {1091 const QRCode = (await import("qrcode")).default;1092 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1093 setQrUrl(url);1094 } catch {1095 1096 }1097 const remaining = Math.max(0, r.data.expiresAt - Date.now());1098 expireTimer = setTimeout(() => {1099 setCode(null);1100 setQrUrl(null);1101 setExpired(true);1102 }, remaining);1103 }11041105 function handleRuntimeMsg(msg: unknown) {1106 const m = msg as { type?: string; payload?: { deviceName?: string } };1107 if (m?.type === "PAIRING_COMPLETED") {1108 clearExpire();1109 setDeviceName(m.payload?.deviceName ?? "your phone");1110 setState("trust");1111 }1112 }11131114 onMount(() => {1115 if (!props.isPro) return;1116 void startPairing();1117 1118 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1119 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1120 }1121 });11221123 onCleanup(() => {1124 clearExpire();1125 1126 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1127 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1128 }1129 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1130 });11311132 async function handleAccept(trust: boolean) {1133 setAccepting(true);1134 if (trust) {1135 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1136 }1137 await props.onSuccess();1138 }11391140 if (!props.isPro) {1141 return (1142 <div class="peach-rt-phone">1143 <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1144 <button1145 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1146 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1147 >1148 Get Peach Pro1149 </button>1150 <button1151 class="peach-btn peach-btn--ghost peach-btn--sm"1152 style="margin-top:6px"1153 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1154 >1155 I already have a license1156 </button>1157 </div>1158 );1159 }11601161 return (1162 <div class="peach-rt-phone">1163 <Show when={state() === "waiting"}>1164 <Show when={!expired()}>1165 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1166 <p class="peach-rt-phone-code">{code()}</p>1167 <Show when={qrUrl() !== null}>1168 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1169 </Show>1170 <p class="peach-rt-phone-hint">1171 Open Peach on your phone and tap<br />1172 Settings → Send to desktop.<br />1173 Same WiFi required.1174 </p>1175 </Show>1176 <Show when={serverError()}>1177 <p class="peach-rt-error">{serverError()}</p>1178 </Show>1179 </Show>1180 <Show when={expired()}>1181 <p class="peach-rt-phone-hint">Code expired.</p>1182 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1183 Generate new code1184 </button>1185 </Show>1186 </Show>11871188 <Show when={state() === "connected"}>1189 <p class="peach-rt-phone-hint">1190 Connected to {deviceName() ?? "your phone"}.<br />1191 Receiving vault…1192 </p>1193 </Show>11941195 <Show when={state() === "trust"}>1196 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1197 Vault received from {deviceName() ?? "your phone"}.<br />1198 Trust this network for sync?1199 </p>1200 <button1201 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1202 onClick={() => void handleAccept(true)}1203 disabled={accepting()}1204 >1205 Yes, trust this network1206 </button>1207 <button1208 class="peach-btn peach-btn--ghost peach-btn--sm"1209 style="margin-top:6px"1210 onClick={() => void handleAccept(false)}1211 disabled={accepting()}1212 >1213 Not now1214 </button>1215 </Show>12161217 <Show when={state() === "error"}>1218 <p class="peach-rt-phone-hint">1219 Could not connect. Check that both devices<br />1220 are on the same WiFi network.1221 </p>1222 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1223 Try again1224 </button>1225 </Show>1226 </div>1227 );1228}1229123012311232async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1233 const unlocked = await biometricUnlock();1234 if (unlocked) {1235 await props.onUnlock();1236 }1237}12381239function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1240 const [bioAvailable, setBioAvailable] = createSignal(false);1241 onMount(async () => {1242 setBioAvailable(await biometricAvailable());1243 });1244 return (1245 <Show when={bioAvailable()}>1246 <button1247 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1248 onClick={() => void handleBiometricUnlock(props)}1249 disabled={!props.ready || props.loading()}1250 type="button"1251 >1252 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1253 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1254 <path d="M5 8v1a3 3 0 0 0 6 0V8" />1255 <line x1="8" y1="12" x2="8" y2="15" />1256 <line x1="5" y1="15" x2="11" y2="15" />1257 </svg>1258 use biometrics1259 </button>1260 </Show>1261 );1262}1263126412651266function getDisplayError(error: string | undefined, fallback: string): string {1267 if (!error) return fallback;1268 if (1269 error === "SERVICE_WORKER_UNAVAILABLE" ||1270 error.includes("Receiving end does not exist") ||1271 error.includes("message port closed")1272 ) {1273 return "background service unavailable";1274 }1275 return error;1276}