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";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 onUnlock: () => void | Promise<void>;38 394041 onBackToOnboarding?: () => void;42}4344type RestoreSurface = "restore" | null;4546export function LockView(props: LockProps) {47 const [password, setPassword] = createSignal("");48 const [confirmPassword, setConfirmPassword] = createSignal("");49 const [error, setError] = createSignal("");50 const [loading, setLoading] = createSignal(false);51 52 53 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(54 new URLSearchParams(location.search).has("detached") ? "restore" : null55 );56 const [showPassword, setShowPassword] = createSignal(false);5758 const MIN_LENGTH = 12;59 const MIN_ZXCVBN_SCORE = 2;6061 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6263 const canCreate = createMemo(64 () =>65 password().length >= MIN_LENGTH &&66 confirmPassword().length >= MIN_LENGTH &&67 password() === confirmPassword() &&68 strengthScore() !== null &&69 strengthScore()! >= MIN_ZXCVBN_SCORE70 );7172 const passwordStrength = createMemo(() => {73 const score = strengthScore();74 if (score === null) return null;75 if (score <= 1) return "weak";76 if (score === 2) return "fair";77 if (score === 3) return "good";78 return "strong";79 });8081 const createBlockerMessage = createMemo(() => {82 if (!password()) return null;83 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;84 if (!confirmPassword()) return "confirm your password";85 if (password() !== confirmPassword()) return "passwords don't match";86 if (strengthScore() === null) return "checking strength…";87 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";88 return null;89 });9091 92 createMemo(() => {93 const value = password();94 if (!value) { setStrengthScore(null); return; }95 getZxcvbn().then((zxcvbn) => {96 if (password() === value) setStrengthScore(zxcvbn(value).score);97 }).catch(() => {98 99 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);100 });101 });102103 async function handleUnlock() {104 if (!password().trim()) return;105 setLoading(true);106 setError("");107 const response = await sendMessage({108 type: "UNLOCK",109 payload: { password: password() },110 });111 const result = response as { ok: boolean; error?: string };112 if (result.ok) {113 await props.onUnlock();114 setLoading(false);115 return;116 }117 setError(getDisplayError(result.error, "incorrect password"));118 setPassword("");119 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;120 passwordField?.classList.add("shake");121 setTimeout(() => passwordField?.classList.remove("shake"), 300);122 setLoading(false);123 }124125 async function handleCreate() {126 if (!password().trim()) return;127 if (password().length < MIN_LENGTH) {128 setError(`password must be at least ${MIN_LENGTH} characters`);129 return;130 }131 if (password() !== confirmPassword()) {132 setError("passwords do not match");133 return;134 }135 const score = strengthScore();136 if (score === null || score < MIN_ZXCVBN_SCORE) {137 setError("password is too weak — try a longer or more varied passphrase");138 return;139 }140 setLoading(true);141 setError("");142 const response = await sendMessage({143 type: "CREATE_VAULT",144 payload: { password: password() },145 });146 const result = response as { ok: boolean; error?: string };147 if (result.ok) {148 await props.onUnlock();149 setLoading(false);150 return;151 }152 setError(getDisplayError(result.error, "failed to create vault"));153 setLoading(false);154 }155156 async function openRestore(surface: Exclude<RestoreSurface, null>) {157 if (!props.ready || loading()) return;158 setPassword("");159 setConfirmPassword("");160 setError("");161162 163 164 165 166 const isDetached = new URLSearchParams(location.search).has("detached");167 if (!isDetached) {168 try {169 const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";170 await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });171 window.close();172 return;173 } catch {174 175 }176 }177178 setRestoreSurface(surface);179 }180181 182 return (183 <Switch>184 {}185 <Match when={restoreSurface() === "restore"}>186 <RestoreTabView187 hasVault={props.hasVault}188 isPro={props.isPro ?? false}189 onBack={() => setRestoreSurface(null)}190 onUnlock={async () => {191 setRestoreSurface(null);192 await props.onUnlock();193 }}194 />195 </Match>196197 {}198 <Match when={props.hasVault}>199 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">200 <header class="peach-screen-header lock-screen-header">201 <div class="peach-wordmark-stack">202 <PeachWordmark class="peach-wordmark-wrap" />203 <Show when={props.isPro}>204 <span class="peach-pro-mini-badge">pro</span>205 </Show>206 </div>207 </header>208209 <div class="peach-lock-hero peach-lock-hero--unlock">210 <div class="peach-lock-hero-art" aria-hidden="true">211 <PeachMark class="peach-lock-hero-mark" />212 </div>213 <span class="peach-lock-eyebrow">vault locked</span>214 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">215 unlock216 <br />217 your vault.218 </h1>219 </div>220221 <LockRuneCanvas />222223 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">224 <label class="peach-label" for="unlock-password">225 master password226 </label>227 <input228 id="unlock-password"229 class="peach-underline-input peach-unlock-input lock-password-input"230 type="password"231 placeholder="••••••••••••"232 autocomplete="current-password"233 value={password()}234 disabled={!props.ready}235 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}236 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}237 onKeyDown={(event) => {238 if (event.key !== "Enter" || !props.ready) return;239 void handleUnlock();240 }}241 />242243 <Show when={error()}>244 <p class="peach-hint peach-hint-error">{error()}</p>245 </Show>246247 <button248 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"249 onClick={() => void handleUnlock()}250 disabled={!props.ready || loading()}251 >252 {loading() ? "unlocking..." : "unlock"}253 </button>254255 <Show when={Capacitor.isNativePlatform()}>256 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />257 </Show>258 </section>259 </section>260 </Match>261262 {}263 <Match when={true}>264 <section265 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"266 data-motion-phase="enter"267 >268 <header class="peach-screen-header peach-newvault-header">269 <Show when={props.onBackToOnboarding}>270 <button271 class="peach-newvault-back"272 type="button"273 onClick={() => props.onBackToOnboarding?.()}274 aria-label="Back to onboarding"275 >276 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">277 <path d="M7 2L3 5.5L7 9" />278 </svg>279 back280 </button>281 </Show>282 <div class="peach-wordmark-stack">283 <PeachWordmark class="peach-wordmark-wrap" />284 <Show when={props.isPro}>285 <span class="peach-pro-mini-badge">pro</span>286 </Show>287 </div>288 </header>289290 <div class="peach-newvault-mark" aria-hidden="true">291 <PeachMark class="peach-newvault-mark-art" />292 </div>293294 <div class="peach-newvault-card">295 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>296 <hr class="peach-newvault-divider" />297298 <div class="peach-newvault-field">299 <div class="peach-newvault-label-row">300 <label class="peach-label" for="master-password">301 master password302 </label>303 <button304 class="peach-lock-reveal-btn"305 type="button"306 onClick={() => setShowPassword((value) => !value)}307 aria-label={showPassword() ? "Hide password" : "Show password"}308 >309 <Show310 when={showPassword()}311 fallback={312 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">313 <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" />314 <circle cx="7" cy="5" r="1.75" />315 </svg>316 }317 >318 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">319 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />320 <path d="M3.5 5.5L2.5 7.5" />321 <path d="M7 6.5V8.5" />322 <path d="M10.5 5.5L11.5 7.5" />323 </svg>324 </Show>325 {showPassword() ? "hide" : "show"}326 </button>327 </div>328 <input329 id="master-password"330 class="peach-underline-input peach-newvault-input lock-password-input"331 type={showPassword() ? "text" : "password"}332 placeholder="••••••••••••"333 autocomplete="new-password"334 value={password()}335 disabled={!props.ready}336 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}337 onKeyDown={(event) => {338 if (event.key !== "Enter" || !props.ready) return;339 void handleCreate();340 }}341 />342 <Show343 when={password().length > 0 && passwordStrength() !== null}344 fallback={345 <p class="peach-newvault-hint">at least 12 characters</p>346 }347 >348 <div class="peach-password-strength">349 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>350 <div class="peach-strength-fill"></div>351 </div>352 <span class="peach-strength-label">{passwordStrength()}</span>353 </div>354 </Show>355 </div>356357 <div class="peach-newvault-field">358 <label class="peach-label" for="confirm-password">359 confirm password360 </label>361 <input362 id="confirm-password"363 class="peach-underline-input peach-newvault-input"364 type={showPassword() ? "text" : "password"}365 placeholder="••••••••••••"366 autocomplete="new-password"367 value={confirmPassword()}368 disabled={!props.ready}369 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}370 onKeyDown={(event) => {371 if (event.key === "Enter" && props.ready) void handleCreate();372 }}373 />374 </div>375376 <Show when={error()}>377 <p class="peach-hint peach-hint-error">{error()}</p>378 </Show>379380 <button381 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"382 onClick={() => void handleCreate()}383 disabled={!props.ready || loading() || !canCreate()}384 aria-live="polite"385 >386 <Show387 when={!canCreate() && createBlockerMessage()}388 fallback={loading() ? "creating..." : "create vault"}389 >390 {createBlockerMessage()}391 </Show>392 </button>393394 <p class="peach-newvault-warning">395 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>396 <span>If you forget it, your vault cannot be opened. There is no reset.</span>397 </p>398 </div>399400 <div class="peach-newvault-footer">401 <p class="peach-hint">Already have a backup?</p>402 <button403 class="peach-lock-restore-link"404 onClick={() => void openRestore("restore")}405 disabled={!props.ready}406 >407 › restore from backup408 </button>409 </div>410 </section>411 </Match>412 </Switch>413 );414}415416417418type RestoreTab = "symbols" | "mayan" | "csv" | "phone";419420function tabLabel(tab: RestoreTab): string {421 return (422 { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]423 );424}425426function RestoreTabView(props: {427 hasVault: boolean;428 isPro: boolean;429 onBack: () => void;430 onUnlock: () => void | Promise<void>;431}) {432 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");433 const [showIntro, setShowIntro] = createSignal(false);434 const [pendingUnlock, setPendingUnlock] = createSignal(false);435436 const isDetachedWindow = new URLSearchParams(location.search).has("detached");437438 async function reattachPopup() {439 if (!isDetachedWindow) return;440 441 try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}442 window.close();443 }444445 async function handleRestoreSuccess() {446 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);447 if (!seen) {448 setShowIntro(true);449 } else {450 await props.onUnlock();451 await reattachPopup();452 }453 }454455 async function dismissIntro() {456 await storageSet("peach_shown_restore_intro", "true").catch(() => {});457 setShowIntro(false);458 await props.onUnlock();459 await reattachPopup();460 }461462 return (463 <section464 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"465 data-motion-phase="enter"466 >467 <RuneBackground opacity={0.05} />468469 <header class="peach-screen-header lock-screen-header">470 <PeachWordmark class="peach-wordmark-wrap" />471 <button class="peach-lock-restore-link" onClick={props.onBack}>472 ‹ back473 </button>474 </header>475476 <div class="peach-newvault-card peach-restore-tabcard">477 <div class="peach-restore-tabs" role="tablist">478 <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>479 {(tab) => (480 <button481 class="peach-restore-tab"482 classList={{ "is-active": activeTab() === tab }}483 onClick={() => setActiveTab(tab)}484 role="tab"485 aria-selected={activeTab() === tab}486 >487 {tabLabel(tab)}488 </button>489 )}490 </For>491 </div>492 <div class="peach-restore-content">493 <Show when={activeTab() === "symbols"}>494 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />495 </Show>496 <Show when={activeTab() === "mayan"}>497 <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />498 </Show>499 <Show when={activeTab() === "csv"}>500 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />501 </Show>502 <Show when={activeTab() === "phone"}>503 <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />504 </Show>505 </div>506 </div>507508 {}509 <Show when={showIntro()}>510 <div class="peach-restore-intro-overlay">511 <div class="peach-restore-intro-card">512 <p class="peach-restore-intro-headline">welcome to peach.</p>513 <div class="peach-restore-intro-body">514 <p>Your vault lives only on this device. No server has a copy.</p>515 <p>The inline bubble fills passwords automatically when you visit sites.</p>516 <p>Export your vault from Settings before switching devices.</p>517 </div>518 <button519 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"520 onClick={() => void dismissIntro()}521 disabled={pendingUnlock()}522 >523 got it524 </button>525 </div>526 </div>527 </Show>528 </section>529 );530}531532533534async function handleMayanDecode(base64: string): Promise<535 { ok: true; payload: Uint8Array } | { ok: false; reason: string }536> {537 const { decodeMayan } = await import("../../crypto/mayan");538 const img = new Image();539 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;540 await new Promise<void>((resolve, reject) => {541 img.onload = () => resolve();542 img.onerror = () => reject(new Error("Couldn't read the image."));543 img.src = dataUrl;544 });545 const canvas = document.createElement("canvas");546 547 const MAX_DIM = 1500;548 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));549 canvas.width = Math.round(img.width * scale);550 canvas.height = Math.round(img.height * scale);551 const ctx = canvas.getContext("2d");552 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };553 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);554 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);555 const payload = decodeMayan(imageData);556 if (!payload) {557 return {558 ok: false,559 reason: "Could not read this Mayan code. Try a clearer image.",560 };561 }562 return { ok: true, payload };563}564565566567function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {568 const [scriptInput, setScriptInput] = createSignal("");569 const [backupPass, setBackupPass] = createSignal("");570 const [working, setWorking] = createSignal(false);571 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);572 const [error, setError] = createSignal<string | null>(null);573 let canvasRef: HTMLCanvasElement | undefined;574 let animFrameId = 0;575 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];576577 const scriptValid = createMemo(() => isPeachScript(scriptInput()));578579 function stopAnim() {580 cancelAnimationFrame(animFrameId);581 animFrameId = 0;582 }583584 function startChaos(canvas: HTMLCanvasElement) {585 const ctx = canvas.getContext("2d")!;586 const W = canvas.width;587 const H = canvas.height;588 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>589 String.fromCodePoint(0xe000 + i)590 );591 if (runes.length === 0) {592 runes = Array.from({ length: 60 }, () => ({593 x: Math.random() * W,594 y: Math.random() * H,595 vx: (Math.random() - 0.5) * 3,596 vy: (Math.random() - 0.5) * 3,597 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,598 size: 14 + Math.random() * 20,599 angle: Math.random() * Math.PI * 2,600 va: (Math.random() - 0.5) * 0.1,601 }));602 }603604 function draw() {605 ctx.clearRect(0, 0, W, H);606 for (const r of runes) {607 r.x = (r.x + r.vx + W) % W;608 r.y = (r.y + r.vy + H) % H;609 r.angle += r.va;610 ctx.save();611 ctx.translate(r.x, r.y);612 ctx.rotate(r.angle);613 ctx.font = `${r.size}px PeachScript, monospace`;614 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);615 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;616 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";617 ctx.shadowBlur = 8;618 ctx.fillText(r.char, 0, 0);619 ctx.restore();620 }621 animFrameId = requestAnimationFrame(draw);622 }623 draw();624 }625626 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {627 stopAnim();628 const ctx = canvas.getContext("2d")!;629 const W = canvas.width;630 const H = canvas.height;631 const cols = 8;632 const rows = 6;633 const cellW = W / cols;634 const cellH = H / rows;635 const targets = runes.slice(0, cols * rows).map((_, i) => ({636 x: (i % cols) * cellW + cellW / 2,637 y: Math.floor(i / cols) * cellH + cellH / 2,638 }));639 const start = performance.now();640 const DURATION = 400;641642 function drawSnap(now: number) {643 const t = Math.min((now - start) / DURATION, 1);644 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;645 ctx.clearRect(0, 0, W, H);646 runes.slice(0, targets.length).forEach((r, i) => {647 const tx = targets[i]!.x;648 const ty = targets[i]!.y;649 const cx = r.x + (tx - r.x) * ease;650 const cy = r.y + (ty - r.y) * ease;651 ctx.font = `16px PeachScript, monospace`;652 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;653 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";654 ctx.shadowBlur = 6;655 ctx.fillText(r.char, cx - 8, cy + 8);656 });657 if (t < 1) {658 animFrameId = requestAnimationFrame(drawSnap);659 } else {660 onDone();661 }662 }663 animFrameId = requestAnimationFrame(drawSnap);664 }665666 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {667 stopAnim();668 const ctx = canvas.getContext("2d")!;669 const W = canvas.width;670 const H = canvas.height;671 const start = performance.now();672 const DURATION = 900;673674 function drawBloom(now: number) {675 const t = Math.min((now - start) / DURATION, 1);676 ctx.clearRect(0, 0, W, H);677 678 const runeAlpha = 1 - Math.min(t / 0.4, 1);679 if (runeAlpha > 0) {680 for (const r of runes.slice(0, 48)) {681 ctx.font = `16px PeachScript, monospace`;682 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;683 ctx.fillText(r.char, r.x - 8, r.y + 8);684 }685 }686 687 const markT = Math.max(0, (t - 0.2) / 0.5);688 const markScale = Math.min(markT * 1.2, 1);689 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;690 if (markAlpha > 0) {691 ctx.save();692 ctx.globalAlpha = markAlpha;693 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;694 ctx.shadowBlur = 30;695 ctx.translate(W / 2, H / 2);696 ctx.scale(markScale, markScale);697 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);698 ctx.restore();699 }700 if (t < 1) {701 animFrameId = requestAnimationFrame(drawBloom);702 } else {703 onDone();704 }705 }706 animFrameId = requestAnimationFrame(drawBloom);707 }708709 onCleanup(() => stopAnim());710711 async function handleRestore() {712 if (!scriptValid() || !backupPass().trim() || working()) return;713 setError(null);714 setWorking(true);715 setAnimating("chaos");716717 const canvas = canvasRef;718 if (canvas) startChaos(canvas);719720 try {721 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));722 const payload = await parsePayload(bytes, backupPass());723724 if (!props.hasVault) {725 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });726 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");727 }728729 const rr = await sendMessage({730 type: "RESTORE_ENTRIES",731 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },732 });733 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");734735 if (payload.license?.key) {736 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});737 }738739 setAnimating("snap");740 stopAnim();741 if (canvas) {742 snapToGrid(canvas, () => {743 setAnimating("bloom");744 bloom(canvas, async () => {745 setAnimating(null);746 await props.onSuccess();747 });748 });749 } else {750 await props.onSuccess();751 }752 } catch (err) {753 stopAnim();754 setAnimating(null);755 const code = err instanceof Error ? err.message : String(err);756 setError(757 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"758 ? "That passphrase didn't work."759 : code === "CREATE_VAULT_FAILED"760 ? "Couldn't create a new local vault."761 : "Could not read this backup.",762 );763 } finally {764 setWorking(false);765 }766 }767768 return (769 <div class="peach-rt-symbols">770 <Show when={animating() !== null}>771 <canvas772 ref={(el) => { canvasRef = el; }}773 class="peach-rt-anim-canvas"774 width={280}775 height={200}776 />777 </Show>778 <Show when={animating() === null}>779 <textarea780 class="peach-restore-symbols-textarea"781 placeholder="paste PeachScript here…"782 rows={3}783 value={scriptInput()}784 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}785 />786 <div class="peach-rt-divider">— or —</div>787 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">788 open .peach file789 <input790 type="file"791 accept=".peach,text/plain"792 style="display:none"793 onChange={async (e) => {794 const file = (e.currentTarget as HTMLInputElement).files?.[0];795 if (!file) return;796 try {797 setScriptInput(await file.text());798 } catch {799 setError("Couldn't read file.");800 } finally {801 (e.currentTarget as HTMLInputElement).value = "";802 }803 }}804 />805 </label>806 <input807 class="peach-restore-symbols-pass"808 type="password"809 placeholder="backup password"810 value={backupPass()}811 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}812 onKeyDown={(e) => {813 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {814 e.preventDefault();815 void handleRestore();816 }817 }}818 />819 <button820 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"821 onClick={() => void handleRestore()}822 disabled={!scriptValid() || !backupPass().trim() || working()}823 >824 restore vault →825 </button>826 <Show when={error()}>827 <p class="peach-rt-error">{error()}</p>828 </Show>829 </Show>830 </div>831 );832}833834835836function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {837 const [decoding, setDecoding] = createSignal(false);838 const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);839 const [entryLine, setEntryLine] = createSignal<string | null>(null);840 const [backupPass, setBackupPass] = createSignal("");841 const [restoring, setRestoring] = createSignal(false);842 const [error, setError] = createSignal<string | null>(null);843844 const isAndroid = createMemo(() =>845 Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android"846 );847848 async function processFile(file: File) {849 setDecoding(true);850 setDecoded(null);851 setEntryLine(null);852 setError(null);853 try {854 const { decodeMayan } = await import("../../crypto/mayan");855 const bitmap = await createImageBitmap(file);856 const MAX_DIM = 1500;857 const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));858 const canvas = document.createElement("canvas");859 canvas.width = Math.round(bitmap.width * scale);860 canvas.height = Math.round(bitmap.height * scale);861 const ctx = canvas.getContext("2d")!;862 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);863 bitmap.close();864 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);865 const payload = decodeMayan(imageData);866 if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }867 setDecoded(payload);868 } catch {869 setError("Could not read this Mayan code. Try a clearer image.");870 } finally {871 setDecoding(false);872 }873 }874875 async function handleCameraScan() {876 setError(null);877 try {878 const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");879 const image = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Base64, source: CameraSource.Camera, allowEditing: false });880 if (!image.base64String) return;881 setDecoding(true);882 const result = await handleMayanDecode(image.base64String);883 setDecoding(false);884 if (!result.ok) { setError(result.reason); return; }885 setDecoded(result.payload);886 } catch (err) {887 setDecoding(false);888 const msg = err instanceof Error ? err.message : "";889 if (msg && !/cancel/i.test(msg)) setError(msg);890 }891 }892893 async function handleRestore() {894 const bytes = decoded();895 if (!bytes || !backupPass().trim() || restoring()) return;896 setRestoring(true);897 setError(null);898 try {899 const payload = await parsePayload(bytes, backupPass());900 const count = payload.entries.length;901 const date = new Date(902 Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))903 );904 const monthYear = date.getFullYear() > 2000905 ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })906 : null;907 setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);908909 if (!props.hasVault) {910 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });911 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");912 }913 const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });914 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");915 if (payload.license?.key) {916 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});917 }918 await props.onSuccess();919 } catch (err) {920 const code = err instanceof Error ? err.message : String(err);921 setError(922 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"923 ? "Wrong backup password."924 : code === "CREATE_VAULT_FAILED"925 ? "Couldn't create a new local vault."926 : "Restore failed. The backup may be corrupted.",927 );928 } finally {929 setRestoring(false);930 }931 }932933 return (934 <div class="peach-rt-mayan">935 <Show when={decoded() === null}>936 <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>937 <span class="peach-rt-upload-label">938 {decoding() ? "decoding…" : "tap to upload image"}939 </span>940 <input941 type="file"942 accept="image/*"943 style="display:none"944 onChange={(e) => {945 const file = (e.currentTarget as HTMLInputElement).files?.[0];946 if (file) void processFile(file);947 (e.currentTarget as HTMLInputElement).value = "";948 }}949 disabled={decoding()}950 />951 </label>952 <Show when={isAndroid()}>953 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void handleCameraScan()} disabled={decoding()}>954 scan with camera955 </button>956 </Show>957 <Show when={error()}>958 <p class="peach-rt-error">{error()}</p>959 </Show>960 </Show>961 <Show when={decoded() !== null}>962 <Show when={entryLine()}>963 <p class="peach-rt-entry-line">{entryLine()}</p>964 </Show>965 <input966 class="peach-restore-symbols-pass"967 type="password"968 placeholder="backup password"969 ref={(el) => queueMicrotask(() => el.focus())}970 value={backupPass()}971 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}972 onKeyDown={(e) => {973 if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }974 }}975 />976 <button977 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"978 onClick={() => void handleRestore()}979 disabled={!backupPass().trim() || restoring()}980 >981 {restoring() ? "restoring…" : "restore vault →"}982 </button>983 <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>984 ← try another image985 </button>986 <Show when={error()}>987 <p class="peach-rt-error">{error()}</p>988 </Show>989 </Show>990 </div>991 );992}993994995996function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {997 const [importing, setImporting] = createSignal(false);998 const [error, setError] = createSignal<string | null>(null);9991000 async function handleFile(e: Event) {1001 const input = e.currentTarget as HTMLInputElement;1002 const file = input.files?.[0];1003 input.value = "";1004 if (!file) return;1005 setImporting(true);1006 setError(null);1007 try {1008 const text = await file.text();1009 const parsed = await parseImportSource(text);1010 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10111012 if (props.hasVault === false) {1013 1014 1015 1016 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1017 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1018 }10191020 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1021 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1022 if (parsed.licenseKey) {1023 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1024 }1025 await props.onSuccess();1026 } catch (err) {1027 const code = err instanceof Error ? err.message : String(err);1028 setError(1029 code === "NO_ENTRIES" ? "No entries found in this file." :1030 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1031 "Import failed. Check the file format and try again."1032 );1033 } finally {1034 setImporting(false);1035 }1036 }10371038 return (1039 <div class="peach-rt-csv">1040 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1041 {importing() ? "importing…" : "open CSV file"}1042 <input1043 type="file"1044 accept=".csv,.json,.peach,application/x-peach"1045 style="display:none"1046 onChange={handleFile}1047 disabled={importing()}1048 />1049 </label>1050 <p class="peach-rt-csv-sources">1051 Imports from Bitwarden, 1Password, Proton Pass,<br />1052 Chrome, Safari, and Edge.1053 </p>1054 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1055 <Show when={error()}>1056 <p class="peach-rt-error">{error()}</p>1057 </Show>1058 </div>1059 );1060}1061106210631064type PhoneState = "waiting" | "connected" | "trust" | "error";10651066function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1067 const [state, setState] = createSignal<PhoneState>("waiting");1068 const [code, setCode] = createSignal<string | null>(null);1069 const [qrUrl, setQrUrl] = createSignal<string | null>(null);1070 const [expired, setExpired] = createSignal(false);1071 const [deviceName, setDeviceName] = createSignal<string | null>(null);1072 const [accepting, setAccepting] = createSignal(false);1073 const [serverError, setServerError] = createSignal<string | null>(null);1074 let expireTimer: ReturnType<typeof setTimeout> | null = null;10751076 function clearExpire() {1077 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1078 }10791080 async function startPairing() {1081 setExpired(false);1082 setCode(null);1083 setQrUrl(null);1084 setServerError(null);1085 clearExpire();1086 const res = await sendMessage({ type: "START_PAIRING_SERVER" });1087 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1088 if (!r.ok || !r.data) {1089 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1090 return;1091 }1092 const token = r.data.token;1093 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1094 setCode(formatted);1095 const qrData = `peach://handoff?token=${token}`;1096 try {1097 const QRCode = (await import("qrcode")).default;1098 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1099 setQrUrl(url);1100 } catch {1101 1102 }1103 const remaining = Math.max(0, r.data.expiresAt - Date.now());1104 expireTimer = setTimeout(() => {1105 setCode(null);1106 setQrUrl(null);1107 setExpired(true);1108 }, remaining);1109 }11101111 function handleRuntimeMsg(msg: unknown) {1112 const m = msg as { type?: string; payload?: { deviceName?: string } };1113 if (m?.type === "PAIRING_COMPLETED") {1114 clearExpire();1115 setDeviceName(m.payload?.deviceName ?? "your phone");1116 setState("trust");1117 }1118 }11191120 onMount(() => {1121 if (!props.isPro) return;1122 void startPairing();1123 1124 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1125 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1126 }1127 });11281129 onCleanup(() => {1130 clearExpire();1131 1132 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1133 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1134 }1135 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1136 });11371138 async function handleAccept(trust: boolean) {1139 setAccepting(true);1140 if (trust) {1141 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1142 }1143 await props.onSuccess();1144 }11451146 if (!props.isPro) {1147 return (1148 <div class="peach-rt-phone">1149 <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1150 <button1151 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1152 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1153 >1154 Get Peach Pro1155 </button>1156 <button1157 class="peach-btn peach-btn--ghost peach-btn--sm"1158 style="margin-top:6px"1159 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1160 >1161 I already have a license1162 </button>1163 </div>1164 );1165 }11661167 return (1168 <div class="peach-rt-phone">1169 <Show when={state() === "waiting"}>1170 <Show when={!expired()}>1171 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1172 <p class="peach-rt-phone-code">{code()}</p>1173 <Show when={qrUrl() !== null}>1174 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1175 </Show>1176 <p class="peach-rt-phone-hint">1177 Open Peach on your phone and tap<br />1178 Settings → Send to desktop.<br />1179 Same WiFi required.1180 </p>1181 </Show>1182 <Show when={serverError()}>1183 <p class="peach-rt-error">{serverError()}</p>1184 </Show>1185 </Show>1186 <Show when={expired()}>1187 <p class="peach-rt-phone-hint">Code expired.</p>1188 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1189 Generate new code1190 </button>1191 </Show>1192 </Show>11931194 <Show when={state() === "connected"}>1195 <p class="peach-rt-phone-hint">1196 Connected to {deviceName() ?? "your phone"}.<br />1197 Receiving vault…1198 </p>1199 </Show>12001201 <Show when={state() === "trust"}>1202 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1203 Vault received from {deviceName() ?? "your phone"}.<br />1204 Trust this network for sync?1205 </p>1206 <button1207 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1208 onClick={() => void handleAccept(true)}1209 disabled={accepting()}1210 >1211 Yes, trust this network1212 </button>1213 <button1214 class="peach-btn peach-btn--ghost peach-btn--sm"1215 style="margin-top:6px"1216 onClick={() => void handleAccept(false)}1217 disabled={accepting()}1218 >1219 Not now1220 </button>1221 </Show>12221223 <Show when={state() === "error"}>1224 <p class="peach-rt-phone-hint">1225 Could not connect. Check that both devices<br />1226 are on the same WiFi network.1227 </p>1228 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1229 Try again1230 </button>1231 </Show>1232 </div>1233 );1234}1235123612371238async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {1239 const unlocked = await biometricUnlock();1240 if (unlocked) {1241 await props.onUnlock();1242 }1243}12441245function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {1246 const [bioAvailable, setBioAvailable] = createSignal(false);1247 onMount(async () => {1248 setBioAvailable(await biometricAvailable());1249 });1250 return (1251 <Show when={bioAvailable()}>1252 <button1253 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"1254 onClick={() => void handleBiometricUnlock(props)}1255 disabled={!props.ready || props.loading()}1256 type="button"1257 >1258 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1259 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />1260 <path d="M5 8v1a3 3 0 0 0 6 0V8" />1261 <line x1="8" y1="12" x2="8" y2="15" />1262 <line x1="5" y1="15" x2="11" y2="15" />1263 </svg>1264 use biometrics1265 </button>1266 </Show>1267 );1268}1269127012711272function getDisplayError(error: string | undefined, fallback: string): string {1273 if (!error) return fallback;1274 if (1275 error === "SERVICE_WORKER_UNAVAILABLE" ||1276 error.includes("Receiving end does not exist") ||1277 error.includes("message port closed")1278 ) {1279 return "background service unavailable";1280 }1281 return error;1282}