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 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);43 const [showPassword, setShowPassword] = createSignal(false);4445 const MIN_LENGTH = 12;46 const MIN_ZXCVBN_SCORE = 2;4748 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4950 const canCreate = createMemo(51 () =>52 password().length >= MIN_LENGTH &&53 confirmPassword().length >= MIN_LENGTH &&54 password() === confirmPassword() &&55 strengthScore() !== null &&56 strengthScore()! >= MIN_ZXCVBN_SCORE57 );5859 const passwordStrength = createMemo(() => {60 const score = strengthScore();61 if (score === null) return null;62 if (score <= 1) return "weak";63 if (score === 2) return "fair";64 if (score === 3) return "good";65 return "strong";66 });6768 const createBlockerMessage = createMemo(() => {69 if (!password()) return null;70 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;71 if (!confirmPassword()) return "confirm your password";72 if (password() !== confirmPassword()) return "passwords don't match";73 if (strengthScore() === null) return "checking strength…";74 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";75 return null;76 });7778 79 createMemo(() => {80 const value = password();81 if (!value) { setStrengthScore(null); return; }82 getZxcvbn().then((zxcvbn) => {83 if (password() === value) setStrengthScore(zxcvbn(value).score);84 }).catch(() => {85 86 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);87 });88 });8990 async function handleUnlock() {91 if (!password().trim()) return;92 setLoading(true);93 setError("");94 const response = await sendMessage({95 type: "UNLOCK",96 payload: { password: password() },97 });98 const result = response as { ok: boolean; error?: string };99 if (result.ok) {100 await props.onUnlock();101 setLoading(false);102 return;103 }104 setError(getDisplayError(result.error, "incorrect password"));105 setPassword("");106 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;107 passwordField?.classList.add("shake");108 setTimeout(() => passwordField?.classList.remove("shake"), 300);109 setLoading(false);110 }111112 async function handleCreate() {113 if (!password().trim()) return;114 if (password().length < MIN_LENGTH) {115 setError(`password must be at least ${MIN_LENGTH} characters`);116 return;117 }118 if (password() !== confirmPassword()) {119 setError("passwords do not match");120 return;121 }122 const score = strengthScore();123 if (score === null || score < MIN_ZXCVBN_SCORE) {124 setError("password is too weak — try a longer or more varied passphrase");125 return;126 }127 setLoading(true);128 setError("");129 const response = await sendMessage({130 type: "CREATE_VAULT",131 payload: { password: password() },132 });133 const result = response as { ok: boolean; error?: string };134 if (result.ok) {135 await props.onUnlock();136 setLoading(false);137 return;138 }139 setError(getDisplayError(result.error, "failed to create vault"));140 setLoading(false);141 }142143 function openRestore(surface: Exclude<RestoreSurface, null>) {144 if (!props.ready || loading()) return;145 setPassword("");146 setConfirmPassword("");147 setError("");148 setRestoreSurface(surface);149 }150151 152 return (153 <Switch>154 {}155 <Match when={restoreSurface() === "restore"}>156 <RestoreTabView157 hasVault={props.hasVault}158 isPro={props.isPro ?? false}159 onBack={() => setRestoreSurface(null)}160 onUnlock={async () => {161 setRestoreSurface(null);162 await props.onUnlock();163 }}164 />165 </Match>166167 {}168 <Match when={props.hasVault}>169 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">170 <header class="peach-screen-header lock-screen-header">171 <div class="peach-wordmark-stack">172 <PeachWordmark class="peach-wordmark-wrap" />173 <Show when={props.isPro}>174 <span class="peach-pro-mini-badge">pro</span>175 </Show>176 </div>177 </header>178179 <div class="peach-lock-hero peach-lock-hero--unlock">180 <div class="peach-lock-hero-art" aria-hidden="true">181 <PeachMark class="peach-lock-hero-mark" />182 </div>183 <span class="peach-lock-eyebrow">vault locked</span>184 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">185 unlock186 <br />187 your vault.188 </h1>189 </div>190191 <LockRuneCanvas />192193 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">194 <label class="peach-label" for="unlock-password">195 master password196 </label>197 <input198 id="unlock-password"199 class="peach-underline-input peach-unlock-input lock-password-input"200 type="password"201 placeholder="••••••••••••"202 autocomplete="current-password"203 value={password()}204 disabled={!props.ready}205 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}206 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}207 onKeyDown={(event) => {208 if (event.key !== "Enter" || !props.ready) return;209 void handleUnlock();210 }}211 />212213 <Show when={error()}>214 <p class="peach-hint peach-hint-error">{error()}</p>215 </Show>216217 <button218 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"219 onClick={() => void handleUnlock()}220 disabled={!props.ready || loading()}221 >222 {loading() ? "unlocking..." : "unlock"}223 </button>224225 <Show when={Capacitor.isNativePlatform()}>226 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />227 </Show>228 </section>229 </section>230 </Match>231232 {}233 <Match when={true}>234 <section235 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"236 data-motion-phase="enter"237 >238 <header class="peach-screen-header peach-newvault-header">239 <Show when={props.onBackToOnboarding}>240 <button241 class="peach-newvault-back"242 type="button"243 onClick={() => props.onBackToOnboarding?.()}244 aria-label="Back to onboarding"245 >246 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">247 <path d="M7 2L3 5.5L7 9" />248 </svg>249 back250 </button>251 </Show>252 <div class="peach-wordmark-stack">253 <PeachWordmark class="peach-wordmark-wrap" />254 <Show when={props.isPro}>255 <span class="peach-pro-mini-badge">pro</span>256 </Show>257 </div>258 </header>259260 <div class="peach-newvault-mark" aria-hidden="true">261 <PeachMark class="peach-newvault-mark-art" />262 </div>263264 <div class="peach-newvault-card">265 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>266 <hr class="peach-newvault-divider" />267268 <div class="peach-newvault-field">269 <div class="peach-newvault-label-row">270 <label class="peach-label" for="master-password">271 master password272 </label>273 <button274 class="peach-lock-reveal-btn"275 type="button"276 onClick={() => setShowPassword((value) => !value)}277 aria-label={showPassword() ? "Hide password" : "Show password"}278 >279 <Show280 when={showPassword()}281 fallback={282 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">283 <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" />284 <circle cx="7" cy="5" r="1.75" />285 </svg>286 }287 >288 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">289 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />290 <path d="M3.5 5.5L2.5 7.5" />291 <path d="M7 6.5V8.5" />292 <path d="M10.5 5.5L11.5 7.5" />293 </svg>294 </Show>295 {showPassword() ? "hide" : "show"}296 </button>297 </div>298 <input299 id="master-password"300 class="peach-underline-input peach-newvault-input lock-password-input"301 type={showPassword() ? "text" : "password"}302 placeholder="••••••••••••"303 autocomplete="new-password"304 value={password()}305 disabled={!props.ready}306 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}307 onKeyDown={(event) => {308 if (event.key !== "Enter" || !props.ready) return;309 void handleCreate();310 }}311 />312 <Show313 when={password().length > 0 && passwordStrength() !== null}314 fallback={315 <p class="peach-newvault-hint">at least 12 characters</p>316 }317 >318 <div class="peach-password-strength">319 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>320 <div class="peach-strength-fill"></div>321 </div>322 <span class="peach-strength-label">{passwordStrength()}</span>323 </div>324 </Show>325 </div>326327 <div class="peach-newvault-field">328 <label class="peach-label" for="confirm-password">329 confirm password330 </label>331 <input332 id="confirm-password"333 class="peach-underline-input peach-newvault-input"334 type={showPassword() ? "text" : "password"}335 placeholder="••••••••••••"336 autocomplete="new-password"337 value={confirmPassword()}338 disabled={!props.ready}339 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}340 onKeyDown={(event) => {341 if (event.key === "Enter" && props.ready) void handleCreate();342 }}343 />344 </div>345346 <Show when={error()}>347 <p class="peach-hint peach-hint-error">{error()}</p>348 </Show>349350 <button351 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"352 onClick={() => void handleCreate()}353 disabled={!props.ready || loading() || !canCreate()}354 aria-live="polite"355 >356 <Show357 when={!canCreate() && createBlockerMessage()}358 fallback={loading() ? "creating..." : "create vault"}359 >360 {createBlockerMessage()}361 </Show>362 </button>363364 <p class="peach-newvault-warning">365 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>366 <span>If you forget it, your vault cannot be opened. There is no reset.</span>367 </p>368 </div>369370 <div class="peach-newvault-footer">371 <p class="peach-hint">Already have a backup?</p>372 <button373 class="peach-lock-restore-link"374 onClick={() => openRestore("restore")}375 disabled={!props.ready}376 >377 › restore from backup378 </button>379 </div>380 </section>381 </Match>382 </Switch>383 );384}385386387388type RestoreTab = "symbols" | "mayan" | "csv" | "phone";389390function tabLabel(tab: RestoreTab): string {391 return (392 { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]393 );394}395396function RestoreTabView(props: {397 hasVault: boolean;398 isPro: boolean;399 onBack: () => void;400 onUnlock: () => void | Promise<void>;401}) {402 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");403 const [showIntro, setShowIntro] = createSignal(false);404 const [pendingUnlock, setPendingUnlock] = createSignal(false);405406 async function handleRestoreSuccess() {407 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);408 if (!seen) {409 setShowIntro(true);410 } else {411 await props.onUnlock();412 }413 }414415 async function dismissIntro() {416 await storageSet("peach_shown_restore_intro", "true").catch(() => {});417 setShowIntro(false);418 await props.onUnlock();419 }420421 return (422 <section423 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"424 data-motion-phase="enter"425 >426 <RuneBackground opacity={0.05} />427428 <header class="peach-screen-header lock-screen-header">429 <PeachWordmark class="peach-wordmark-wrap" />430 <button class="peach-lock-restore-link" onClick={props.onBack}>431 ‹ back432 </button>433 </header>434435 <div class="peach-newvault-card peach-restore-tabcard">436 <div class="peach-restore-tabs" role="tablist">437 <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>438 {(tab) => (439 <button440 class="peach-restore-tab"441 classList={{ "is-active": activeTab() === tab }}442 onClick={() => setActiveTab(tab)}443 role="tab"444 aria-selected={activeTab() === tab}445 >446 {tabLabel(tab)}447 </button>448 )}449 </For>450 </div>451 <div class="peach-restore-content">452 <Show when={activeTab() === "symbols"}>453 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />454 </Show>455 <Show when={activeTab() === "mayan"}>456 <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />457 </Show>458 <Show when={activeTab() === "csv"}>459 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />460 </Show>461 <Show when={activeTab() === "phone"}>462 <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />463 </Show>464 </div>465 </div>466467 {}468 <Show when={showIntro()}>469 <div class="peach-restore-intro-overlay">470 <div class="peach-restore-intro-card">471 <p class="peach-restore-intro-headline">welcome to peach.</p>472 <div class="peach-restore-intro-body">473 <p>Your vault lives only on this device. No server has a copy.</p>474 <p>The inline bubble fills passwords automatically when you visit sites.</p>475 <p>Export your vault from Settings before switching devices.</p>476 </div>477 <button478 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"479 onClick={() => void dismissIntro()}480 disabled={pendingUnlock()}481 >482 got it483 </button>484 </div>485 </div>486 </Show>487 </section>488 );489}490491492493async function handleMayanDecode(base64: string): Promise<494 { ok: true; payload: Uint8Array } | { ok: false; reason: string }495> {496 const { decodeMayan } = await import("../../crypto/mayan");497 const img = new Image();498 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;499 await new Promise<void>((resolve, reject) => {500 img.onload = () => resolve();501 img.onerror = () => reject(new Error("Couldn't read the image."));502 img.src = dataUrl;503 });504 const canvas = document.createElement("canvas");505 506 const MAX_DIM = 1500;507 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));508 canvas.width = Math.round(img.width * scale);509 canvas.height = Math.round(img.height * scale);510 const ctx = canvas.getContext("2d");511 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };512 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);513 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);514 const payload = decodeMayan(imageData);515 if (!payload) {516 return {517 ok: false,518 reason: "Could not read this Mayan code. Try a clearer image.",519 };520 }521 return { ok: true, payload };522}523524525526function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {527 const [scriptInput, setScriptInput] = createSignal("");528 const [backupPass, setBackupPass] = createSignal("");529 const [working, setWorking] = createSignal(false);530 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);531 const [error, setError] = createSignal<string | null>(null);532 let canvasRef: HTMLCanvasElement | undefined;533 let animFrameId = 0;534 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];535536 const scriptValid = createMemo(() => isPeachScript(scriptInput()));537538 function stopAnim() {539 cancelAnimationFrame(animFrameId);540 animFrameId = 0;541 }542543 function startChaos(canvas: HTMLCanvasElement) {544 const ctx = canvas.getContext("2d")!;545 const W = canvas.width;546 const H = canvas.height;547 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>548 String.fromCodePoint(0xe000 + i)549 );550 if (runes.length === 0) {551 runes = Array.from({ length: 60 }, () => ({552 x: Math.random() * W,553 y: Math.random() * H,554 vx: (Math.random() - 0.5) * 3,555 vy: (Math.random() - 0.5) * 3,556 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,557 size: 14 + Math.random() * 20,558 angle: Math.random() * Math.PI * 2,559 va: (Math.random() - 0.5) * 0.1,560 }));561 }562563 function draw() {564 ctx.clearRect(0, 0, W, H);565 for (const r of runes) {566 r.x = (r.x + r.vx + W) % W;567 r.y = (r.y + r.vy + H) % H;568 r.angle += r.va;569 ctx.save();570 ctx.translate(r.x, r.y);571 ctx.rotate(r.angle);572 ctx.font = `${r.size}px PeachScript, monospace`;573 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);574 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;575 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";576 ctx.shadowBlur = 8;577 ctx.fillText(r.char, 0, 0);578 ctx.restore();579 }580 animFrameId = requestAnimationFrame(draw);581 }582 draw();583 }584585 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {586 stopAnim();587 const ctx = canvas.getContext("2d")!;588 const W = canvas.width;589 const H = canvas.height;590 const cols = 8;591 const rows = 6;592 const cellW = W / cols;593 const cellH = H / rows;594 const targets = runes.slice(0, cols * rows).map((_, i) => ({595 x: (i % cols) * cellW + cellW / 2,596 y: Math.floor(i / cols) * cellH + cellH / 2,597 }));598 const start = performance.now();599 const DURATION = 400;600601 function drawSnap(now: number) {602 const t = Math.min((now - start) / DURATION, 1);603 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;604 ctx.clearRect(0, 0, W, H);605 runes.slice(0, targets.length).forEach((r, i) => {606 const tx = targets[i]!.x;607 const ty = targets[i]!.y;608 const cx = r.x + (tx - r.x) * ease;609 const cy = r.y + (ty - r.y) * ease;610 ctx.font = `16px PeachScript, monospace`;611 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;612 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";613 ctx.shadowBlur = 6;614 ctx.fillText(r.char, cx - 8, cy + 8);615 });616 if (t < 1) {617 animFrameId = requestAnimationFrame(drawSnap);618 } else {619 onDone();620 }621 }622 animFrameId = requestAnimationFrame(drawSnap);623 }624625 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {626 stopAnim();627 const ctx = canvas.getContext("2d")!;628 const W = canvas.width;629 const H = canvas.height;630 const start = performance.now();631 const DURATION = 900;632633 function drawBloom(now: number) {634 const t = Math.min((now - start) / DURATION, 1);635 ctx.clearRect(0, 0, W, H);636 637 const runeAlpha = 1 - Math.min(t / 0.4, 1);638 if (runeAlpha > 0) {639 for (const r of runes.slice(0, 48)) {640 ctx.font = `16px PeachScript, monospace`;641 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;642 ctx.fillText(r.char, r.x - 8, r.y + 8);643 }644 }645 646 const markT = Math.max(0, (t - 0.2) / 0.5);647 const markScale = Math.min(markT * 1.2, 1);648 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;649 if (markAlpha > 0) {650 ctx.save();651 ctx.translate(W / 2, H / 2);652 ctx.scale(markScale, markScale);653 ctx.font = `bold 64px serif`;654 ctx.fillStyle = `rgba(232, 149, 109, ${markAlpha})`;655 ctx.shadowColor = `rgba(232, 149, 109, ${markAlpha * 0.8})`;656 ctx.shadowBlur = 30;657 ctx.textAlign = "center";658 ctx.textBaseline = "middle";659 ctx.fillText("🍑", 0, 0);660 ctx.restore();661 }662 if (t < 1) {663 animFrameId = requestAnimationFrame(drawBloom);664 } else {665 onDone();666 }667 }668 animFrameId = requestAnimationFrame(drawBloom);669 }670671 onCleanup(() => stopAnim());672673 async function handleRestore() {674 if (!scriptValid() || !backupPass().trim() || working()) return;675 setError(null);676 setWorking(true);677 setAnimating("chaos");678679 const canvas = canvasRef;680 if (canvas) startChaos(canvas);681682 try {683 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));684 const payload = await parsePayload(bytes, backupPass());685686 if (!props.hasVault) {687 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });688 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");689 }690691 const rr = await sendMessage({692 type: "RESTORE_ENTRIES",693 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },694 });695 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");696697 if (payload.license?.key) {698 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});699 }700701 setAnimating("snap");702 stopAnim();703 if (canvas) {704 snapToGrid(canvas, () => {705 setAnimating("bloom");706 bloom(canvas, async () => {707 setAnimating(null);708 await props.onSuccess();709 });710 });711 } else {712 await props.onSuccess();713 }714 } catch (err) {715 stopAnim();716 setAnimating(null);717 const code = err instanceof Error ? err.message : String(err);718 setError(719 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"720 ? "That passphrase didn't work."721 : code === "CREATE_VAULT_FAILED"722 ? "Couldn't create a new local vault."723 : "Could not read this backup.",724 );725 } finally {726 setWorking(false);727 }728 }729730 return (731 <div class="peach-rt-symbols">732 <Show when={animating() !== null}>733 <canvas734 ref={(el) => { canvasRef = el; }}735 class="peach-rt-anim-canvas"736 width={280}737 height={200}738 />739 </Show>740 <Show when={animating() === null}>741 <textarea742 class="peach-restore-symbols-textarea"743 placeholder="paste PeachScript here…"744 rows={3}745 value={scriptInput()}746 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}747 />748 <div class="peach-rt-divider">— or —</div>749 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">750 open .peach file751 <input752 type="file"753 accept=".peach,text/plain"754 style="display:none"755 onChange={async (e) => {756 const file = (e.currentTarget as HTMLInputElement).files?.[0];757 if (!file) return;758 try {759 setScriptInput(await file.text());760 } catch {761 setError("Couldn't read file.");762 } finally {763 (e.currentTarget as HTMLInputElement).value = "";764 }765 }}766 />767 </label>768 <input769 class="peach-restore-symbols-pass"770 type="password"771 placeholder="backup password"772 value={backupPass()}773 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}774 onKeyDown={(e) => {775 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {776 e.preventDefault();777 void handleRestore();778 }779 }}780 />781 <button782 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"783 onClick={() => void handleRestore()}784 disabled={!scriptValid() || !backupPass().trim() || working()}785 >786 restore vault →787 </button>788 <Show when={error()}>789 <p class="peach-rt-error">{error()}</p>790 </Show>791 </Show>792 </div>793 );794}795796797798function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {799 const [decoding, setDecoding] = createSignal(false);800 const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);801 const [entryLine, setEntryLine] = createSignal<string | null>(null);802 const [backupPass, setBackupPass] = createSignal("");803 const [restoring, setRestoring] = createSignal(false);804 const [error, setError] = createSignal<string | null>(null);805806 const isAndroid = createMemo(() =>807 Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"808 );809810 async function processFile(file: File) {811 setDecoding(true);812 setDecoded(null);813 setEntryLine(null);814 setError(null);815 try {816 const reader = new FileReader();817 const base64 = await new Promise<string>((res, rej) => {818 reader.onload = () => res(reader.result as string);819 reader.onerror = () => rej(new Error("read error"));820 reader.readAsDataURL(file);821 });822 const result = await handleMayanDecode(base64);823 if (!result.ok) { setError(result.reason); return; }824 setDecoded(result.payload);825 } catch {826 setError("Could not read this Mayan code. Try a clearer image.");827 } finally {828 setDecoding(false);829 }830 }831832 async function handleCameraScan() {833 setError(null);834 try {835 const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");836 const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });837 if (!image.base64String) return;838 setDecoding(true);839 const result = await handleMayanDecode(image.base64String);840 setDecoding(false);841 if (!result.ok) { setError(result.reason); return; }842 setDecoded(result.payload);843 } catch (err) {844 setDecoding(false);845 const msg = err instanceof Error ? err.message : "";846 if (msg && !/cancel/i.test(msg)) setError(msg);847 }848 }849850 async function handleRestore() {851 const bytes = decoded();852 if (!bytes || !backupPass().trim() || restoring()) return;853 setRestoring(true);854 setError(null);855 try {856 const payload = await parsePayload(bytes, backupPass());857 const count = payload.entries.length;858 const date = new Date(859 Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))860 );861 const monthYear = date.getFullYear() > 2000862 ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })863 : null;864 setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);865866 if (!props.hasVault) {867 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });868 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");869 }870 const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });871 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");872 if (payload.license?.key) {873 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});874 }875 await props.onSuccess();876 } catch (err) {877 const code = err instanceof Error ? err.message : String(err);878 setError(879 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"880 ? "Wrong backup password."881 : code === "CREATE_VAULT_FAILED"882 ? "Couldn't create a new local vault."883 : "Restore failed. The backup may be corrupted.",884 );885 } finally {886 setRestoring(false);887 }888 }889890 return (891 <div class="peach-rt-mayan">892 <Show when={decoded() === null}>893 <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>894 <span class="peach-rt-upload-label">895 {decoding() ? "decoding…" : "tap to upload image"}896 </span>897 <input898 type="file"899 accept="image/*"900 style="display:none"901 onChange={(e) => {902 const file = (e.currentTarget as HTMLInputElement).files?.[0];903 if (file) void processFile(file);904 (e.currentTarget as HTMLInputElement).value = "";905 }}906 disabled={decoding()}907 />908 </label>909 <Show when={isAndroid()}>910 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>911 scan with camera912 </button>913 </Show>914 <Show when={error()}>915 <p class="peach-rt-error">{error()}</p>916 </Show>917 </Show>918 <Show when={decoded() !== null}>919 <Show when={entryLine()}>920 <p class="peach-rt-entry-line">{entryLine()}</p>921 </Show>922 <input923 class="peach-restore-symbols-pass"924 type="password"925 placeholder="backup password"926 ref={(el) => queueMicrotask(() => el.focus())}927 value={backupPass()}928 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}929 onKeyDown={(e) => {930 if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }931 }}932 />933 <button934 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"935 onClick={() => void handleRestore()}936 disabled={!backupPass().trim() || restoring()}937 >938 {restoring() ? "restoring…" : "restore vault →"}939 </button>940 <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>941 ← try another image942 </button>943 <Show when={error()}>944 <p class="peach-rt-error">{error()}</p>945 </Show>946 </Show>947 </div>948 );949}950951952953function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {954 const [importing, setImporting] = createSignal(false);955 const [error, setError] = createSignal<string | null>(null);956957 async function handleFile(e: Event) {958 const input = e.currentTarget as HTMLInputElement;959 const file = input.files?.[0];960 input.value = "";961 if (!file) return;962 setImporting(true);963 setError(null);964 try {965 const text = await file.text();966 const parsed = await parseImportSource(text);967 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");968969 if (props.hasVault === false) {970 971 972 973 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });974 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");975 }976977 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });978 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");979 if (parsed.licenseKey) {980 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});981 }982 await props.onSuccess();983 } catch (err) {984 const code = err instanceof Error ? err.message : String(err);985 setError(986 code === "NO_ENTRIES" ? "No entries found in this file." :987 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :988 "Import failed. Check the file format and try again."989 );990 } finally {991 setImporting(false);992 }993 }994995 return (996 <div class="peach-rt-csv">997 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>998 {importing() ? "importing…" : "open CSV file"}999 <input1000 type="file"1001 accept=".csv,.json,.peach,application/x-peach"1002 style="display:none"1003 onChange={handleFile}1004 disabled={importing()}1005 />1006 </label>1007 <p class="peach-rt-csv-sources">1008 Imports from Bitwarden, 1Password, Proton Pass,<br />1009 Chrome, Safari, and Edge.1010 </p>1011 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1012 <Show when={error()}>1013 <p class="peach-rt-error">{error()}</p>1014 </Show>1015 </div>1016 );1017}1018101910201021type PhoneState = "waiting" | "connected" | "trust" | "error";10221023function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1024 const [state, setState] = createSignal<PhoneState>("waiting");1025 const [code, setCode] = createSignal<string | null>(null);1026 const [qrUrl, setQrUrl] = createSignal<string | null>(null);1027 const [expired, setExpired] = createSignal(false);1028 const [deviceName, setDeviceName] = createSignal<string | null>(null);1029 const [accepting, setAccepting] = createSignal(false);1030 const [serverError, setServerError] = createSignal<string | null>(null);1031 let expireTimer: ReturnType<typeof setTimeout> | null = null;10321033 function clearExpire() {1034 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1035 }10361037 async function startPairing() {1038 setExpired(false);1039 setCode(null);1040 setQrUrl(null);1041 setServerError(null);1042 clearExpire();1043 const res = await sendMessage({ type: "START_PAIRING_SERVER" });1044 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1045 if (!r.ok || !r.data) {1046 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1047 return;1048 }1049 const token = r.data.token;1050 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1051 setCode(formatted);1052 const qrData = `peach://handoff?token=${token}`;1053 try {1054 const QRCode = (await import("qrcode")).default;1055 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1056 setQrUrl(url);1057 } catch {1058 1059 }1060 const remaining = Math.max(0, r.data.expiresAt - Date.now());1061 expireTimer = setTimeout(() => {1062 setCode(null);1063 setQrUrl(null);1064 setExpired(true);1065 }, remaining);1066 }10671068 function handleRuntimeMsg(msg: unknown) {1069 const m = msg as { type?: string; payload?: { deviceName?: string } };1070 if (m?.type === "PAIRING_COMPLETED") {1071 clearExpire();1072 setDeviceName(m.payload?.deviceName ?? "your phone");1073 setState("trust");1074 }1075 }10761077 onMount(() => {1078 if (!props.isPro) return;1079 void startPairing();1080 1081 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1082 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1083 }1084 });10851086 onCleanup(() => {1087 clearExpire();1088 1089 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1090 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1091 }1092 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1093 });10941095 async function handleAccept(trust: boolean) {1096 setAccepting(true);1097 if (trust) {1098 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1099 }1100 await props.onSuccess();1101 }11021103 if (!props.isPro) {1104 return (1105 <div class="peach-rt-phone">1106 <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1107 <button1108 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1109 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1110 >1111 Get Peach Pro1112 </button>1113 <button1114 class="peach-btn peach-btn--ghost peach-btn--sm"1115 style="margin-top:6px"1116 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1117 >1118 I already have a license1119 </button>1120 </div>1121 );1122 }11231124 return (1125 <div class="peach-rt-phone">1126 <Show when={state() === "waiting"}>1127 <Show when={!expired()}>1128 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1129 <p class="peach-rt-phone-code">{code()}</p>1130 <Show when={qrUrl() !== null}>1131 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1132 </Show>1133 <p class="peach-rt-phone-hint">1134 Open Peach on your phone and tap<br />1135 Settings → Send to desktop.<br />1136 Same WiFi required.1137 </p>1138 </Show>1139 <Show when={serverError()}>1140 <p class="peach-rt-error">{serverError()}</p>1141 </Show>1142 </Show>1143 <Show when={expired()}>1144 <p class="peach-rt-phone-hint">Code expired.</p>1145 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1146 Generate new code1147 </button>1148 </Show>1149 </Show>11501151 <Show when={state() === "connected"}>1152 <p class="peach-rt-phone-hint">1153 Connected to {deviceName() ?? "your phone"}.<br />1154 Receiving vault…1155 </p>1156 </Show>11571158 <Show when={state() === "trust"}>1159 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1160 Vault received from {deviceName() ?? "your phone"}.<br />1161 Trust this network for sync?1162 </p>1163 <button1164 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1165 onClick={() => void handleAccept(true)}1166 disabled={accepting()}1167 >1168 Yes, trust this network1169 </button>1170 <button1171 class="peach-btn peach-btn--ghost peach-btn--sm"1172 style="margin-top:6px"1173 onClick={() => void handleAccept(false)}1174 disabled={accepting()}1175 >1176 Not now1177 </button>1178 </Show>11791180 <Show when={state() === "error"}>1181 <p class="peach-rt-phone-hint">1182 Could not connect. Check that both devices<br />1183 are on the same WiFi network.1184 </p>1185 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1186 Try again1187 </button>1188 </Show>1189 </div>1190 );1191}1192119311941195async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1196 const unlocked = await biometricUnlock();1197 if (unlocked) {1198 await props.onUnlock();1199 }1200}12011202function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1203 const [bioAvailable, setBioAvailable] = createSignal(false);1204 onMount(async () => {1205 setBioAvailable(await biometricAvailable());1206 });1207 return (1208 <Show when={bioAvailable()}>1209 <button1210 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1211 onClick={() => void handleBiometricUnlock(props)}1212 disabled={!props.ready || props.loading()}1213 type="button"1214 >1215 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1216 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1217 <path d="M5 8v1a3 3 0 0 0 6 0V8" />1218 <line x1="8" y1="12" x2="8" y2="15" />1219 <line x1="5" y1="15" x2="11" y2="15" />1220 </svg>1221 use biometrics1222 </button>1223 </Show>1224 );1225}1226122712281229function getDisplayError(error: string | undefined, fallback: string): string {1230 if (!error) return fallback;1231 if (1232 error === "SERVICE_WORKER_UNAVAILABLE" ||1233 error.includes("Receiving end does not exist") ||1234 error.includes("message port closed")1235 ) {1236 return "background service unavailable";1237 }1238 return error;1239}