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 onUnlock: () => void | Promise<void>;37 383940 onBackToOnboarding?: () => void;41}4243type RestoreSurface = "restore" | null;44type RecoverSurface = "words" | "new-password" | 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 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);52 const [showPassword, setShowPassword] = createSignal(false);53 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);54 55 56 const [showRecoveryWords, setShowRecoveryWords] = createSignal(false);57 const [recoveryWords, setRecoveryWords] = createSignal<string[]>([]);5859 const MIN_LENGTH = 12;60 const MIN_ZXCVBN_SCORE = 2;6162 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);6364 const canCreate = createMemo(65 () =>66 password().length >= MIN_LENGTH &&67 confirmPassword().length >= MIN_LENGTH &&68 password() === confirmPassword() &&69 strengthScore() !== null &&70 strengthScore()! >= MIN_ZXCVBN_SCORE71 );7273 const passwordStrength = createMemo(() => {74 const score = strengthScore();75 if (score === null) return null;76 if (score <= 1) return "weak";77 if (score === 2) return "fair";78 if (score === 3) return "good";79 return "strong";80 });8182 const createBlockerMessage = createMemo(() => {83 if (!password()) return null;84 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;85 if (!confirmPassword()) return "confirm your password";86 if (password() !== confirmPassword()) return "passwords don't match";87 if (strengthScore() === null) return "checking strength…";88 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";89 return null;90 });9192 93 createMemo(() => {94 const value = password();95 if (!value) { setStrengthScore(null); return; }96 getZxcvbn().then((zxcvbn) => {97 if (password() === value) setStrengthScore(zxcvbn(value).score);98 }).catch(() => {99 100 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);101 });102 });103104 async function handleUnlock() {105 if (!password().trim()) return;106 setLoading(true);107 setError("");108 const response = await sendMessage({109 type: "UNLOCK",110 payload: { password: password() },111 });112 const result = response as { ok: boolean; error?: string };113 if (result.ok) {114 await props.onUnlock();115 setLoading(false);116 return;117 }118 setError(getDisplayError(result.error, "incorrect password"));119 setPassword("");120 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;121 passwordField?.classList.add("shake");122 setTimeout(() => passwordField?.classList.remove("shake"), 300);123 setLoading(false);124 }125126 async function handleCreate() {127 if (!password().trim()) return;128 if (password().length < MIN_LENGTH) {129 setError(`password must be at least ${MIN_LENGTH} characters`);130 return;131 }132 if (password() !== confirmPassword()) {133 setError("passwords do not match");134 return;135 }136 const score = strengthScore();137 if (score === null || score < MIN_ZXCVBN_SCORE) {138 setError("password is too weak — try a longer or more varied passphrase");139 return;140 }141 setLoading(true);142 setError("");143 const response = await sendMessage({144 type: "CREATE_VAULT",145 payload: { password: password() },146 });147 const result = response as { ok: boolean; error?: string };148 if (result.ok) {149 150 setShowRecoveryWords(true);151 setLoading(false);152 return;153 }154 setError(getDisplayError(result.error, "failed to create vault"));155 setLoading(false);156 }157158 function openRestore(surface: Exclude<RestoreSurface, null>) {159 if (!props.ready || loading()) return;160 setPassword("");161 setConfirmPassword("");162 setError("");163 setRestoreSurface(surface);164 }165166 167 return (168 <Switch>169 {}170 <Match when={restoreSurface() === "restore"}>171 <RestoreTabView172 hasVault={props.hasVault}173 onBack={() => setRestoreSurface(null)}174 onUnlock={async () => {175 setRestoreSurface(null);176 await props.onUnlock();177 }}178 />179 </Match>180181 {}182 <Match when={showRecoveryWords() && !props.hasVault}>183 <RecoveryWordDisplayView184 onDone={props.onUnlock}185 />186 </Match>187188 {}189 <Match when={recoverSurface() === "words"}>190 <RecoveryWordEntryView191 onCancel={() => setRecoverSurface(null)}192 onRecoveryComplete={props.onUnlock}193 />194 </Match>195196 {}197 <Match when={props.hasVault}>198 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">199 <header class="peach-screen-header lock-screen-header">200 <div class="peach-wordmark-stack">201 <PeachWordmark class="peach-wordmark-wrap" />202 <Show when={props.isPro}>203 <span class="peach-pro-mini-badge">pro</span>204 </Show>205 </div>206 </header>207208 <div class="peach-lock-hero peach-lock-hero--unlock">209 <div class="peach-lock-hero-art" aria-hidden="true">210 <PeachMark class="peach-lock-hero-mark" />211 </div>212 <span class="peach-lock-eyebrow">vault locked</span>213 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">214 unlock215 <br />216 your vault.217 </h1>218 </div>219220 <LockRuneCanvas />221222 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">223 <label class="peach-label" for="unlock-password">224 master password225 </label>226 <input227 id="unlock-password"228 class="peach-underline-input peach-unlock-input lock-password-input"229 type="password"230 placeholder="••••••••••••"231 autocomplete="current-password"232 value={password()}233 disabled={!props.ready}234 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}235 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}236 onKeyDown={(event) => {237 if (event.key !== "Enter" || !props.ready) return;238 void handleUnlock();239 }}240 />241242 <Show when={error()}>243 <p class="peach-hint peach-hint-error">{error()}</p>244 </Show>245246 <button247 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"248 onClick={() => void handleUnlock()}249 disabled={!props.ready || loading()}250 >251 {loading() ? "unlocking..." : "unlock"}252 </button>253254 <button255 class="peach-lock-recover-link"256 type="button"257 onClick={() => setRecoverSurface("words")}258 >259 forgot your password?260 </button>261 </section>262 </section>263 </Match>264265 {}266 <Match when={true}>267 <section268 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"269 data-motion-phase="enter"270 >271 <header class="peach-screen-header peach-newvault-header">272 <Show when={props.onBackToOnboarding}>273 <button274 class="peach-newvault-back"275 type="button"276 onClick={() => props.onBackToOnboarding?.()}277 aria-label="Back to onboarding"278 >279 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">280 <path d="M7 2L3 5.5L7 9" />281 </svg>282 back283 </button>284 </Show>285 <div class="peach-wordmark-stack">286 <PeachWordmark class="peach-wordmark-wrap" />287 <Show when={props.isPro}>288 <span class="peach-pro-mini-badge">pro</span>289 </Show>290 </div>291 </header>292293 <div class="peach-newvault-mark" aria-hidden="true">294 <PeachMark class="peach-newvault-mark-art" />295 </div>296297 <div class="peach-newvault-card">298 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>299 <hr class="peach-newvault-divider" />300301 <div class="peach-newvault-field">302 <div class="peach-newvault-label-row">303 <label class="peach-label" for="master-password">304 master password305 </label>306 <button307 class="peach-lock-reveal-btn"308 type="button"309 onClick={() => setShowPassword((value) => !value)}310 aria-label={showPassword() ? "Hide password" : "Show password"}311 >312 <Show313 when={showPassword()}314 fallback={315 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">316 <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" />317 <circle cx="7" cy="5" r="1.75" />318 </svg>319 }320 >321 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">322 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />323 <path d="M3.5 5.5L2.5 7.5" />324 <path d="M7 6.5V8.5" />325 <path d="M10.5 5.5L11.5 7.5" />326 </svg>327 </Show>328 {showPassword() ? "hide" : "show"}329 </button>330 </div>331 <input332 id="master-password"333 class="peach-underline-input peach-newvault-input lock-password-input"334 type={showPassword() ? "text" : "password"}335 placeholder="••••••••••••"336 autocomplete="new-password"337 value={password()}338 disabled={!props.ready}339 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}340 onKeyDown={(event) => {341 if (event.key !== "Enter" || !props.ready) return;342 void handleCreate();343 }}344 />345 <Show346 when={password().length > 0 && passwordStrength() !== null}347 fallback={348 <p class="peach-newvault-hint">at least 12 characters</p>349 }350 >351 <div class="peach-password-strength">352 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>353 <div class="peach-strength-fill"></div>354 </div>355 <span class="peach-strength-label">{passwordStrength()}</span>356 </div>357 </Show>358 </div>359360 <div class="peach-newvault-field">361 <label class="peach-label" for="confirm-password">362 confirm password363 </label>364 <input365 id="confirm-password"366 class="peach-underline-input peach-newvault-input"367 type={showPassword() ? "text" : "password"}368 placeholder="••••••••••••"369 autocomplete="new-password"370 value={confirmPassword()}371 disabled={!props.ready}372 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}373 onKeyDown={(event) => {374 if (event.key === "Enter" && props.ready) void handleCreate();375 }}376 />377 </div>378379 <Show when={error()}>380 <p class="peach-hint peach-hint-error">{error()}</p>381 </Show>382383 <button384 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"385 onClick={() => void handleCreate()}386 disabled={!props.ready || loading() || !canCreate()}387 aria-live="polite"388 >389 <Show390 when={!canCreate() && createBlockerMessage()}391 fallback={loading() ? "creating..." : "create vault"}392 >393 {createBlockerMessage()}394 </Show>395 </button>396397 <p class="peach-newvault-warning">398 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>399 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>400 </p>401 </div>402403 <div class="peach-newvault-footer">404 <p class="peach-hint">Already have a backup?</p>405 <button406 class="peach-lock-restore-link"407 onClick={() => void openRestore("restore")}408 disabled={!props.ready}409 >410 › restore from backup411 </button>412 </div>413 </section>414 </Match>415 </Switch>416 );417}418419420421type RestoreTab = "symbols" | "csv" | "phone";422423function tabLabel(tab: RestoreTab): string {424 return (425 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]426 );427}428429function RestoreTabView(props: {430 hasVault: boolean;431 onBack: () => void;432 onUnlock: () => void | Promise<void>;433}) {434 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");435 const [showIntro, setShowIntro] = createSignal(false);436 const [pendingUnlock, setPendingUnlock] = createSignal(false);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 }445 }446447 async function dismissIntro() {448 await storageSet("peach_shown_restore_intro", "true").catch(() => {});449 setShowIntro(false);450 await props.onUnlock();451 }452453 return (454 <section455 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"456 data-motion-phase="enter"457 >458 <RuneBackground opacity={0.05} />459460 <header class="peach-screen-header lock-screen-header">461 <PeachWordmark class="peach-wordmark-wrap" />462 <button class="peach-lock-restore-link" onClick={props.onBack}>463 ‹ back464 </button>465 </header>466467 <div class="peach-newvault-card peach-restore-tabcard">468 <div class="peach-restore-tabs" role="tablist">469 <For each={["symbols", "csv", "phone"] as RestoreTab[]}>470 {(tab) => (471 <button472 class="peach-restore-tab"473 classList={{ "is-active": activeTab() === tab }}474 onClick={() => setActiveTab(tab)}475 role="tab"476 aria-selected={activeTab() === tab}477 >478 {tabLabel(tab)}479 </button>480 )}481 </For>482 </div>483 <div class="peach-restore-content">484 <Show when={activeTab() === "symbols"}>485 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />486 </Show>487 <Show when={activeTab() === "csv"}>488 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />489 </Show>490 <Show when={activeTab() === "phone"}>491 <RestoreFromPhone onSuccess={handleRestoreSuccess} />492 </Show>493 </div>494 </div>495496 {}497 <Show when={showIntro()}>498 <div class="peach-restore-intro-overlay">499 <div class="peach-restore-intro-card">500 <p class="peach-restore-intro-headline">welcome to peach.</p>501 <div class="peach-restore-intro-body">502 <p>Your vault lives only on this device. No server has a copy.</p>503 <p>The inline bubble fills passwords automatically when you visit sites.</p>504 <p>Export your vault from Settings before switching devices.</p>505 </div>506 <button507 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"508 onClick={() => void dismissIntro()}509 disabled={pendingUnlock()}510 >511 got it512 </button>513 </div>514 </div>515 </Show>516 </section>517 );518}519520521522function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {523 const [scriptInput, setScriptInput] = createSignal("");524 const [backupPass, setBackupPass] = createSignal("");525 const [working, setWorking] = createSignal(false);526 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);527 const [error, setError] = createSignal<string | null>(null);528 let canvasRef: HTMLCanvasElement | undefined;529 let animFrameId = 0;530 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];531532 const scriptValid = createMemo(() => isPeachScript(scriptInput()));533534 function stopAnim() {535 cancelAnimationFrame(animFrameId);536 animFrameId = 0;537 }538539 function startChaos(canvas: HTMLCanvasElement) {540 const ctx = canvas.getContext("2d")!;541 const W = canvas.width;542 const H = canvas.height;543 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>544 String.fromCodePoint(0xe000 + i)545 );546 if (runes.length === 0) {547 runes = Array.from({ length: 60 }, () => ({548 x: Math.random() * W,549 y: Math.random() * H,550 vx: (Math.random() - 0.5) * 3,551 vy: (Math.random() - 0.5) * 3,552 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,553 size: 14 + Math.random() * 20,554 angle: Math.random() * Math.PI * 2,555 va: (Math.random() - 0.5) * 0.1,556 }));557 }558559 function draw() {560 ctx.clearRect(0, 0, W, H);561 for (const r of runes) {562 r.x = (r.x + r.vx + W) % W;563 r.y = (r.y + r.vy + H) % H;564 r.angle += r.va;565 ctx.save();566 ctx.translate(r.x, r.y);567 ctx.rotate(r.angle);568 ctx.font = `${r.size}px PeachScript, monospace`;569 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);570 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;571 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";572 ctx.shadowBlur = 8;573 ctx.fillText(r.char, 0, 0);574 ctx.restore();575 }576 animFrameId = requestAnimationFrame(draw);577 }578 draw();579 }580581 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {582 stopAnim();583 const ctx = canvas.getContext("2d")!;584 const W = canvas.width;585 const H = canvas.height;586 const cols = 8;587 const rows = 6;588 const cellW = W / cols;589 const cellH = H / rows;590 const targets = runes.slice(0, cols * rows).map((_, i) => ({591 x: (i % cols) * cellW + cellW / 2,592 y: Math.floor(i / cols) * cellH + cellH / 2,593 }));594 const start = performance.now();595 const DURATION = 400;596597 function drawSnap(now: number) {598 const t = Math.min((now - start) / DURATION, 1);599 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;600 ctx.clearRect(0, 0, W, H);601 runes.slice(0, targets.length).forEach((r, i) => {602 const tx = targets[i]!.x;603 const ty = targets[i]!.y;604 const cx = r.x + (tx - r.x) * ease;605 const cy = r.y + (ty - r.y) * ease;606 ctx.font = `16px PeachScript, monospace`;607 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;608 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";609 ctx.shadowBlur = 6;610 ctx.fillText(r.char, cx - 8, cy + 8);611 });612 if (t < 1) {613 animFrameId = requestAnimationFrame(drawSnap);614 } else {615 onDone();616 }617 }618 animFrameId = requestAnimationFrame(drawSnap);619 }620621 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {622 stopAnim();623 const ctx = canvas.getContext("2d")!;624 const W = canvas.width;625 const H = canvas.height;626 const start = performance.now();627 const DURATION = 900;628629 function drawBloom(now: number) {630 const t = Math.min((now - start) / DURATION, 1);631 ctx.clearRect(0, 0, W, H);632 633 const runeAlpha = 1 - Math.min(t / 0.4, 1);634 if (runeAlpha > 0) {635 for (const r of runes.slice(0, 48)) {636 ctx.font = `16px PeachScript, monospace`;637 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;638 ctx.fillText(r.char, r.x - 8, r.y + 8);639 }640 }641 642 const markT = Math.max(0, (t - 0.2) / 0.5);643 const markScale = Math.min(markT * 1.2, 1);644 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;645 if (markAlpha > 0) {646 ctx.save();647 ctx.globalAlpha = markAlpha;648 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;649 ctx.shadowBlur = 30;650 ctx.translate(W / 2, H / 2);651 ctx.scale(markScale, markScale);652 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);653 ctx.restore();654 }655 if (t < 1) {656 animFrameId = requestAnimationFrame(drawBloom);657 } else {658 onDone();659 }660 }661 animFrameId = requestAnimationFrame(drawBloom);662 }663664 onCleanup(() => stopAnim());665666 async function handleRestore() {667 if (!scriptValid() || !backupPass().trim() || working()) return;668 setError(null);669 setWorking(true);670 setAnimating("chaos");671672 const canvas = canvasRef;673 if (canvas) startChaos(canvas);674675 try {676 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));677 const payload = await parsePayload(bytes, backupPass());678679 if (!props.hasVault) {680 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });681 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");682 }683684 const rr = await sendMessage({685 type: "RESTORE_ENTRIES",686 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },687 });688 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");689690 if (payload.license?.key) {691 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});692 }693694 setAnimating("snap");695 stopAnim();696 if (canvas) {697 snapToGrid(canvas, () => {698 setAnimating("bloom");699 bloom(canvas, async () => {700 setAnimating(null);701 await props.onSuccess();702 });703 });704 } else {705 await props.onSuccess();706 }707 } catch (err) {708 stopAnim();709 setAnimating(null);710 const code = err instanceof Error ? err.message : String(err);711 setError(712 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"713 ? "That passphrase didn't work."714 : code === "CREATE_VAULT_FAILED"715 ? "Couldn't create a new local vault."716 : "Could not read this backup.",717 );718 } finally {719 setWorking(false);720 }721 }722723 return (724 <div class="peach-rt-symbols">725 <Show when={animating() !== null}>726 <canvas727 ref={(el) => { canvasRef = el; }}728 class="peach-rt-anim-canvas"729 width={280}730 height={200}731 />732 </Show>733 <Show when={animating() === null}>734 <textarea735 class="peach-restore-symbols-textarea"736 placeholder="paste PeachScript here…"737 rows={3}738 value={scriptInput()}739 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}740 />741 <div class="peach-rt-divider">— or —</div>742 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">743 open .peach file744 <input745 type="file"746 accept=".peach,text/plain"747 style="display:none"748 onChange={async (e) => {749 const file = (e.currentTarget as HTMLInputElement).files?.[0];750 if (!file) return;751 try {752 setScriptInput(await file.text());753 } catch {754 setError("Couldn't read file.");755 } finally {756 (e.currentTarget as HTMLInputElement).value = "";757 }758 }}759 />760 </label>761 <input762 class="peach-restore-symbols-pass"763 type="password"764 placeholder="backup password"765 value={backupPass()}766 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}767 onKeyDown={(e) => {768 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {769 e.preventDefault();770 void handleRestore();771 }772 }}773 />774 <button775 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"776 onClick={() => void handleRestore()}777 disabled={!scriptValid() || !backupPass().trim() || working()}778 >779 restore vault →780 </button>781 <Show when={error()}>782 <p class="peach-rt-error">{error()}</p>783 </Show>784 </Show>785 </div>786 );787}788789790791function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {792 const [importing, setImporting] = createSignal(false);793 const [error, setError] = createSignal<string | null>(null);794795 async function handleFile(e: Event) {796 const input = e.currentTarget as HTMLInputElement;797 const file = input.files?.[0];798 input.value = "";799 if (!file) return;800 setImporting(true);801 setError(null);802 try {803 const text = await file.text();804 const parsed = await parseImportSource(text);805 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");806807 if (props.hasVault === false) {808 809 810 811 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });812 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");813 }814815 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });816 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");817 if (parsed.licenseKey) {818 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});819 }820 await props.onSuccess();821 } catch (err) {822 const code = err instanceof Error ? err.message : String(err);823 setError(824 code === "NO_ENTRIES" ? "No entries found in this file." :825 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :826 "Import failed. Check the file format and try again."827 );828 } finally {829 setImporting(false);830 }831 }832833 return (834 <div class="peach-rt-csv">835 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>836 {importing() ? "importing…" : "open CSV file"}837 <input838 type="file"839 accept=".csv,.json,.peach,application/x-peach"840 style="display:none"841 onChange={handleFile}842 disabled={importing()}843 />844 </label>845 <p class="peach-rt-csv-sources">846 Imports from Bitwarden, 1Password, Proton Pass,<br />847 Chrome, Safari, and Edge.848 </p>849 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>850 <Show when={error()}>851 <p class="peach-rt-error">{error()}</p>852 </Show>853 </div>854 );855}856857858859type PhoneState = "waiting" | "connected" | "trust" | "error";860861function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {862 const [state, setState] = createSignal<PhoneState>("waiting");863 const [code, setCode] = createSignal<string | null>(null);864 const [qrUrl, setQrUrl] = createSignal<string | null>(null);865 const [expired, setExpired] = createSignal(false);866 const [deviceName, setDeviceName] = createSignal<string | null>(null);867 const [accepting, setAccepting] = createSignal(false);868 const [serverError, setServerError] = createSignal<string | null>(null);869 let expireTimer: ReturnType<typeof setTimeout> | null = null;870871 function clearExpire() {872 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }873 }874875 async function startPairing() {876 setExpired(false);877 setCode(null);878 setQrUrl(null);879 setServerError(null);880 clearExpire();881 const res = await sendMessage({ type: "START_PAIRING_SERVER" });882 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };883 if (!r.ok || !r.data) {884 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");885 return;886 }887 const token = r.data.token;888 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;889 setCode(formatted);890 const qrData = `peach://handoff?token=${token}`;891 try {892 const QRCode = (await import("qrcode")).default;893 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });894 setQrUrl(url);895 } catch {896 897 }898 const remaining = Math.max(0, r.data.expiresAt - Date.now());899 expireTimer = setTimeout(() => {900 setCode(null);901 setQrUrl(null);902 setExpired(true);903 }, remaining);904 }905906 function handleRuntimeMsg(msg: unknown) {907 const m = msg as { type?: string; payload?: { deviceName?: string } };908 if (m?.type === "PAIRING_COMPLETED") {909 clearExpire();910 setDeviceName(m.payload?.deviceName ?? "your phone");911 setState("trust");912 }913 }914915 onMount(() => {916 void startPairing();917 918 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {919 chrome.runtime.onMessage.addListener(handleRuntimeMsg);920 }921 });922923 onCleanup(() => {924 clearExpire();925 926 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {927 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);928 }929 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});930 });931932 async function handleAccept(trust: boolean) {933 setAccepting(true);934 if (trust) {935 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});936 }937 await props.onSuccess();938 }939940 return (941 <div class="peach-rt-phone">942 <Show when={state() === "waiting"}>943 <Show when={!expired()}>944 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>945 <p class="peach-rt-phone-code">{code()}</p>946 <Show when={qrUrl() !== null}>947 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />948 </Show>949 <p class="peach-rt-phone-hint">950 Open Peach on your phone and tap<br />951 Settings → Send to desktop.<br />952 Same WiFi required.953 </p>954 </Show>955 <Show when={serverError()}>956 <p class="peach-rt-error">{serverError()}</p>957 </Show>958 </Show>959 <Show when={expired()}>960 <p class="peach-rt-phone-hint">Code expired.</p>961 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>962 Generate new code963 </button>964 </Show>965 </Show>966967 <Show when={state() === "connected"}>968 <p class="peach-rt-phone-hint">969 Connected to {deviceName() ?? "your phone"}.<br />970 Receiving vault…971 </p>972 </Show>973974 <Show when={state() === "trust"}>975 <p class="peach-rt-phone-hint" style="margin-bottom:10px">976 Vault received from {deviceName() ?? "your phone"}.<br />977 Trust this network for sync?978 </p>979 <button980 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"981 onClick={() => void handleAccept(true)}982 disabled={accepting()}983 >984 Yes, trust this network985 </button>986 <button987 class="peach-btn peach-btn--ghost peach-btn--sm"988 style="margin-top:6px"989 onClick={() => void handleAccept(false)}990 disabled={accepting()}991 >992 Not now993 </button>994 </Show>995996 <Show when={state() === "error"}>997 <p class="peach-rt-phone-hint">998 Could not connect. Check that both devices<br />999 are on the same WiFi network.1000 </p>1001 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1002 Try again1003 </button>1004 </Show>1005 </div>1006 );1007}1008100910101011function getDisplayError(error: string | undefined, fallback: string): string {1012 if (!error) return fallback;1013 if (1014 error === "SERVICE_WORKER_UNAVAILABLE" ||1015 error.includes("Receiving end does not exist") ||1016 error.includes("message port closed")1017 ) {1018 return "background service unavailable";1019 }1020 return error;1021}