1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { ImportView } from "./Import";9import { storageGet, storageSet } from "../../shared/storage";10import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";11import { parsePayload } from "../../crypto/peachPayload";12import { parseImportSource } from "../../shared/import";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +17 '<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"/>' +18 '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19 '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20 '</svg>'21)}`;222324let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26 if (_zxcvbn) return _zxcvbn;27 const mod = await import("zxcvbn");28 _zxcvbn = mod.default;29 return _zxcvbn;30}3132interface LockProps {33 hasVault: boolean;34 ready: boolean;35 isPro?: boolean;36 373839 forceRecoveryWords?: boolean;40 onUnlock: () => void | Promise<void>;41 424344 onBackToOnboarding?: () => void;45}4647type RestoreSurface = "restore" | null;48type RecoverSurface = "words" | "new-password" | null;4950export function LockView(props: LockProps) {51 const [password, setPassword] = createSignal("");52 const [confirmPassword, setConfirmPassword] = createSignal("");53 const [error, setError] = createSignal("");54 const [loading, setLoading] = createSignal(false);55 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);56 const [showPassword, setShowPassword] = createSignal(false);57 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);58 59 60 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);61 const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);6263 const MIN_LENGTH = 12;64 const MIN_ZXCVBN_SCORE = 2;6566 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6768 const canCreate = createMemo(69 () =>70 password().length >= MIN_LENGTH &&71 confirmPassword().length >= MIN_LENGTH &&72 password() === confirmPassword() &&73 strengthScore() !== null &&74 strengthScore()! >= MIN_ZXCVBN_SCORE75 );7677 const passwordStrength = createMemo(() => {78 const score = strengthScore();79 if (score === null) return null;80 if (score <= 1) return "weak";81 if (score === 2) return "fair";82 if (score === 3) return "good";83 return "strong";84 });8586 const createBlockerMessage = createMemo(() => {87 if (!password()) return null;88 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;89 if (!confirmPassword()) return "confirm your password";90 if (password() !== confirmPassword()) return "passwords don't match";91 if (strengthScore() === null) return "checking strength…";92 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";93 return null;94 });9596 97 createMemo(() => {98 const value = password();99 if (!value) { setStrengthScore(null); return; }100 getZxcvbn().then((zxcvbn) => {101 if (password() === value) setStrengthScore(zxcvbn(value).score);102 }).catch(() => {103 104 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);105 });106 });107108 async function handleUnlock() {109 if (!password().trim()) return;110 setLoading(true);111 setError("");112 const response = await sendMessage({113 type: "UNLOCK",114 payload: { password: password() },115 });116 const result = response as { ok: boolean; error?: string };117 if (result.ok) {118 await props.onUnlock();119 setLoading(false);120 return;121 }122 setError(getDisplayError(result.error, "incorrect password"));123 setPassword("");124 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;125 passwordField?.classList.add("shake");126 setTimeout(() => passwordField?.classList.remove("shake"), 300);127 setLoading(false);128 }129130 async function handleCreate() {131 if (!password().trim()) return;132 if (password().length < MIN_LENGTH) {133 setError(`password must be at least ${MIN_LENGTH} characters`);134 return;135 }136 if (password() !== confirmPassword()) {137 setError("passwords do not match");138 return;139 }140 const score = strengthScore();141 if (score === null || score < MIN_ZXCVBN_SCORE) {142 setError("password is too weak — try a longer or more varied passphrase");143 return;144 }145 setLoading(true);146 setError("");147 const response = await sendMessage({148 type: "CREATE_VAULT",149 payload: { password: password() },150 });151 const result = response as { ok: boolean; error?: string };152 if (result.ok) {153 154 155 156 157 158 try {159 await chrome.storage.local?.set({ peach_recovery_pending: "1" });160 } catch { }161 setShowRecoveryWords(true);162 setLoading(false);163 return;164 }165 setError(getDisplayError(result.error, "failed to create vault"));166 setLoading(false);167 }168169 function openRestore(surface: Exclude<RestoreSurface, null>) {170 if (!props.ready || loading()) return;171 setPassword("");172 setConfirmPassword("");173 setError("");174 setRestoreSurface(surface);175 }176177 178 return (179 <Switch>180 {}181 <Match when={restoreSurface() === "restore"}>182 <RestoreTabView183 hasVault={props.hasVault}184 onBack={() => setRestoreSurface(null)}185 onUnlock={async () => {186 setRestoreSurface(null);187 await props.onUnlock();188 }}189 />190 </Match>191192 {}193 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>194 <RecoveryWordDisplayView195 onDone={props.onUnlock}196 />197 </Match>198199 {}200 <Match when={recoverSurface() === "words"}>201 <RecoveryWordEntryView202 onCancel={() => setRecoverSurface(null)}203 onRecoveryComplete={props.onUnlock}204 />205 </Match>206207 {}208 <Match when={props.hasVault}>209 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">210 <header class="peach-screen-header lock-screen-header">211 <div class="peach-wordmark-stack">212 <PeachWordmark class="peach-wordmark-wrap" />213 <Show when={props.isPro}>214 <span class="peach-pro-mini-badge">pro</span>215 </Show>216 </div>217 </header>218219 <div class="peach-lock-hero peach-lock-hero--unlock">220 <div class="peach-lock-hero-art" aria-hidden="true">221 <PeachMark class="peach-lock-hero-mark" />222 </div>223 <span class="peach-lock-eyebrow">vault locked</span>224 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">225 unlock226 <br />227 your vault.228 </h1>229 </div>230231 <LockRuneCanvas />232233 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">234 <label class="peach-label" for="unlock-password">235 master password236 </label>237 <input238 id="unlock-password"239 class="peach-underline-input peach-unlock-input lock-password-input"240 type="password"241 placeholder="••••••••••••"242 autocomplete="current-password"243 value={password()}244 disabled={!props.ready}245 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}246 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}247 onKeyDown={(event) => {248 if (event.key !== "Enter" || !props.ready) return;249 void handleUnlock();250 }}251 />252253 <Show when={error()}>254 <p class="peach-hint peach-hint-error">{error()}</p>255 </Show>256257 <button258 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"259 onClick={() => void handleUnlock()}260 disabled={!props.ready || loading()}261 >262 {loading() ? "unlocking..." : "unlock"}263 </button>264265 <button266 class="peach-lock-recover-link"267 type="button"268 onClick={() => setRecoverSurface("words")}269 >270 forgot your password?271 </button>272 </section>273 </section>274 </Match>275276 {}277 <Match when={true}>278 <section279 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"280 data-motion-phase="enter"281 >282 <header class="peach-screen-header peach-newvault-header">283 <Show when={props.onBackToOnboarding}>284 <button285 class="peach-newvault-back"286 type="button"287 onClick={() => props.onBackToOnboarding?.()}288 aria-label="Back to onboarding"289 >290 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">291 <path d="M7 2L3 5.5L7 9" />292 </svg>293 back294 </button>295 </Show>296 <div class="peach-wordmark-stack">297 <PeachWordmark class="peach-wordmark-wrap" />298 <Show when={props.isPro}>299 <span class="peach-pro-mini-badge">pro</span>300 </Show>301 </div>302 </header>303304 <div class="peach-newvault-mark" aria-hidden="true">305 <PeachMark class="peach-newvault-mark-art" />306 </div>307308 <div class="peach-newvault-card">309 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>310 <hr class="peach-newvault-divider" />311312 <div class="peach-newvault-field">313 <div class="peach-newvault-label-row">314 <label class="peach-label" for="master-password">315 master password316 </label>317 <button318 class="peach-lock-reveal-btn"319 type="button"320 onClick={() => setShowPassword((value) => !value)}321 aria-label={showPassword() ? "Hide password" : "Show password"}322 >323 <Show324 when={showPassword()}325 fallback={326 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">327 <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" />328 <circle cx="7" cy="5" r="1.75" />329 </svg>330 }331 >332 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">333 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />334 <path d="M3.5 5.5L2.5 7.5" />335 <path d="M7 6.5V8.5" />336 <path d="M10.5 5.5L11.5 7.5" />337 </svg>338 </Show>339 {showPassword() ? "hide" : "show"}340 </button>341 </div>342 <input343 id="master-password"344 class="peach-underline-input peach-newvault-input lock-password-input"345 type={showPassword() ? "text" : "password"}346 placeholder="••••••••••••"347 autocomplete="new-password"348 value={password()}349 disabled={!props.ready}350 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}351 onKeyDown={(event) => {352 if (event.key !== "Enter" || !props.ready) return;353 void handleCreate();354 }}355 />356 <Show357 when={password().length > 0 && passwordStrength() !== null}358 fallback={359 <p class="peach-newvault-hint">at least 12 characters</p>360 }361 >362 <div class="peach-password-strength">363 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>364 <div class="peach-strength-fill"></div>365 </div>366 <span class="peach-strength-label">{passwordStrength()}</span>367 </div>368 </Show>369 </div>370371 <div class="peach-newvault-field">372 <label class="peach-label" for="confirm-password">373 confirm password374 </label>375 <input376 id="confirm-password"377 class="peach-underline-input peach-newvault-input"378 type={showPassword() ? "text" : "password"}379 placeholder="••••••••••••"380 autocomplete="new-password"381 value={confirmPassword()}382 disabled={!props.ready}383 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}384 onKeyDown={(event) => {385 if (event.key === "Enter" && props.ready) void handleCreate();386 }}387 />388 </div>389390 <Show when={error()}>391 <p class="peach-hint peach-hint-error">{error()}</p>392 </Show>393394 <button395 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"396 onClick={() => void handleCreate()}397 disabled={!props.ready || loading() || !canCreate()}398 aria-live="polite"399 >400 <Show401 when={!canCreate() && createBlockerMessage()}402 fallback={loading() ? "creating..." : "create vault"}403 >404 {createBlockerMessage()}405 </Show>406 </button>407408 <p class="peach-newvault-warning">409 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>410 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>411 </p>412 </div>413414 <div class="peach-newvault-footer">415 <p class="peach-hint">Already have a backup?</p>416 <button417 class="peach-lock-restore-link"418 onClick={() => void openRestore("restore")}419 disabled={!props.ready}420 >421 › restore from backup422 </button>423 </div>424 </section>425 </Match>426 </Switch>427 );428}429430431432type RestoreTab = "symbols" | "csv" | "phone";433434function tabLabel(tab: RestoreTab): string {435 return (436 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]437 );438}439440function RestoreTabView(props: {441 hasVault: boolean;442 onBack: () => void;443 onUnlock: () => void | Promise<void>;444}) {445 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");446 const [showIntro, setShowIntro] = createSignal(false);447 const [pendingUnlock, setPendingUnlock] = createSignal(false);448449 async function handleRestoreSuccess() {450 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);451 if (!seen) {452 setShowIntro(true);453 } else {454 await props.onUnlock();455 }456 }457458 async function dismissIntro() {459 await storageSet("peach_shown_restore_intro", "true").catch(() => {});460 setShowIntro(false);461 await props.onUnlock();462 }463464 return (465 <section466 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"467 data-motion-phase="enter"468 >469 <RuneBackground opacity={0.05} />470471 <header class="peach-screen-header lock-screen-header">472 <PeachWordmark class="peach-wordmark-wrap" />473 <button class="peach-lock-restore-link" onClick={props.onBack}>474 ‹ back475 </button>476 </header>477478 <div class="peach-newvault-card peach-restore-tabcard">479 <div class="peach-restore-tabs" role="tablist">480 <For each={["symbols", "csv", "phone"] as RestoreTab[]}>481 {(tab) => (482 <button483 class="peach-restore-tab"484 classList={{ "is-active": activeTab() === tab }}485 onClick={() => setActiveTab(tab)}486 role="tab"487 aria-selected={activeTab() === tab}488 >489 {tabLabel(tab)}490 </button>491 )}492 </For>493 </div>494 <div class="peach-restore-content">495 <Show when={activeTab() === "symbols"}>496 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />497 </Show>498 <Show when={activeTab() === "csv"}>499 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />500 </Show>501 <Show when={activeTab() === "phone"}>502 <RestoreFromPhone onSuccess={handleRestoreSuccess} />503 </Show>504 </div>505 </div>506507 {}508 <Show when={showIntro()}>509 <div class="peach-restore-intro-overlay">510 <div class="peach-restore-intro-card">511 <p class="peach-restore-intro-headline">welcome to peach.</p>512 <div class="peach-restore-intro-body">513 <p>Your vault lives only on this device. No server has a copy.</p>514 <p>The inline bubble fills passwords automatically when you visit sites.</p>515 <p>Export your vault from Settings before switching devices.</p>516 </div>517 <button518 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"519 onClick={() => void dismissIntro()}520 disabled={pendingUnlock()}521 >522 got it523 </button>524 </div>525 </div>526 </Show>527 </section>528 );529}530531532533function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {534 const [scriptInput, setScriptInput] = createSignal("");535 const [backupPass, setBackupPass] = createSignal("");536 const [working, setWorking] = createSignal(false);537 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);538 const [error, setError] = createSignal<string | null>(null);539 let canvasRef: HTMLCanvasElement | undefined;540 let animFrameId = 0;541 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];542543 const scriptValid = createMemo(() => isPeachScript(scriptInput()));544545 function stopAnim() {546 cancelAnimationFrame(animFrameId);547 animFrameId = 0;548 }549550 function startChaos(canvas: HTMLCanvasElement) {551 const ctx = canvas.getContext("2d")!;552 const W = canvas.width;553 const H = canvas.height;554 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>555 String.fromCodePoint(0xe000 + i)556 );557 if (runes.length === 0) {558 runes = Array.from({ length: 60 }, () => ({559 x: Math.random() * W,560 y: Math.random() * H,561 vx: (Math.random() - 0.5) * 3,562 vy: (Math.random() - 0.5) * 3,563 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,564 size: 14 + Math.random() * 20,565 angle: Math.random() * Math.PI * 2,566 va: (Math.random() - 0.5) * 0.1,567 }));568 }569570 function draw() {571 ctx.clearRect(0, 0, W, H);572 for (const r of runes) {573 r.x = (r.x + r.vx + W) % W;574 r.y = (r.y + r.vy + H) % H;575 r.angle += r.va;576 ctx.save();577 ctx.translate(r.x, r.y);578 ctx.rotate(r.angle);579 ctx.font = `${r.size}px PeachScript, monospace`;580 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);581 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;582 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";583 ctx.shadowBlur = 8;584 ctx.fillText(r.char, 0, 0);585 ctx.restore();586 }587 animFrameId = requestAnimationFrame(draw);588 }589 draw();590 }591592 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {593 stopAnim();594 const ctx = canvas.getContext("2d")!;595 const W = canvas.width;596 const H = canvas.height;597 const cols = 8;598 const rows = 6;599 const cellW = W / cols;600 const cellH = H / rows;601 const targets = runes.slice(0, cols * rows).map((_, i) => ({602 x: (i % cols) * cellW + cellW / 2,603 y: Math.floor(i / cols) * cellH + cellH / 2,604 }));605 const start = performance.now();606 const DURATION = 400;607608 function drawSnap(now: number) {609 const t = Math.min((now - start) / DURATION, 1);610 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;611 ctx.clearRect(0, 0, W, H);612 runes.slice(0, targets.length).forEach((r, i) => {613 const tx = targets[i]!.x;614 const ty = targets[i]!.y;615 const cx = r.x + (tx - r.x) * ease;616 const cy = r.y + (ty - r.y) * ease;617 ctx.font = `16px PeachScript, monospace`;618 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;619 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";620 ctx.shadowBlur = 6;621 ctx.fillText(r.char, cx - 8, cy + 8);622 });623 if (t < 1) {624 animFrameId = requestAnimationFrame(drawSnap);625 } else {626 onDone();627 }628 }629 animFrameId = requestAnimationFrame(drawSnap);630 }631632 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {633 stopAnim();634 const ctx = canvas.getContext("2d")!;635 const W = canvas.width;636 const H = canvas.height;637 const start = performance.now();638 const DURATION = 900;639640 function drawBloom(now: number) {641 const t = Math.min((now - start) / DURATION, 1);642 ctx.clearRect(0, 0, W, H);643 644 const runeAlpha = 1 - Math.min(t / 0.4, 1);645 if (runeAlpha > 0) {646 for (const r of runes.slice(0, 48)) {647 ctx.font = `16px PeachScript, monospace`;648 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;649 ctx.fillText(r.char, r.x - 8, r.y + 8);650 }651 }652 653 const markT = Math.max(0, (t - 0.2) / 0.5);654 const markScale = Math.min(markT * 1.2, 1);655 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;656 if (markAlpha > 0) {657 ctx.save();658 ctx.globalAlpha = markAlpha;659 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;660 ctx.shadowBlur = 30;661 ctx.translate(W / 2, H / 2);662 ctx.scale(markScale, markScale);663 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);664 ctx.restore();665 }666 if (t < 1) {667 animFrameId = requestAnimationFrame(drawBloom);668 } else {669 onDone();670 }671 }672 animFrameId = requestAnimationFrame(drawBloom);673 }674675 onCleanup(() => stopAnim());676677 async function handleRestore() {678 if (!scriptValid() || !backupPass().trim() || working()) return;679 setError(null);680 setWorking(true);681 setAnimating("chaos");682683 const canvas = canvasRef;684 if (canvas) startChaos(canvas);685686 try {687 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));688 const payload = await parsePayload(bytes, backupPass());689690 if (!props.hasVault) {691 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });692 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");693 }694695 const rr = await sendMessage({696 type: "RESTORE_ENTRIES",697 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },698 });699 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");700701 if (payload.license?.key) {702 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});703 }704705 setAnimating("snap");706 stopAnim();707 if (canvas) {708 snapToGrid(canvas, () => {709 setAnimating("bloom");710 bloom(canvas, async () => {711 setAnimating(null);712 await props.onSuccess();713 });714 });715 } else {716 await props.onSuccess();717 }718 } catch (err) {719 stopAnim();720 setAnimating(null);721 const code = err instanceof Error ? err.message : String(err);722 setError(723 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"724 ? "That passphrase didn't work."725 : code === "CREATE_VAULT_FAILED"726 ? "Couldn't create a new local vault."727 : "Could not read this backup.",728 );729 } finally {730 setWorking(false);731 }732 }733734 return (735 <div class="peach-rt-symbols">736 <Show when={animating() !== null}>737 <canvas738 ref={(el) => { canvasRef = el; }}739 class="peach-rt-anim-canvas"740 width={280}741 height={200}742 />743 </Show>744 <Show when={animating() === null}>745 <textarea746 class="peach-restore-symbols-textarea"747 placeholder="paste peachscript…"748 rows={3}749 value={scriptInput()}750 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}751 />752 <div class="peach-rt-divider">— or —</div>753 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">754 open .peach file755 <input756 type="file"757 accept=".peach,text/plain"758 style="display:none"759 onChange={async (e) => {760 const file = (e.currentTarget as HTMLInputElement).files?.[0];761 if (!file) return;762 try {763 setScriptInput(await file.text());764 } catch {765 setError("Couldn't read file.");766 } finally {767 (e.currentTarget as HTMLInputElement).value = "";768 }769 }}770 />771 </label>772 <input773 class="peach-restore-symbols-pass"774 type="password"775 placeholder="backup password"776 value={backupPass()}777 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}778 onKeyDown={(e) => {779 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {780 e.preventDefault();781 void handleRestore();782 }783 }}784 />785 <button786 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"787 onClick={() => void handleRestore()}788 disabled={!scriptValid() || !backupPass().trim() || working()}789 >790 restore vault →791 </button>792 <Show when={error()}>793 <p class="peach-rt-error">{error()}</p>794 </Show>795 </Show>796 </div>797 );798}799800801802function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {803 const [importing, setImporting] = createSignal(false);804 const [error, setError] = createSignal<string | null>(null);805806 async function handleFile(e: Event) {807 const input = e.currentTarget as HTMLInputElement;808 const file = input.files?.[0];809 input.value = "";810 if (!file) return;811 setImporting(true);812 setError(null);813 try {814 const text = await file.text();815 const parsed = await parseImportSource(text);816 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");817818 if (props.hasVault === false) {819 820 821 822 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });823 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");824 }825826 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });827 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");828 if (parsed.licenseKey) {829 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});830 }831 await props.onSuccess();832 } catch (err) {833 const code = err instanceof Error ? err.message : String(err);834 setError(835 code === "NO_ENTRIES" ? "No entries found in this file." :836 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :837 "Import failed. Check the file format and try again."838 );839 } finally {840 setImporting(false);841 }842 }843844 return (845 <div class="peach-rt-csv">846 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>847 {importing() ? "importing…" : "open csv file"}848 <input849 type="file"850 accept=".csv,.json,.peach,application/x-peach"851 style="display:none"852 onChange={handleFile}853 disabled={importing()}854 />855 </label>856 <p class="peach-rt-csv-sources">857 Imports from Bitwarden, 1Password, Proton Pass,<br />858 Chrome, Safari, and Edge.859 </p>860 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>861 <Show when={error()}>862 <p class="peach-rt-error">{error()}</p>863 </Show>864 </div>865 );866}867868869870type PhoneState = "waiting" | "connected" | "trust" | "error";871872function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {873 const [state, setState] = createSignal<PhoneState>("waiting");874 const [code, setCode] = createSignal<string | null>(null);875 const [qrUrl, setQrUrl] = createSignal<string | null>(null);876 const [expired, setExpired] = createSignal(false);877 const [deviceName, setDeviceName] = createSignal<string | null>(null);878 const [accepting, setAccepting] = createSignal(false);879 const [serverError, setServerError] = createSignal<string | null>(null);880 let expireTimer: ReturnType<typeof setTimeout> | null = null;881882 function clearExpire() {883 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }884 }885886 async function startPairing() {887 setExpired(false);888 setCode(null);889 setQrUrl(null);890 setServerError(null);891 clearExpire();892 const res = await sendMessage({ type: "START_PAIRING_SERVER" });893 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };894 if (!r.ok || !r.data) {895 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");896 return;897 }898 const token = r.data.token;899 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;900 setCode(formatted);901 const qrData = `peach://handoff?token=${token}`;902 try {903 const QRCode = (await import("qrcode")).default;904 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });905 setQrUrl(url);906 } catch {907 908 }909 const remaining = Math.max(0, r.data.expiresAt - Date.now());910 expireTimer = setTimeout(() => {911 setCode(null);912 setQrUrl(null);913 setExpired(true);914 }, remaining);915 }916917 function handleRuntimeMsg(msg: unknown) {918 const m = msg as { type?: string; payload?: { deviceName?: string } };919 if (m?.type === "PAIRING_COMPLETED") {920 clearExpire();921 setDeviceName(m.payload?.deviceName ?? "your phone");922 setState("trust");923 }924 }925926 onMount(() => {927 void startPairing();928 929 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {930 chrome.runtime.onMessage.addListener(handleRuntimeMsg);931 }932 });933934 onCleanup(() => {935 clearExpire();936 937 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {938 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);939 }940 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});941 });942943 async function handleAccept(trust: boolean) {944 setAccepting(true);945 if (trust) {946 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});947 }948 await props.onSuccess();949 }950951 return (952 <div class="peach-rt-phone">953 <Show when={state() === "waiting"}>954 <Show when={!expired()}>955 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>956 <p class="peach-rt-phone-code">{code()}</p>957 <Show when={qrUrl() !== null}>958 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />959 </Show>960 <p class="peach-rt-phone-hint">961 Open Peach on your phone and tap<br />962 Settings → Send to desktop.<br />963 Same WiFi required.964 </p>965 </Show>966 <Show when={serverError()}>967 <p class="peach-rt-error">{serverError()}</p>968 </Show>969 </Show>970 <Show when={expired()}>971 <p class="peach-rt-phone-hint">Code expired.</p>972 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>973 Generate new code974 </button>975 </Show>976 </Show>977978 <Show when={state() === "connected"}>979 <p class="peach-rt-phone-hint">980 Connected to {deviceName() ?? "your phone"}.<br />981 Receiving vault…982 </p>983 </Show>984985 <Show when={state() === "trust"}>986 <p class="peach-rt-phone-hint" style="margin-bottom:10px">987 Vault received from {deviceName() ?? "your phone"}.<br />988 Trust this network for sync?989 </p>990 <button991 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"992 onClick={() => void handleAccept(true)}993 disabled={accepting()}994 >995 Yes, trust this network996 </button>997 <button998 class="peach-btn peach-btn--ghost peach-btn--sm"999 style="margin-top:6px"1000 onClick={() => void handleAccept(false)}1001 disabled={accepting()}1002 >1003 Not now1004 </button>1005 </Show>10061007 <Show when={state() === "error"}>1008 <p class="peach-rt-phone-hint">1009 Could not connect. Check that both devices<br />1010 are on the same WiFi network.1011 </p>1012 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1013 Try again1014 </button>1015 </Show>1016 </div>1017 );1018}1019102010211022function getDisplayError(error: string | undefined, fallback: string): string {1023 if (!error) return fallback;1024 if (1025 error === "SERVICE_WORKER_UNAVAILABLE" ||1026 error.includes("Receiving end does not exist") ||1027 error.includes("message port closed")1028 ) {1029 return "background service unavailable";1030 }1031 return error;1032}