1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RuneBackground } from "../components/RuneBackground";6import { ImportView } from "./Import";7import { storageGet, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";1112const peachMarkImg = new Image();13peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(14 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +15 '<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"/>' +16 '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +17 '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +18 '</svg>'19)}`;202122let _zxcvbn: ((password: string) => { score: number }) | null = null;23async function getZxcvbn() {24 if (_zxcvbn) return _zxcvbn;25 const mod = await import("zxcvbn");26 _zxcvbn = mod.default;27 return _zxcvbn;28}2930interface LockProps {31 hasVault: boolean;32 ready: boolean;33 isPro?: boolean;34 onUnlock: () => void | Promise<void>;35 363738 onBackToOnboarding?: () => void;39}4041type RestoreSurface = "restore" | null;4243export function LockView(props: LockProps) {44 const [password, setPassword] = createSignal("");45 const [confirmPassword, setConfirmPassword] = createSignal("");46 const [error, setError] = createSignal("");47 const [loading, setLoading] = createSignal(false);48 49 50 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(51 new URLSearchParams(location.search).has("detached") ? "restore" : null52 );53 const [showPassword, setShowPassword] = createSignal(false);5455 const MIN_LENGTH = 12;56 const MIN_ZXCVBN_SCORE = 2;5758 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);5960 const canCreate = createMemo(61 () =>62 password().length >= MIN_LENGTH &&63 confirmPassword().length >= MIN_LENGTH &&64 password() === confirmPassword() &&65 strengthScore() !== null &&66 strengthScore()! >= MIN_ZXCVBN_SCORE67 );6869 const passwordStrength = createMemo(() => {70 const score = strengthScore();71 if (score === null) return null;72 if (score <= 1) return "weak";73 if (score === 2) return "fair";74 if (score === 3) return "good";75 return "strong";76 });7778 const createBlockerMessage = createMemo(() => {79 if (!password()) return null;80 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;81 if (!confirmPassword()) return "confirm your password";82 if (password() !== confirmPassword()) return "passwords don't match";83 if (strengthScore() === null) return "checking strength…";84 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";85 return null;86 });8788 89 createMemo(() => {90 const value = password();91 if (!value) { setStrengthScore(null); return; }92 getZxcvbn().then((zxcvbn) => {93 if (password() === value) setStrengthScore(zxcvbn(value).score);94 }).catch(() => {95 96 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);97 });98 });99100 async function handleUnlock() {101 if (!password().trim()) return;102 setLoading(true);103 setError("");104 const response = await sendMessage({105 type: "UNLOCK",106 payload: { password: password() },107 });108 const result = response as { ok: boolean; error?: string };109 if (result.ok) {110 await props.onUnlock();111 setLoading(false);112 return;113 }114 setError(getDisplayError(result.error, "incorrect password"));115 setPassword("");116 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;117 passwordField?.classList.add("shake");118 setTimeout(() => passwordField?.classList.remove("shake"), 300);119 setLoading(false);120 }121122 async function handleCreate() {123 if (!password().trim()) return;124 if (password().length < MIN_LENGTH) {125 setError(`password must be at least ${MIN_LENGTH} characters`);126 return;127 }128 if (password() !== confirmPassword()) {129 setError("passwords do not match");130 return;131 }132 const score = strengthScore();133 if (score === null || score < MIN_ZXCVBN_SCORE) {134 setError("password is too weak — try a longer or more varied passphrase");135 return;136 }137 setLoading(true);138 setError("");139 const response = await sendMessage({140 type: "CREATE_VAULT",141 payload: { password: password() },142 });143 const result = response as { ok: boolean; error?: string };144 if (result.ok) {145 await props.onUnlock();146 setLoading(false);147 return;148 }149 setError(getDisplayError(result.error, "failed to create vault"));150 setLoading(false);151 }152153 async function openRestore(surface: Exclude<RestoreSurface, null>) {154 if (!props.ready || loading()) return;155 setPassword("");156 setConfirmPassword("");157 setError("");158159 160 161 162 163 const isDetached = new URLSearchParams(location.search).has("detached");164 if (!isDetached) {165 try {166 const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";167 await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });168 window.close();169 return;170 } catch {171 172 }173 }174175 setRestoreSurface(surface);176 }177178 179 return (180 <Switch>181 {}182 <Match when={restoreSurface() === "restore"}>183 <RestoreTabView184 hasVault={props.hasVault}185 isPro={props.isPro ?? false}186 onBack={() => setRestoreSurface(null)}187 onUnlock={async () => {188 setRestoreSurface(null);189 await props.onUnlock();190 }}191 />192 </Match>193194 {}195 <Match when={props.hasVault}>196 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">197 <header class="peach-screen-header lock-screen-header">198 <div class="peach-wordmark-stack">199 <PeachWordmark class="peach-wordmark-wrap" />200 <Show when={props.isPro}>201 <span class="peach-pro-mini-badge">pro</span>202 </Show>203 </div>204 </header>205206 <div class="peach-lock-hero peach-lock-hero--unlock">207 <div class="peach-lock-hero-art" aria-hidden="true">208 <PeachMark class="peach-lock-hero-mark" />209 </div>210 <span class="peach-lock-eyebrow">vault locked</span>211 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">212 unlock213 <br />214 your vault.215 </h1>216 </div>217218 <LockRuneCanvas />219220 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">221 <label class="peach-label" for="unlock-password">222 master password223 </label>224 <input225 id="unlock-password"226 class="peach-underline-input peach-unlock-input lock-password-input"227 type="password"228 placeholder="••••••••••••"229 autocomplete="current-password"230 value={password()}231 disabled={!props.ready}232 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}233 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}234 onKeyDown={(event) => {235 if (event.key !== "Enter" || !props.ready) return;236 void handleUnlock();237 }}238 />239240 <Show when={error()}>241 <p class="peach-hint peach-hint-error">{error()}</p>242 </Show>243244 <button245 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"246 onClick={() => void handleUnlock()}247 disabled={!props.ready || loading()}248 >249 {loading() ? "unlocking..." : "unlock"}250 </button>251 </section>252 </section>253 </Match>254255 {}256 <Match when={true}>257 <section258 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"259 data-motion-phase="enter"260 >261 <header class="peach-screen-header peach-newvault-header">262 <Show when={props.onBackToOnboarding}>263 <button264 class="peach-newvault-back"265 type="button"266 onClick={() => props.onBackToOnboarding?.()}267 aria-label="Back to onboarding"268 >269 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">270 <path d="M7 2L3 5.5L7 9" />271 </svg>272 back273 </button>274 </Show>275 <div class="peach-wordmark-stack">276 <PeachWordmark class="peach-wordmark-wrap" />277 <Show when={props.isPro}>278 <span class="peach-pro-mini-badge">pro</span>279 </Show>280 </div>281 </header>282283 <div class="peach-newvault-mark" aria-hidden="true">284 <PeachMark class="peach-newvault-mark-art" />285 </div>286287 <div class="peach-newvault-card">288 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>289 <hr class="peach-newvault-divider" />290291 <div class="peach-newvault-field">292 <div class="peach-newvault-label-row">293 <label class="peach-label" for="master-password">294 master password295 </label>296 <button297 class="peach-lock-reveal-btn"298 type="button"299 onClick={() => setShowPassword((value) => !value)}300 aria-label={showPassword() ? "Hide password" : "Show password"}301 >302 <Show303 when={showPassword()}304 fallback={305 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">306 <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" />307 <circle cx="7" cy="5" r="1.75" />308 </svg>309 }310 >311 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">312 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />313 <path d="M3.5 5.5L2.5 7.5" />314 <path d="M7 6.5V8.5" />315 <path d="M10.5 5.5L11.5 7.5" />316 </svg>317 </Show>318 {showPassword() ? "hide" : "show"}319 </button>320 </div>321 <input322 id="master-password"323 class="peach-underline-input peach-newvault-input lock-password-input"324 type={showPassword() ? "text" : "password"}325 placeholder="••••••••••••"326 autocomplete="new-password"327 value={password()}328 disabled={!props.ready}329 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}330 onKeyDown={(event) => {331 if (event.key !== "Enter" || !props.ready) return;332 void handleCreate();333 }}334 />335 <Show336 when={password().length > 0 && passwordStrength() !== null}337 fallback={338 <p class="peach-newvault-hint">at least 12 characters</p>339 }340 >341 <div class="peach-password-strength">342 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>343 <div class="peach-strength-fill"></div>344 </div>345 <span class="peach-strength-label">{passwordStrength()}</span>346 </div>347 </Show>348 </div>349350 <div class="peach-newvault-field">351 <label class="peach-label" for="confirm-password">352 confirm password353 </label>354 <input355 id="confirm-password"356 class="peach-underline-input peach-newvault-input"357 type={showPassword() ? "text" : "password"}358 placeholder="••••••••••••"359 autocomplete="new-password"360 value={confirmPassword()}361 disabled={!props.ready}362 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}363 onKeyDown={(event) => {364 if (event.key === "Enter" && props.ready) void handleCreate();365 }}366 />367 </div>368369 <Show when={error()}>370 <p class="peach-hint peach-hint-error">{error()}</p>371 </Show>372373 <button374 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"375 onClick={() => void handleCreate()}376 disabled={!props.ready || loading() || !canCreate()}377 aria-live="polite"378 >379 <Show380 when={!canCreate() && createBlockerMessage()}381 fallback={loading() ? "creating..." : "create vault"}382 >383 {createBlockerMessage()}384 </Show>385 </button>386387 <p class="peach-newvault-warning">388 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>389 <span>If you forget it, your vault cannot be opened. There is no reset.</span>390 </p>391 </div>392393 <div class="peach-newvault-footer">394 <p class="peach-hint">Already have a backup?</p>395 <button396 class="peach-lock-restore-link"397 onClick={() => void openRestore("restore")}398 disabled={!props.ready}399 >400 › restore from backup401 </button>402 </div>403 </section>404 </Match>405 </Switch>406 );407}408409410411type RestoreTab = "symbols" | "mayan" | "csv" | "phone";412413function tabLabel(tab: RestoreTab): string {414 return (415 { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]416 );417}418419function RestoreTabView(props: {420 hasVault: boolean;421 isPro: boolean;422 onBack: () => void;423 onUnlock: () => void | Promise<void>;424}) {425 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");426 const [showIntro, setShowIntro] = createSignal(false);427 const [pendingUnlock, setPendingUnlock] = createSignal(false);428429 const isDetachedWindow = new URLSearchParams(location.search).has("detached");430431 async function reattachPopup() {432 if (!isDetachedWindow) return;433 434 try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}435 window.close();436 }437438 async function handleRestoreSuccess() {439 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);440 if (!seen) {441 setShowIntro(true);442 } else {443 await props.onUnlock();444 await reattachPopup();445 }446 }447448 async function dismissIntro() {449 await storageSet("peach_shown_restore_intro", "true").catch(() => {});450 setShowIntro(false);451 await props.onUnlock();452 await reattachPopup();453 }454455 return (456 <section457 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"458 data-motion-phase="enter"459 >460 <RuneBackground opacity={0.05} />461462 <header class="peach-screen-header lock-screen-header">463 <PeachWordmark class="peach-wordmark-wrap" />464 <button class="peach-lock-restore-link" onClick={props.onBack}>465 ‹ back466 </button>467 </header>468469 <div class="peach-newvault-card peach-restore-tabcard">470 <div class="peach-restore-tabs" role="tablist">471 <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>472 {(tab) => (473 <button474 class="peach-restore-tab"475 classList={{ "is-active": activeTab() === tab }}476 onClick={() => setActiveTab(tab)}477 role="tab"478 aria-selected={activeTab() === tab}479 >480 {tabLabel(tab)}481 </button>482 )}483 </For>484 </div>485 <div class="peach-restore-content">486 <Show when={activeTab() === "symbols"}>487 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />488 </Show>489 <Show when={activeTab() === "mayan"}>490 <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />491 </Show>492 <Show when={activeTab() === "csv"}>493 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />494 </Show>495 <Show when={activeTab() === "phone"}>496 <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />497 </Show>498 </div>499 </div>500501 {}502 <Show when={showIntro()}>503 <div class="peach-restore-intro-overlay">504 <div class="peach-restore-intro-card">505 <p class="peach-restore-intro-headline">welcome to peach.</p>506 <div class="peach-restore-intro-body">507 <p>Your vault lives only on this device. No server has a copy.</p>508 <p>The inline bubble fills passwords automatically when you visit sites.</p>509 <p>Export your vault from Settings before switching devices.</p>510 </div>511 <button512 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"513 onClick={() => void dismissIntro()}514 disabled={pendingUnlock()}515 >516 got it517 </button>518 </div>519 </div>520 </Show>521 </section>522 );523}524525526527async function handleMayanDecode(base64: string): Promise<528 { ok: true; payload: Uint8Array } | { ok: false; reason: string }529> {530 const { decodeMayan } = await import("../../crypto/mayan");531 const img = new Image();532 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;533 await new Promise<void>((resolve, reject) => {534 img.onload = () => resolve();535 img.onerror = () => reject(new Error("Couldn't read the image."));536 img.src = dataUrl;537 });538 const canvas = document.createElement("canvas");539 540 const MAX_DIM = 1500;541 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));542 canvas.width = Math.round(img.width * scale);543 canvas.height = Math.round(img.height * scale);544 const ctx = canvas.getContext("2d");545 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };546 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);547 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);548 const payload = decodeMayan(imageData);549 if (!payload) {550 return {551 ok: false,552 reason: "Could not read this Mayan code. Try a clearer image.",553 };554 }555 return { ok: true, payload };556}557558559560function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {561 const [scriptInput, setScriptInput] = createSignal("");562 const [backupPass, setBackupPass] = createSignal("");563 const [working, setWorking] = createSignal(false);564 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);565 const [error, setError] = createSignal<string | null>(null);566 let canvasRef: HTMLCanvasElement | undefined;567 let animFrameId = 0;568 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];569570 const scriptValid = createMemo(() => isPeachScript(scriptInput()));571572 function stopAnim() {573 cancelAnimationFrame(animFrameId);574 animFrameId = 0;575 }576577 function startChaos(canvas: HTMLCanvasElement) {578 const ctx = canvas.getContext("2d")!;579 const W = canvas.width;580 const H = canvas.height;581 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>582 String.fromCodePoint(0xe000 + i)583 );584 if (runes.length === 0) {585 runes = Array.from({ length: 60 }, () => ({586 x: Math.random() * W,587 y: Math.random() * H,588 vx: (Math.random() - 0.5) * 3,589 vy: (Math.random() - 0.5) * 3,590 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,591 size: 14 + Math.random() * 20,592 angle: Math.random() * Math.PI * 2,593 va: (Math.random() - 0.5) * 0.1,594 }));595 }596597 function draw() {598 ctx.clearRect(0, 0, W, H);599 for (const r of runes) {600 r.x = (r.x + r.vx + W) % W;601 r.y = (r.y + r.vy + H) % H;602 r.angle += r.va;603 ctx.save();604 ctx.translate(r.x, r.y);605 ctx.rotate(r.angle);606 ctx.font = `${r.size}px PeachScript, monospace`;607 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);608 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;609 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";610 ctx.shadowBlur = 8;611 ctx.fillText(r.char, 0, 0);612 ctx.restore();613 }614 animFrameId = requestAnimationFrame(draw);615 }616 draw();617 }618619 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {620 stopAnim();621 const ctx = canvas.getContext("2d")!;622 const W = canvas.width;623 const H = canvas.height;624 const cols = 8;625 const rows = 6;626 const cellW = W / cols;627 const cellH = H / rows;628 const targets = runes.slice(0, cols * rows).map((_, i) => ({629 x: (i % cols) * cellW + cellW / 2,630 y: Math.floor(i / cols) * cellH + cellH / 2,631 }));632 const start = performance.now();633 const DURATION = 400;634635 function drawSnap(now: number) {636 const t = Math.min((now - start) / DURATION, 1);637 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;638 ctx.clearRect(0, 0, W, H);639 runes.slice(0, targets.length).forEach((r, i) => {640 const tx = targets[i]!.x;641 const ty = targets[i]!.y;642 const cx = r.x + (tx - r.x) * ease;643 const cy = r.y + (ty - r.y) * ease;644 ctx.font = `16px PeachScript, monospace`;645 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;646 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";647 ctx.shadowBlur = 6;648 ctx.fillText(r.char, cx - 8, cy + 8);649 });650 if (t < 1) {651 animFrameId = requestAnimationFrame(drawSnap);652 } else {653 onDone();654 }655 }656 animFrameId = requestAnimationFrame(drawSnap);657 }658659 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {660 stopAnim();661 const ctx = canvas.getContext("2d")!;662 const W = canvas.width;663 const H = canvas.height;664 const start = performance.now();665 const DURATION = 900;666667 function drawBloom(now: number) {668 const t = Math.min((now - start) / DURATION, 1);669 ctx.clearRect(0, 0, W, H);670 671 const runeAlpha = 1 - Math.min(t / 0.4, 1);672 if (runeAlpha > 0) {673 for (const r of runes.slice(0, 48)) {674 ctx.font = `16px PeachScript, monospace`;675 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;676 ctx.fillText(r.char, r.x - 8, r.y + 8);677 }678 }679 680 const markT = Math.max(0, (t - 0.2) / 0.5);681 const markScale = Math.min(markT * 1.2, 1);682 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;683 if (markAlpha > 0) {684 ctx.save();685 ctx.globalAlpha = markAlpha;686 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;687 ctx.shadowBlur = 30;688 ctx.translate(W / 2, H / 2);689 ctx.scale(markScale, markScale);690 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);691 ctx.restore();692 }693 if (t < 1) {694 animFrameId = requestAnimationFrame(drawBloom);695 } else {696 onDone();697 }698 }699 animFrameId = requestAnimationFrame(drawBloom);700 }701702 onCleanup(() => stopAnim());703704 async function handleRestore() {705 if (!scriptValid() || !backupPass().trim() || working()) return;706 setError(null);707 setWorking(true);708 setAnimating("chaos");709710 const canvas = canvasRef;711 if (canvas) startChaos(canvas);712713 try {714 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));715 const payload = await parsePayload(bytes, backupPass());716717 if (!props.hasVault) {718 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });719 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");720 }721722 const rr = await sendMessage({723 type: "RESTORE_ENTRIES",724 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },725 });726 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");727728 if (payload.license?.key) {729 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});730 }731732 setAnimating("snap");733 stopAnim();734 if (canvas) {735 snapToGrid(canvas, () => {736 setAnimating("bloom");737 bloom(canvas, async () => {738 setAnimating(null);739 await props.onSuccess();740 });741 });742 } else {743 await props.onSuccess();744 }745 } catch (err) {746 stopAnim();747 setAnimating(null);748 const code = err instanceof Error ? err.message : String(err);749 setError(750 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"751 ? "That passphrase didn't work."752 : code === "CREATE_VAULT_FAILED"753 ? "Couldn't create a new local vault."754 : "Could not read this backup.",755 );756 } finally {757 setWorking(false);758 }759 }760761 return (762 <div class="peach-rt-symbols">763 <Show when={animating() !== null}>764 <canvas765 ref={(el) => { canvasRef = el; }}766 class="peach-rt-anim-canvas"767 width={280}768 height={200}769 />770 </Show>771 <Show when={animating() === null}>772 <textarea773 class="peach-restore-symbols-textarea"774 placeholder="paste PeachScript here…"775 rows={3}776 value={scriptInput()}777 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}778 />779 <div class="peach-rt-divider">— or —</div>780 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">781 open .peach file782 <input783 type="file"784 accept=".peach,text/plain"785 style="display:none"786 onChange={async (e) => {787 const file = (e.currentTarget as HTMLInputElement).files?.[0];788 if (!file) return;789 try {790 setScriptInput(await file.text());791 } catch {792 setError("Couldn't read file.");793 } finally {794 (e.currentTarget as HTMLInputElement).value = "";795 }796 }}797 />798 </label>799 <input800 class="peach-restore-symbols-pass"801 type="password"802 placeholder="backup password"803 value={backupPass()}804 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}805 onKeyDown={(e) => {806 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {807 e.preventDefault();808 void handleRestore();809 }810 }}811 />812 <button813 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"814 onClick={() => void handleRestore()}815 disabled={!scriptValid() || !backupPass().trim() || working()}816 >817 restore vault →818 </button>819 <Show when={error()}>820 <p class="peach-rt-error">{error()}</p>821 </Show>822 </Show>823 </div>824 );825}826827828829function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {830 const [decoding, setDecoding] = createSignal(false);831 const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);832 const [entryLine, setEntryLine] = createSignal<string | null>(null);833 const [backupPass, setBackupPass] = createSignal("");834 const [restoring, setRestoring] = createSignal(false);835 const [error, setError] = createSignal<string | null>(null);836837 async function processFile(file: File) {838 setDecoding(true);839 setDecoded(null);840 setEntryLine(null);841 setError(null);842 try {843 const { decodeMayan } = await import("../../crypto/mayan");844 const bitmap = await createImageBitmap(file);845 const MAX_DIM = 1500;846 const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));847 const canvas = document.createElement("canvas");848 canvas.width = Math.round(bitmap.width * scale);849 canvas.height = Math.round(bitmap.height * scale);850 const ctx = canvas.getContext("2d")!;851 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);852 bitmap.close();853 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);854 const payload = decodeMayan(imageData);855 if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }856 setDecoded(payload);857 } catch {858 setError("Could not read this Mayan code. Try a clearer image.");859 } finally {860 setDecoding(false);861 }862 }863864 async function handleRestore() {865 const bytes = decoded();866 if (!bytes || !backupPass().trim() || restoring()) return;867 setRestoring(true);868 setError(null);869 try {870 const payload = await parsePayload(bytes, backupPass());871 const count = payload.entries.length;872 const date = new Date(873 Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))874 );875 const monthYear = date.getFullYear() > 2000876 ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })877 : null;878 setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);879880 if (!props.hasVault) {881 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });882 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");883 }884 const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });885 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");886 if (payload.license?.key) {887 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});888 }889 await props.onSuccess();890 } catch (err) {891 const code = err instanceof Error ? err.message : String(err);892 setError(893 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"894 ? "Wrong backup password."895 : code === "CREATE_VAULT_FAILED"896 ? "Couldn't create a new local vault."897 : "Restore failed. The backup may be corrupted.",898 );899 } finally {900 setRestoring(false);901 }902 }903904 return (905 <div class="peach-rt-mayan">906 <Show when={decoded() === null}>907 <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>908 <span class="peach-rt-upload-label">909 {decoding() ? "decoding…" : "tap to upload image"}910 </span>911 <input912 type="file"913 accept="image/*"914 style="display:none"915 onChange={(e) => {916 const file = (e.currentTarget as HTMLInputElement).files?.[0];917 if (file) void processFile(file);918 (e.currentTarget as HTMLInputElement).value = "";919 }}920 disabled={decoding()}921 />922 </label>923 <Show when={error()}>924 <p class="peach-rt-error">{error()}</p>925 </Show>926 </Show>927 <Show when={decoded() !== null}>928 <Show when={entryLine()}>929 <p class="peach-rt-entry-line">{entryLine()}</p>930 </Show>931 <input932 class="peach-restore-symbols-pass"933 type="password"934 placeholder="backup password"935 ref={(el) => queueMicrotask(() => el.focus())}936 value={backupPass()}937 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}938 onKeyDown={(e) => {939 if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }940 }}941 />942 <button943 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"944 onClick={() => void handleRestore()}945 disabled={!backupPass().trim() || restoring()}946 >947 {restoring() ? "restoring…" : "restore vault →"}948 </button>949 <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>950 ← try another image951 </button>952 <Show when={error()}>953 <p class="peach-rt-error">{error()}</p>954 </Show>955 </Show>956 </div>957 );958}959960961962function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {963 const [importing, setImporting] = createSignal(false);964 const [error, setError] = createSignal<string | null>(null);965966 async function handleFile(e: Event) {967 const input = e.currentTarget as HTMLInputElement;968 const file = input.files?.[0];969 input.value = "";970 if (!file) return;971 setImporting(true);972 setError(null);973 try {974 const text = await file.text();975 const parsed = await parseImportSource(text);976 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");977978 if (props.hasVault === false) {979 980 981 982 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });983 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");984 }985986 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });987 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");988 if (parsed.licenseKey) {989 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});990 }991 await props.onSuccess();992 } catch (err) {993 const code = err instanceof Error ? err.message : String(err);994 setError(995 code === "NO_ENTRIES" ? "No entries found in this file." :996 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :997 "Import failed. Check the file format and try again."998 );999 } finally {1000 setImporting(false);1001 }1002 }10031004 return (1005 <div class="peach-rt-csv">1006 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1007 {importing() ? "importing…" : "open CSV file"}1008 <input1009 type="file"1010 accept=".csv,.json,.peach,application/x-peach"1011 style="display:none"1012 onChange={handleFile}1013 disabled={importing()}1014 />1015 </label>1016 <p class="peach-rt-csv-sources">1017 Imports from Bitwarden, 1Password, Proton Pass,<br />1018 Chrome, Safari, and Edge.1019 </p>1020 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1021 <Show when={error()}>1022 <p class="peach-rt-error">{error()}</p>1023 </Show>1024 </div>1025 );1026}1027102810291030type PhoneState = "waiting" | "connected" | "trust" | "error";10311032function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1033 const [state, setState] = createSignal<PhoneState>("waiting");1034 const [code, setCode] = createSignal<string | null>(null);1035 const [qrUrl, setQrUrl] = createSignal<string | null>(null);1036 const [expired, setExpired] = createSignal(false);1037 const [deviceName, setDeviceName] = createSignal<string | null>(null);1038 const [accepting, setAccepting] = createSignal(false);1039 const [serverError, setServerError] = createSignal<string | null>(null);1040 let expireTimer: ReturnType<typeof setTimeout> | null = null;10411042 function clearExpire() {1043 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1044 }10451046 async function startPairing() {1047 setExpired(false);1048 setCode(null);1049 setQrUrl(null);1050 setServerError(null);1051 clearExpire();1052 const res = await sendMessage({ type: "START_PAIRING_SERVER" });1053 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1054 if (!r.ok || !r.data) {1055 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1056 return;1057 }1058 const token = r.data.token;1059 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1060 setCode(formatted);1061 const qrData = `peach://handoff?token=${token}`;1062 try {1063 const QRCode = (await import("qrcode")).default;1064 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1065 setQrUrl(url);1066 } catch {1067 1068 }1069 const remaining = Math.max(0, r.data.expiresAt - Date.now());1070 expireTimer = setTimeout(() => {1071 setCode(null);1072 setQrUrl(null);1073 setExpired(true);1074 }, remaining);1075 }10761077 function handleRuntimeMsg(msg: unknown) {1078 const m = msg as { type?: string; payload?: { deviceName?: string } };1079 if (m?.type === "PAIRING_COMPLETED") {1080 clearExpire();1081 setDeviceName(m.payload?.deviceName ?? "your phone");1082 setState("trust");1083 }1084 }10851086 onMount(() => {1087 if (!props.isPro) return;1088 void startPairing();1089 1090 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1091 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1092 }1093 });10941095 onCleanup(() => {1096 clearExpire();1097 1098 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1099 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1100 }1101 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1102 });11031104 async function handleAccept(trust: boolean) {1105 setAccepting(true);1106 if (trust) {1107 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1108 }1109 await props.onSuccess();1110 }11111112 if (!props.isPro) {1113 return (1114 <div class="peach-rt-phone">1115 <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1116 <button1117 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1118 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1119 >1120 Get Peach Pro1121 </button>1122 <button1123 class="peach-btn peach-btn--ghost peach-btn--sm"1124 style="margin-top:6px"1125 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1126 >1127 I already have a license1128 </button>1129 </div>1130 );1131 }11321133 return (1134 <div class="peach-rt-phone">1135 <Show when={state() === "waiting"}>1136 <Show when={!expired()}>1137 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1138 <p class="peach-rt-phone-code">{code()}</p>1139 <Show when={qrUrl() !== null}>1140 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1141 </Show>1142 <p class="peach-rt-phone-hint">1143 Open Peach on your phone and tap<br />1144 Settings → Send to desktop.<br />1145 Same WiFi required.1146 </p>1147 </Show>1148 <Show when={serverError()}>1149 <p class="peach-rt-error">{serverError()}</p>1150 </Show>1151 </Show>1152 <Show when={expired()}>1153 <p class="peach-rt-phone-hint">Code expired.</p>1154 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1155 Generate new code1156 </button>1157 </Show>1158 </Show>11591160 <Show when={state() === "connected"}>1161 <p class="peach-rt-phone-hint">1162 Connected to {deviceName() ?? "your phone"}.<br />1163 Receiving vault…1164 </p>1165 </Show>11661167 <Show when={state() === "trust"}>1168 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1169 Vault received from {deviceName() ?? "your phone"}.<br />1170 Trust this network for sync?1171 </p>1172 <button1173 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1174 onClick={() => void handleAccept(true)}1175 disabled={accepting()}1176 >1177 Yes, trust this network1178 </button>1179 <button1180 class="peach-btn peach-btn--ghost peach-btn--sm"1181 style="margin-top:6px"1182 onClick={() => void handleAccept(false)}1183 disabled={accepting()}1184 >1185 Not now1186 </button>1187 </Show>11881189 <Show when={state() === "error"}>1190 <p class="peach-rt-phone-hint">1191 Could not connect. Check that both devices<br />1192 are on the same WiFi network.1193 </p>1194 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1195 Try again1196 </button>1197 </Show>1198 </div>1199 );1200}1201120212031204function getDisplayError(error: string | undefined, fallback: string): string {1205 if (!error) return fallback;1206 if (1207 error === "SERVICE_WORKER_UNAVAILABLE" ||1208 error.includes("Receiving end does not exist") ||1209 error.includes("message port closed")1210 ) {1211 return "background service unavailable";1212 }1213 return error;1214}