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 try {158 await chrome.storage.session?.set({ peach_recovery_pending: "1" });159 } catch { }160 setShowRecoveryWords(true);161 setLoading(false);162 return;163 }164 setError(getDisplayError(result.error, "failed to create vault"));165 setLoading(false);166 }167168 function openRestore(surface: Exclude<RestoreSurface, null>) {169 if (!props.ready || loading()) return;170 setPassword("");171 setConfirmPassword("");172 setError("");173 setRestoreSurface(surface);174 }175176 177 return (178 <Switch>179 {}180 <Match when={restoreSurface() === "restore"}>181 <RestoreTabView182 hasVault={props.hasVault}183 onBack={() => setRestoreSurface(null)}184 onUnlock={async () => {185 setRestoreSurface(null);186 await props.onUnlock();187 }}188 />189 </Match>190191 {}192 <Match when={showRecoveryWords() && !props.hasVault}>193 <RecoveryWordDisplayView194 onDone={props.onUnlock}195 />196 </Match>197198 {}199 <Match when={recoverSurface() === "words"}>200 <RecoveryWordEntryView201 onCancel={() => setRecoverSurface(null)}202 onRecoveryComplete={props.onUnlock}203 />204 </Match>205206 {}207 <Match when={props.hasVault}>208 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">209 <header class="peach-screen-header lock-screen-header">210 <div class="peach-wordmark-stack">211 <PeachWordmark class="peach-wordmark-wrap" />212 <Show when={props.isPro}>213 <span class="peach-pro-mini-badge">pro</span>214 </Show>215 </div>216 </header>217218 <div class="peach-lock-hero peach-lock-hero--unlock">219 <div class="peach-lock-hero-art" aria-hidden="true">220 <PeachMark class="peach-lock-hero-mark" />221 </div>222 <span class="peach-lock-eyebrow">vault locked</span>223 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">224 unlock225 <br />226 your vault.227 </h1>228 </div>229230 <LockRuneCanvas />231232 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">233 <label class="peach-label" for="unlock-password">234 master password235 </label>236 <input237 id="unlock-password"238 class="peach-underline-input peach-unlock-input lock-password-input"239 type="password"240 placeholder="••••••••••••"241 autocomplete="current-password"242 value={password()}243 disabled={!props.ready}244 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}245 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}246 onKeyDown={(event) => {247 if (event.key !== "Enter" || !props.ready) return;248 void handleUnlock();249 }}250 />251252 <Show when={error()}>253 <p class="peach-hint peach-hint-error">{error()}</p>254 </Show>255256 <button257 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"258 onClick={() => void handleUnlock()}259 disabled={!props.ready || loading()}260 >261 {loading() ? "unlocking..." : "unlock"}262 </button>263264 <button265 class="peach-lock-recover-link"266 type="button"267 onClick={() => setRecoverSurface("words")}268 >269 forgot your password?270 </button>271 </section>272 </section>273 </Match>274275 {}276 <Match when={true}>277 <section278 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"279 data-motion-phase="enter"280 >281 <header class="peach-screen-header peach-newvault-header">282 <Show when={props.onBackToOnboarding}>283 <button284 class="peach-newvault-back"285 type="button"286 onClick={() => props.onBackToOnboarding?.()}287 aria-label="Back to onboarding"288 >289 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">290 <path d="M7 2L3 5.5L7 9" />291 </svg>292 back293 </button>294 </Show>295 <div class="peach-wordmark-stack">296 <PeachWordmark class="peach-wordmark-wrap" />297 <Show when={props.isPro}>298 <span class="peach-pro-mini-badge">pro</span>299 </Show>300 </div>301 </header>302303 <div class="peach-newvault-mark" aria-hidden="true">304 <PeachMark class="peach-newvault-mark-art" />305 </div>306307 <div class="peach-newvault-card">308 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>309 <hr class="peach-newvault-divider" />310311 <div class="peach-newvault-field">312 <div class="peach-newvault-label-row">313 <label class="peach-label" for="master-password">314 master password315 </label>316 <button317 class="peach-lock-reveal-btn"318 type="button"319 onClick={() => setShowPassword((value) => !value)}320 aria-label={showPassword() ? "Hide password" : "Show password"}321 >322 <Show323 when={showPassword()}324 fallback={325 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">326 <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" />327 <circle cx="7" cy="5" r="1.75" />328 </svg>329 }330 >331 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">332 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />333 <path d="M3.5 5.5L2.5 7.5" />334 <path d="M7 6.5V8.5" />335 <path d="M10.5 5.5L11.5 7.5" />336 </svg>337 </Show>338 {showPassword() ? "hide" : "show"}339 </button>340 </div>341 <input342 id="master-password"343 class="peach-underline-input peach-newvault-input lock-password-input"344 type={showPassword() ? "text" : "password"}345 placeholder="••••••••••••"346 autocomplete="new-password"347 value={password()}348 disabled={!props.ready}349 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}350 onKeyDown={(event) => {351 if (event.key !== "Enter" || !props.ready) return;352 void handleCreate();353 }}354 />355 <Show356 when={password().length > 0 && passwordStrength() !== null}357 fallback={358 <p class="peach-newvault-hint">at least 12 characters</p>359 }360 >361 <div class="peach-password-strength">362 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>363 <div class="peach-strength-fill"></div>364 </div>365 <span class="peach-strength-label">{passwordStrength()}</span>366 </div>367 </Show>368 </div>369370 <div class="peach-newvault-field">371 <label class="peach-label" for="confirm-password">372 confirm password373 </label>374 <input375 id="confirm-password"376 class="peach-underline-input peach-newvault-input"377 type={showPassword() ? "text" : "password"}378 placeholder="••••••••••••"379 autocomplete="new-password"380 value={confirmPassword()}381 disabled={!props.ready}382 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}383 onKeyDown={(event) => {384 if (event.key === "Enter" && props.ready) void handleCreate();385 }}386 />387 </div>388389 <Show when={error()}>390 <p class="peach-hint peach-hint-error">{error()}</p>391 </Show>392393 <button394 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"395 onClick={() => void handleCreate()}396 disabled={!props.ready || loading() || !canCreate()}397 aria-live="polite"398 >399 <Show400 when={!canCreate() && createBlockerMessage()}401 fallback={loading() ? "creating..." : "create vault"}402 >403 {createBlockerMessage()}404 </Show>405 </button>406407 <p class="peach-newvault-warning">408 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>409 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>410 </p>411 </div>412413 <div class="peach-newvault-footer">414 <p class="peach-hint">Already have a backup?</p>415 <button416 class="peach-lock-restore-link"417 onClick={() => void openRestore("restore")}418 disabled={!props.ready}419 >420 › restore from backup421 </button>422 </div>423 </section>424 </Match>425 </Switch>426 );427}428429430431type RestoreTab = "symbols" | "csv" | "phone";432433function tabLabel(tab: RestoreTab): string {434 return (435 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]436 );437}438439function RestoreTabView(props: {440 hasVault: boolean;441 onBack: () => void;442 onUnlock: () => void | Promise<void>;443}) {444 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");445 const [showIntro, setShowIntro] = createSignal(false);446 const [pendingUnlock, setPendingUnlock] = createSignal(false);447448 async function handleRestoreSuccess() {449 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);450 if (!seen) {451 setShowIntro(true);452 } else {453 await props.onUnlock();454 }455 }456457 async function dismissIntro() {458 await storageSet("peach_shown_restore_intro", "true").catch(() => {});459 setShowIntro(false);460 await props.onUnlock();461 }462463 return (464 <section465 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"466 data-motion-phase="enter"467 >468 <RuneBackground opacity={0.05} />469470 <header class="peach-screen-header lock-screen-header">471 <PeachWordmark class="peach-wordmark-wrap" />472 <button class="peach-lock-restore-link" onClick={props.onBack}>473 ‹ back474 </button>475 </header>476477 <div class="peach-newvault-card peach-restore-tabcard">478 <div class="peach-restore-tabs" role="tablist">479 <For each={["symbols", "csv", "phone"] as RestoreTab[]}>480 {(tab) => (481 <button482 class="peach-restore-tab"483 classList={{ "is-active": activeTab() === tab }}484 onClick={() => setActiveTab(tab)}485 role="tab"486 aria-selected={activeTab() === tab}487 >488 {tabLabel(tab)}489 </button>490 )}491 </For>492 </div>493 <div class="peach-restore-content">494 <Show when={activeTab() === "symbols"}>495 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />496 </Show>497 <Show when={activeTab() === "csv"}>498 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />499 </Show>500 <Show when={activeTab() === "phone"}>501 <RestoreFromPhone onSuccess={handleRestoreSuccess} />502 </Show>503 </div>504 </div>505506 {}507 <Show when={showIntro()}>508 <div class="peach-restore-intro-overlay">509 <div class="peach-restore-intro-card">510 <p class="peach-restore-intro-headline">welcome to peach.</p>511 <div class="peach-restore-intro-body">512 <p>Your vault lives only on this device. No server has a copy.</p>513 <p>The inline bubble fills passwords automatically when you visit sites.</p>514 <p>Export your vault from Settings before switching devices.</p>515 </div>516 <button517 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"518 onClick={() => void dismissIntro()}519 disabled={pendingUnlock()}520 >521 got it522 </button>523 </div>524 </div>525 </Show>526 </section>527 );528}529530531532function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {533 const [scriptInput, setScriptInput] = createSignal("");534 const [backupPass, setBackupPass] = createSignal("");535 const [working, setWorking] = createSignal(false);536 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);537 const [error, setError] = createSignal<string | null>(null);538 let canvasRef: HTMLCanvasElement | undefined;539 let animFrameId = 0;540 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];541542 const scriptValid = createMemo(() => isPeachScript(scriptInput()));543544 function stopAnim() {545 cancelAnimationFrame(animFrameId);546 animFrameId = 0;547 }548549 function startChaos(canvas: HTMLCanvasElement) {550 const ctx = canvas.getContext("2d")!;551 const W = canvas.width;552 const H = canvas.height;553 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>554 String.fromCodePoint(0xe000 + i)555 );556 if (runes.length === 0) {557 runes = Array.from({ length: 60 }, () => ({558 x: Math.random() * W,559 y: Math.random() * H,560 vx: (Math.random() - 0.5) * 3,561 vy: (Math.random() - 0.5) * 3,562 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,563 size: 14 + Math.random() * 20,564 angle: Math.random() * Math.PI * 2,565 va: (Math.random() - 0.5) * 0.1,566 }));567 }568569 function draw() {570 ctx.clearRect(0, 0, W, H);571 for (const r of runes) {572 r.x = (r.x + r.vx + W) % W;573 r.y = (r.y + r.vy + H) % H;574 r.angle += r.va;575 ctx.save();576 ctx.translate(r.x, r.y);577 ctx.rotate(r.angle);578 ctx.font = `${r.size}px PeachScript, monospace`;579 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);580 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;581 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";582 ctx.shadowBlur = 8;583 ctx.fillText(r.char, 0, 0);584 ctx.restore();585 }586 animFrameId = requestAnimationFrame(draw);587 }588 draw();589 }590591 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {592 stopAnim();593 const ctx = canvas.getContext("2d")!;594 const W = canvas.width;595 const H = canvas.height;596 const cols = 8;597 const rows = 6;598 const cellW = W / cols;599 const cellH = H / rows;600 const targets = runes.slice(0, cols * rows).map((_, i) => ({601 x: (i % cols) * cellW + cellW / 2,602 y: Math.floor(i / cols) * cellH + cellH / 2,603 }));604 const start = performance.now();605 const DURATION = 400;606607 function drawSnap(now: number) {608 const t = Math.min((now - start) / DURATION, 1);609 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;610 ctx.clearRect(0, 0, W, H);611 runes.slice(0, targets.length).forEach((r, i) => {612 const tx = targets[i]!.x;613 const ty = targets[i]!.y;614 const cx = r.x + (tx - r.x) * ease;615 const cy = r.y + (ty - r.y) * ease;616 ctx.font = `16px PeachScript, monospace`;617 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;618 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";619 ctx.shadowBlur = 6;620 ctx.fillText(r.char, cx - 8, cy + 8);621 });622 if (t < 1) {623 animFrameId = requestAnimationFrame(drawSnap);624 } else {625 onDone();626 }627 }628 animFrameId = requestAnimationFrame(drawSnap);629 }630631 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {632 stopAnim();633 const ctx = canvas.getContext("2d")!;634 const W = canvas.width;635 const H = canvas.height;636 const start = performance.now();637 const DURATION = 900;638639 function drawBloom(now: number) {640 const t = Math.min((now - start) / DURATION, 1);641 ctx.clearRect(0, 0, W, H);642 643 const runeAlpha = 1 - Math.min(t / 0.4, 1);644 if (runeAlpha > 0) {645 for (const r of runes.slice(0, 48)) {646 ctx.font = `16px PeachScript, monospace`;647 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;648 ctx.fillText(r.char, r.x - 8, r.y + 8);649 }650 }651 652 const markT = Math.max(0, (t - 0.2) / 0.5);653 const markScale = Math.min(markT * 1.2, 1);654 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;655 if (markAlpha > 0) {656 ctx.save();657 ctx.globalAlpha = markAlpha;658 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;659 ctx.shadowBlur = 30;660 ctx.translate(W / 2, H / 2);661 ctx.scale(markScale, markScale);662 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);663 ctx.restore();664 }665 if (t < 1) {666 animFrameId = requestAnimationFrame(drawBloom);667 } else {668 onDone();669 }670 }671 animFrameId = requestAnimationFrame(drawBloom);672 }673674 onCleanup(() => stopAnim());675676 async function handleRestore() {677 if (!scriptValid() || !backupPass().trim() || working()) return;678 setError(null);679 setWorking(true);680 setAnimating("chaos");681682 const canvas = canvasRef;683 if (canvas) startChaos(canvas);684685 try {686 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));687 const payload = await parsePayload(bytes, backupPass());688689 if (!props.hasVault) {690 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });691 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");692 }693694 const rr = await sendMessage({695 type: "RESTORE_ENTRIES",696 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },697 });698 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");699700 if (payload.license?.key) {701 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});702 }703704 setAnimating("snap");705 stopAnim();706 if (canvas) {707 snapToGrid(canvas, () => {708 setAnimating("bloom");709 bloom(canvas, async () => {710 setAnimating(null);711 await props.onSuccess();712 });713 });714 } else {715 await props.onSuccess();716 }717 } catch (err) {718 stopAnim();719 setAnimating(null);720 const code = err instanceof Error ? err.message : String(err);721 setError(722 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"723 ? "That passphrase didn't work."724 : code === "CREATE_VAULT_FAILED"725 ? "Couldn't create a new local vault."726 : "Could not read this backup.",727 );728 } finally {729 setWorking(false);730 }731 }732733 return (734 <div class="peach-rt-symbols">735 <Show when={animating() !== null}>736 <canvas737 ref={(el) => { canvasRef = el; }}738 class="peach-rt-anim-canvas"739 width={280}740 height={200}741 />742 </Show>743 <Show when={animating() === null}>744 <textarea745 class="peach-restore-symbols-textarea"746 placeholder="paste peachscript…"747 rows={3}748 value={scriptInput()}749 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}750 />751 <div class="peach-rt-divider">— or —</div>752 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">753 open .peach file754 <input755 type="file"756 accept=".peach,text/plain"757 style="display:none"758 onChange={async (e) => {759 const file = (e.currentTarget as HTMLInputElement).files?.[0];760 if (!file) return;761 try {762 setScriptInput(await file.text());763 } catch {764 setError("Couldn't read file.");765 } finally {766 (e.currentTarget as HTMLInputElement).value = "";767 }768 }}769 />770 </label>771 <input772 class="peach-restore-symbols-pass"773 type="password"774 placeholder="backup password"775 value={backupPass()}776 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}777 onKeyDown={(e) => {778 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {779 e.preventDefault();780 void handleRestore();781 }782 }}783 />784 <button785 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"786 onClick={() => void handleRestore()}787 disabled={!scriptValid() || !backupPass().trim() || working()}788 >789 restore vault →790 </button>791 <Show when={error()}>792 <p class="peach-rt-error">{error()}</p>793 </Show>794 </Show>795 </div>796 );797}798799800801function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {802 const [importing, setImporting] = createSignal(false);803 const [error, setError] = createSignal<string | null>(null);804805 async function handleFile(e: Event) {806 const input = e.currentTarget as HTMLInputElement;807 const file = input.files?.[0];808 input.value = "";809 if (!file) return;810 setImporting(true);811 setError(null);812 try {813 const text = await file.text();814 const parsed = await parseImportSource(text);815 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");816817 if (props.hasVault === false) {818 819 820 821 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });822 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");823 }824825 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });826 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");827 if (parsed.licenseKey) {828 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});829 }830 await props.onSuccess();831 } catch (err) {832 const code = err instanceof Error ? err.message : String(err);833 setError(834 code === "NO_ENTRIES" ? "No entries found in this file." :835 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :836 "Import failed. Check the file format and try again."837 );838 } finally {839 setImporting(false);840 }841 }842843 return (844 <div class="peach-rt-csv">845 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>846 {importing() ? "importing…" : "open csv file"}847 <input848 type="file"849 accept=".csv,.json,.peach,application/x-peach"850 style="display:none"851 onChange={handleFile}852 disabled={importing()}853 />854 </label>855 <p class="peach-rt-csv-sources">856 Imports from Bitwarden, 1Password, Proton Pass,<br />857 Chrome, Safari, and Edge.858 </p>859 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>860 <Show when={error()}>861 <p class="peach-rt-error">{error()}</p>862 </Show>863 </div>864 );865}866867868869type PhoneState = "waiting" | "connected" | "trust" | "error";870871function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {872 const [state, setState] = createSignal<PhoneState>("waiting");873 const [code, setCode] = createSignal<string | null>(null);874 const [qrUrl, setQrUrl] = createSignal<string | null>(null);875 const [expired, setExpired] = createSignal(false);876 const [deviceName, setDeviceName] = createSignal<string | null>(null);877 const [accepting, setAccepting] = createSignal(false);878 const [serverError, setServerError] = createSignal<string | null>(null);879 let expireTimer: ReturnType<typeof setTimeout> | null = null;880881 function clearExpire() {882 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }883 }884885 async function startPairing() {886 setExpired(false);887 setCode(null);888 setQrUrl(null);889 setServerError(null);890 clearExpire();891 const res = await sendMessage({ type: "START_PAIRING_SERVER" });892 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };893 if (!r.ok || !r.data) {894 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");895 return;896 }897 const token = r.data.token;898 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;899 setCode(formatted);900 const qrData = `peach://handoff?token=${token}`;901 try {902 const QRCode = (await import("qrcode")).default;903 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });904 setQrUrl(url);905 } catch {906 907 }908 const remaining = Math.max(0, r.data.expiresAt - Date.now());909 expireTimer = setTimeout(() => {910 setCode(null);911 setQrUrl(null);912 setExpired(true);913 }, remaining);914 }915916 function handleRuntimeMsg(msg: unknown) {917 const m = msg as { type?: string; payload?: { deviceName?: string } };918 if (m?.type === "PAIRING_COMPLETED") {919 clearExpire();920 setDeviceName(m.payload?.deviceName ?? "your phone");921 setState("trust");922 }923 }924925 onMount(() => {926 void startPairing();927 928 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {929 chrome.runtime.onMessage.addListener(handleRuntimeMsg);930 }931 });932933 onCleanup(() => {934 clearExpire();935 936 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {937 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);938 }939 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});940 });941942 async function handleAccept(trust: boolean) {943 setAccepting(true);944 if (trust) {945 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});946 }947 await props.onSuccess();948 }949950 return (951 <div class="peach-rt-phone">952 <Show when={state() === "waiting"}>953 <Show when={!expired()}>954 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>955 <p class="peach-rt-phone-code">{code()}</p>956 <Show when={qrUrl() !== null}>957 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />958 </Show>959 <p class="peach-rt-phone-hint">960 Open Peach on your phone and tap<br />961 Settings → Send to desktop.<br />962 Same WiFi required.963 </p>964 </Show>965 <Show when={serverError()}>966 <p class="peach-rt-error">{serverError()}</p>967 </Show>968 </Show>969 <Show when={expired()}>970 <p class="peach-rt-phone-hint">Code expired.</p>971 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>972 Generate new code973 </button>974 </Show>975 </Show>976977 <Show when={state() === "connected"}>978 <p class="peach-rt-phone-hint">979 Connected to {deviceName() ?? "your phone"}.<br />980 Receiving vault…981 </p>982 </Show>983984 <Show when={state() === "trust"}>985 <p class="peach-rt-phone-hint" style="margin-bottom:10px">986 Vault received from {deviceName() ?? "your phone"}.<br />987 Trust this network for sync?988 </p>989 <button990 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"991 onClick={() => void handleAccept(true)}992 disabled={accepting()}993 >994 Yes, trust this network995 </button>996 <button997 class="peach-btn peach-btn--ghost peach-btn--sm"998 style="margin-top:6px"999 onClick={() => void handleAccept(false)}1000 disabled={accepting()}1001 >1002 Not now1003 </button>1004 </Show>10051006 <Show when={state() === "error"}>1007 <p class="peach-rt-phone-hint">1008 Could not connect. Check that both devices<br />1009 are on the same WiFi network.1010 </p>1011 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1012 Try again1013 </button>1014 </Show>1015 </div>1016 );1017}1018101910201021function getDisplayError(error: string | undefined, fallback: string): string {1022 if (!error) return fallback;1023 if (1024 error === "SERVICE_WORKER_UNAVAILABLE" ||1025 error.includes("Receiving end does not exist") ||1026 error.includes("message port closed")1027 ) {1028 return "background service unavailable";1029 }1030 return error;1031}