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 52 53 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(54 new URLSearchParams(location.search).has("detached") ? "restore" : null55 );56 const [showPassword, setShowPassword] = createSignal(false);57 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);58 59 60 const [showRecoveryWords, setShowRecoveryWords] = createSignal(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 setShowRecoveryWords(true);155 setLoading(false);156 return;157 }158 setError(getDisplayError(result.error, "failed to create vault"));159 setLoading(false);160 }161162 async function openRestore(surface: Exclude<RestoreSurface, null>) {163 if (!props.ready || loading()) return;164 setPassword("");165 setConfirmPassword("");166 setError("");167168 169 170 171 172 const isDetached = new URLSearchParams(location.search).has("detached");173 if (!isDetached) {174 try {175 const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";176 await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });177 window.close();178 return;179 } catch {180 181 }182 }183184 setRestoreSurface(surface);185 }186187 188 return (189 <Switch>190 {}191 <Match when={restoreSurface() === "restore"}>192 <RestoreTabView193 hasVault={props.hasVault}194 isPro={props.isPro ?? false}195 onBack={() => setRestoreSurface(null)}196 onUnlock={async () => {197 setRestoreSurface(null);198 await props.onUnlock();199 }}200 />201 </Match>202203 {}204 <Match when={showRecoveryWords() && !props.hasVault}>205 <RecoveryWordDisplayView206 onDone={props.onUnlock}207 />208 </Match>209210 {}211 <Match when={props.hasVault}>212 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">213 <header class="peach-screen-header lock-screen-header">214 <div class="peach-wordmark-stack">215 <PeachWordmark class="peach-wordmark-wrap" />216 <Show when={props.isPro}>217 <span class="peach-pro-mini-badge">pro</span>218 </Show>219 </div>220 </header>221222 <div class="peach-lock-hero peach-lock-hero--unlock">223 <div class="peach-lock-hero-art" aria-hidden="true">224 <PeachMark class="peach-lock-hero-mark" />225 </div>226 <span class="peach-lock-eyebrow">vault locked</span>227 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">228 unlock229 <br />230 your vault.231 </h1>232 </div>233234 <LockRuneCanvas />235236 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">237 <label class="peach-label" for="unlock-password">238 master password239 </label>240 <input241 id="unlock-password"242 class="peach-underline-input peach-unlock-input lock-password-input"243 type="password"244 placeholder="••••••••••••"245 autocomplete="current-password"246 value={password()}247 disabled={!props.ready}248 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}249 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}250 onKeyDown={(event) => {251 if (event.key !== "Enter" || !props.ready) return;252 void handleUnlock();253 }}254 />255256 <Show when={error()}>257 <p class="peach-hint peach-hint-error">{error()}</p>258 </Show>259260 <button261 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"262 onClick={() => void handleUnlock()}263 disabled={!props.ready || loading()}264 >265 {loading() ? "unlocking..." : "unlock"}266 </button>267268 <button269 class="peach-lock-recover-link"270 type="button"271 onClick={() => setRecoverSurface("words")}272 >273 forgot your password?274 </button>275 </section>276 </section>277 </Match>278279 {}280 <Match when={recoverSurface() === "words"}>281 <RecoveryWordEntryView282 onCancel={() => setRecoverSurface(null)}283 onRecoveryComplete={props.onUnlock}284 />285 </Match>286287 {}288 <Match when={true}>289 <section290 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"291 data-motion-phase="enter"292 >293 <header class="peach-screen-header peach-newvault-header">294 <Show when={props.onBackToOnboarding}>295 <button296 class="peach-newvault-back"297 type="button"298 onClick={() => props.onBackToOnboarding?.()}299 aria-label="Back to onboarding"300 >301 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">302 <path d="M7 2L3 5.5L7 9" />303 </svg>304 back305 </button>306 </Show>307 <div class="peach-wordmark-stack">308 <PeachWordmark class="peach-wordmark-wrap" />309 <Show when={props.isPro}>310 <span class="peach-pro-mini-badge">pro</span>311 </Show>312 </div>313 </header>314315 <div class="peach-newvault-mark" aria-hidden="true">316 <PeachMark class="peach-newvault-mark-art" />317 </div>318319 <div class="peach-newvault-card">320 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>321 <hr class="peach-newvault-divider" />322323 <div class="peach-newvault-field">324 <div class="peach-newvault-label-row">325 <label class="peach-label" for="master-password">326 master password327 </label>328 <button329 class="peach-lock-reveal-btn"330 type="button"331 onClick={() => setShowPassword((value) => !value)}332 aria-label={showPassword() ? "Hide password" : "Show password"}333 >334 <Show335 when={showPassword()}336 fallback={337 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">338 <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" />339 <circle cx="7" cy="5" r="1.75" />340 </svg>341 }342 >343 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">344 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />345 <path d="M3.5 5.5L2.5 7.5" />346 <path d="M7 6.5V8.5" />347 <path d="M10.5 5.5L11.5 7.5" />348 </svg>349 </Show>350 {showPassword() ? "hide" : "show"}351 </button>352 </div>353 <input354 id="master-password"355 class="peach-underline-input peach-newvault-input lock-password-input"356 type={showPassword() ? "text" : "password"}357 placeholder="••••••••••••"358 autocomplete="new-password"359 value={password()}360 disabled={!props.ready}361 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}362 onKeyDown={(event) => {363 if (event.key !== "Enter" || !props.ready) return;364 void handleCreate();365 }}366 />367 <Show368 when={password().length > 0 && passwordStrength() !== null}369 fallback={370 <p class="peach-newvault-hint">at least 12 characters</p>371 }372 >373 <div class="peach-password-strength">374 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>375 <div class="peach-strength-fill"></div>376 </div>377 <span class="peach-strength-label">{passwordStrength()}</span>378 </div>379 </Show>380 </div>381382 <div class="peach-newvault-field">383 <label class="peach-label" for="confirm-password">384 confirm password385 </label>386 <input387 id="confirm-password"388 class="peach-underline-input peach-newvault-input"389 type={showPassword() ? "text" : "password"}390 placeholder="••••••••••••"391 autocomplete="new-password"392 value={confirmPassword()}393 disabled={!props.ready}394 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}395 onKeyDown={(event) => {396 if (event.key === "Enter" && props.ready) void handleCreate();397 }}398 />399 </div>400401 <Show when={error()}>402 <p class="peach-hint peach-hint-error">{error()}</p>403 </Show>404405 <button406 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"407 onClick={() => void handleCreate()}408 disabled={!props.ready || loading() || !canCreate()}409 aria-live="polite"410 >411 <Show412 when={!canCreate() && createBlockerMessage()}413 fallback={loading() ? "creating..." : "create vault"}414 >415 {createBlockerMessage()}416 </Show>417 </button>418419 <p class="peach-newvault-warning">420 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>421 <span>If you forget it, your vault cannot be opened. There is no reset.</span>422 </p>423 </div>424425 <div class="peach-newvault-footer">426 <p class="peach-hint">Already have a backup?</p>427 <button428 class="peach-lock-restore-link"429 onClick={() => void openRestore("restore")}430 disabled={!props.ready}431 >432 › restore from backup433 </button>434 </div>435 </section>436 </Match>437 </Switch>438 );439}440441442443type RestoreTab = "symbols" | "mayan" | "csv" | "phone";444445function tabLabel(tab: RestoreTab): string {446 return (447 { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]448 );449}450451function RestoreTabView(props: {452 hasVault: boolean;453 isPro: boolean;454 onBack: () => void;455 onUnlock: () => void | Promise<void>;456}) {457 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");458 const [showIntro, setShowIntro] = createSignal(false);459 const [pendingUnlock, setPendingUnlock] = createSignal(false);460461 const isDetachedWindow = new URLSearchParams(location.search).has("detached");462463 async function reattachPopup() {464 if (!isDetachedWindow) return;465 466 try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}467 window.close();468 }469470 async function handleRestoreSuccess() {471 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);472 if (!seen) {473 setShowIntro(true);474 } else {475 await props.onUnlock();476 await reattachPopup();477 }478 }479480 async function dismissIntro() {481 await storageSet("peach_shown_restore_intro", "true").catch(() => {});482 setShowIntro(false);483 await props.onUnlock();484 await reattachPopup();485 }486487 return (488 <section489 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"490 data-motion-phase="enter"491 >492 <RuneBackground opacity={0.05} />493494 <header class="peach-screen-header lock-screen-header">495 <PeachWordmark class="peach-wordmark-wrap" />496 <button class="peach-lock-restore-link" onClick={props.onBack}>497 ‹ back498 </button>499 </header>500501 <div class="peach-newvault-card peach-restore-tabcard">502 <div class="peach-restore-tabs" role="tablist">503 <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>504 {(tab) => (505 <button506 class="peach-restore-tab"507 classList={{ "is-active": activeTab() === tab }}508 onClick={() => setActiveTab(tab)}509 role="tab"510 aria-selected={activeTab() === tab}511 >512 {tabLabel(tab)}513 </button>514 )}515 </For>516 </div>517 <div class="peach-restore-content">518 <Show when={activeTab() === "symbols"}>519 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />520 </Show>521 <Show when={activeTab() === "mayan"}>522 <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />523 </Show>524 <Show when={activeTab() === "csv"}>525 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />526 </Show>527 <Show when={activeTab() === "phone"}>528 <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />529 </Show>530 </div>531 </div>532533 {}534 <Show when={showIntro()}>535 <div class="peach-restore-intro-overlay">536 <div class="peach-restore-intro-card">537 <p class="peach-restore-intro-headline">welcome to peach.</p>538 <div class="peach-restore-intro-body">539 <p>Your vault lives only on this device. No server has a copy.</p>540 <p>The inline bubble fills passwords automatically when you visit sites.</p>541 <p>Export your vault from Settings before switching devices.</p>542 </div>543 <button544 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"545 onClick={() => void dismissIntro()}546 disabled={pendingUnlock()}547 >548 got it549 </button>550 </div>551 </div>552 </Show>553 </section>554 );555}556557558559async function handleMayanDecode(base64: string): Promise<560 { ok: true; payload: Uint8Array } | { ok: false; reason: string }561> {562 const { decodeMayan } = await import("../../crypto/mayan");563 const img = new Image();564 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;565 await new Promise<void>((resolve, reject) => {566 img.onload = () => resolve();567 img.onerror = () => reject(new Error("Couldn't read the image."));568 img.src = dataUrl;569 });570 const canvas = document.createElement("canvas");571 572 const MAX_DIM = 1500;573 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));574 canvas.width = Math.round(img.width * scale);575 canvas.height = Math.round(img.height * scale);576 const ctx = canvas.getContext("2d");577 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };578 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);579 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);580 const payload = decodeMayan(imageData);581 if (!payload) {582 return {583 ok: false,584 reason: "Could not read this Mayan code. Try a clearer image.",585 };586 }587 return { ok: true, payload };588}589590591592function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {593 const [scriptInput, setScriptInput] = createSignal("");594 const [backupPass, setBackupPass] = createSignal("");595 const [working, setWorking] = createSignal(false);596 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);597 const [error, setError] = createSignal<string | null>(null);598 let canvasRef: HTMLCanvasElement | undefined;599 let animFrameId = 0;600 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];601602 const scriptValid = createMemo(() => isPeachScript(scriptInput()));603604 function stopAnim() {605 cancelAnimationFrame(animFrameId);606 animFrameId = 0;607 }608609 function startChaos(canvas: HTMLCanvasElement) {610 const ctx = canvas.getContext("2d")!;611 const W = canvas.width;612 const H = canvas.height;613 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>614 String.fromCodePoint(0xe000 + i)615 );616 if (runes.length === 0) {617 runes = Array.from({ length: 60 }, () => ({618 x: Math.random() * W,619 y: Math.random() * H,620 vx: (Math.random() - 0.5) * 3,621 vy: (Math.random() - 0.5) * 3,622 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,623 size: 14 + Math.random() * 20,624 angle: Math.random() * Math.PI * 2,625 va: (Math.random() - 0.5) * 0.1,626 }));627 }628629 function draw() {630 ctx.clearRect(0, 0, W, H);631 for (const r of runes) {632 r.x = (r.x + r.vx + W) % W;633 r.y = (r.y + r.vy + H) % H;634 r.angle += r.va;635 ctx.save();636 ctx.translate(r.x, r.y);637 ctx.rotate(r.angle);638 ctx.font = `${r.size}px PeachScript, monospace`;639 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);640 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;641 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";642 ctx.shadowBlur = 8;643 ctx.fillText(r.char, 0, 0);644 ctx.restore();645 }646 animFrameId = requestAnimationFrame(draw);647 }648 draw();649 }650651 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {652 stopAnim();653 const ctx = canvas.getContext("2d")!;654 const W = canvas.width;655 const H = canvas.height;656 const cols = 8;657 const rows = 6;658 const cellW = W / cols;659 const cellH = H / rows;660 const targets = runes.slice(0, cols * rows).map((_, i) => ({661 x: (i % cols) * cellW + cellW / 2,662 y: Math.floor(i / cols) * cellH + cellH / 2,663 }));664 const start = performance.now();665 const DURATION = 400;666667 function drawSnap(now: number) {668 const t = Math.min((now - start) / DURATION, 1);669 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;670 ctx.clearRect(0, 0, W, H);671 runes.slice(0, targets.length).forEach((r, i) => {672 const tx = targets[i]!.x;673 const ty = targets[i]!.y;674 const cx = r.x + (tx - r.x) * ease;675 const cy = r.y + (ty - r.y) * ease;676 ctx.font = `16px PeachScript, monospace`;677 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;678 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";679 ctx.shadowBlur = 6;680 ctx.fillText(r.char, cx - 8, cy + 8);681 });682 if (t < 1) {683 animFrameId = requestAnimationFrame(drawSnap);684 } else {685 onDone();686 }687 }688 animFrameId = requestAnimationFrame(drawSnap);689 }690691 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {692 stopAnim();693 const ctx = canvas.getContext("2d")!;694 const W = canvas.width;695 const H = canvas.height;696 const start = performance.now();697 const DURATION = 900;698699 function drawBloom(now: number) {700 const t = Math.min((now - start) / DURATION, 1);701 ctx.clearRect(0, 0, W, H);702 703 const runeAlpha = 1 - Math.min(t / 0.4, 1);704 if (runeAlpha > 0) {705 for (const r of runes.slice(0, 48)) {706 ctx.font = `16px PeachScript, monospace`;707 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;708 ctx.fillText(r.char, r.x - 8, r.y + 8);709 }710 }711 712 const markT = Math.max(0, (t - 0.2) / 0.5);713 const markScale = Math.min(markT * 1.2, 1);714 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;715 if (markAlpha > 0) {716 ctx.save();717 ctx.globalAlpha = markAlpha;718 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;719 ctx.shadowBlur = 30;720 ctx.translate(W / 2, H / 2);721 ctx.scale(markScale, markScale);722 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);723 ctx.restore();724 }725 if (t < 1) {726 animFrameId = requestAnimationFrame(drawBloom);727 } else {728 onDone();729 }730 }731 animFrameId = requestAnimationFrame(drawBloom);732 }733734 onCleanup(() => stopAnim());735736 async function handleRestore() {737 if (!scriptValid() || !backupPass().trim() || working()) return;738 setError(null);739 setWorking(true);740 setAnimating("chaos");741742 const canvas = canvasRef;743 if (canvas) startChaos(canvas);744745 try {746 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));747 const payload = await parsePayload(bytes, backupPass());748749 if (!props.hasVault) {750 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });751 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");752 }753754 const rr = await sendMessage({755 type: "RESTORE_ENTRIES",756 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },757 });758 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");759760 if (payload.license?.key) {761 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});762 }763764 setAnimating("snap");765 stopAnim();766 if (canvas) {767 snapToGrid(canvas, () => {768 setAnimating("bloom");769 bloom(canvas, async () => {770 setAnimating(null);771 await props.onSuccess();772 });773 });774 } else {775 await props.onSuccess();776 }777 } catch (err) {778 stopAnim();779 setAnimating(null);780 const code = err instanceof Error ? err.message : String(err);781 setError(782 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"783 ? "That passphrase didn't work."784 : code === "CREATE_VAULT_FAILED"785 ? "Couldn't create a new local vault."786 : "Could not read this backup.",787 );788 } finally {789 setWorking(false);790 }791 }792793 return (794 <div class="peach-rt-symbols">795 <Show when={animating() !== null}>796 <canvas797 ref={(el) => { canvasRef = el; }}798 class="peach-rt-anim-canvas"799 width={280}800 height={200}801 />802 </Show>803 <Show when={animating() === null}>804 <textarea805 class="peach-restore-symbols-textarea"806 placeholder="paste PeachScript here…"807 rows={3}808 value={scriptInput()}809 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}810 />811 <div class="peach-rt-divider">— or —</div>812 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">813 open .peach file814 <input815 type="file"816 accept=".peach,text/plain"817 style="display:none"818 onChange={async (e) => {819 const file = (e.currentTarget as HTMLInputElement).files?.[0];820 if (!file) return;821 try {822 setScriptInput(await file.text());823 } catch {824 setError("Couldn't read file.");825 } finally {826 (e.currentTarget as HTMLInputElement).value = "";827 }828 }}829 />830 </label>831 <input832 class="peach-restore-symbols-pass"833 type="password"834 placeholder="backup password"835 value={backupPass()}836 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}837 onKeyDown={(e) => {838 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {839 e.preventDefault();840 void handleRestore();841 }842 }}843 />844 <button845 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"846 onClick={() => void handleRestore()}847 disabled={!scriptValid() || !backupPass().trim() || working()}848 >849 restore vault →850 </button>851 <Show when={error()}>852 <p class="peach-rt-error">{error()}</p>853 </Show>854 </Show>855 </div>856 );857}858859860861function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {862 const [decoding, setDecoding] = createSignal(false);863 const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);864 const [entryLine, setEntryLine] = createSignal<string | null>(null);865 const [backupPass, setBackupPass] = createSignal("");866 const [restoring, setRestoring] = createSignal(false);867 const [error, setError] = createSignal<string | null>(null);868869 async function processFile(file: File) {870 setDecoding(true);871 setDecoded(null);872 setEntryLine(null);873 setError(null);874 try {875 const { decodeMayan } = await import("../../crypto/mayan");876 const bitmap = await createImageBitmap(file);877 const MAX_DIM = 1500;878 const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));879 const canvas = document.createElement("canvas");880 canvas.width = Math.round(bitmap.width * scale);881 canvas.height = Math.round(bitmap.height * scale);882 const ctx = canvas.getContext("2d")!;883 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);884 bitmap.close();885 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);886 const payload = decodeMayan(imageData);887 if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }888 setDecoded(payload);889 } catch {890 setError("Could not read this Mayan code. Try a clearer image.");891 } finally {892 setDecoding(false);893 }894 }895896 async function handleRestore() {897 const bytes = decoded();898 if (!bytes || !backupPass().trim() || restoring()) return;899 setRestoring(true);900 setError(null);901 try {902 const payload = await parsePayload(bytes, backupPass());903 const count = payload.entries.length;904 const date = new Date(905 Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))906 );907 const monthYear = date.getFullYear() > 2000908 ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })909 : null;910 setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);911912 if (!props.hasVault) {913 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });914 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");915 }916 const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });917 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");918 if (payload.license?.key) {919 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});920 }921 await props.onSuccess();922 } catch (err) {923 const code = err instanceof Error ? err.message : String(err);924 setError(925 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"926 ? "Wrong backup password."927 : code === "CREATE_VAULT_FAILED"928 ? "Couldn't create a new local vault."929 : "Restore failed. The backup may be corrupted.",930 );931 } finally {932 setRestoring(false);933 }934 }935936 return (937 <div class="peach-rt-mayan">938 <Show when={decoded() === null}>939 <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>940 <span class="peach-rt-upload-label">941 {decoding() ? "decoding…" : "tap to upload image"}942 </span>943 <input944 type="file"945 accept="image/*"946 style="display:none"947 onChange={(e) => {948 const file = (e.currentTarget as HTMLInputElement).files?.[0];949 if (file) void processFile(file);950 (e.currentTarget as HTMLInputElement).value = "";951 }}952 disabled={decoding()}953 />954 </label>955 <Show when={error()}>956 <p class="peach-rt-error">{error()}</p>957 </Show>958 </Show>959 <Show when={decoded() !== null}>960 <Show when={entryLine()}>961 <p class="peach-rt-entry-line">{entryLine()}</p>962 </Show>963 <input964 class="peach-restore-symbols-pass"965 type="password"966 placeholder="backup password"967 ref={(el) => queueMicrotask(() => el.focus())}968 value={backupPass()}969 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}970 onKeyDown={(e) => {971 if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }972 }}973 />974 <button975 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"976 onClick={() => void handleRestore()}977 disabled={!backupPass().trim() || restoring()}978 >979 {restoring() ? "restoring…" : "restore vault →"}980 </button>981 <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>982 ← try another image983 </button>984 <Show when={error()}>985 <p class="peach-rt-error">{error()}</p>986 </Show>987 </Show>988 </div>989 );990}991992993994function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {995 const [importing, setImporting] = createSignal(false);996 const [error, setError] = createSignal<string | null>(null);997998 async function handleFile(e: Event) {999 const input = e.currentTarget as HTMLInputElement;1000 const file = input.files?.[0];1001 input.value = "";1002 if (!file) return;1003 setImporting(true);1004 setError(null);1005 try {1006 const text = await file.text();1007 const parsed = await parseImportSource(text);1008 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10091010 if (props.hasVault === false) {1011 1012 1013 1014 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });1015 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1016 }10171018 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1019 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1020 if (parsed.licenseKey) {1021 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1022 }1023 await props.onSuccess();1024 } catch (err) {1025 const code = err instanceof Error ? err.message : String(err);1026 setError(1027 code === "NO_ENTRIES" ? "No entries found in this file." :1028 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1029 "Import failed. Check the file format and try again."1030 );1031 } finally {1032 setImporting(false);1033 }1034 }10351036 return (1037 <div class="peach-rt-csv">1038 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>1039 {importing() ? "importing…" : "open CSV file"}1040 <input1041 type="file"1042 accept=".csv,.json,.peach,application/x-peach"1043 style="display:none"1044 onChange={handleFile}1045 disabled={importing()}1046 />1047 </label>1048 <p class="peach-rt-csv-sources">1049 Imports from Bitwarden, 1Password, Proton Pass,<br />1050 Chrome, Safari, and Edge.1051 </p>1052 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1053 <Show when={error()}>1054 <p class="peach-rt-error">{error()}</p>1055 </Show>1056 </div>1057 );1058}1059106010611062type PhoneState = "waiting" | "connected" | "trust" | "error";10631064function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {1065 const [state, setState] = createSignal<PhoneState>("waiting");1066 const [code, setCode] = createSignal<string | null>(null);1067 const [qrUrl, setQrUrl] = createSignal<string | null>(null);1068 const [expired, setExpired] = createSignal(false);1069 const [deviceName, setDeviceName] = createSignal<string | null>(null);1070 const [accepting, setAccepting] = createSignal(false);1071 const [serverError, setServerError] = createSignal<string | null>(null);1072 let expireTimer: ReturnType<typeof setTimeout> | null = null;10731074 function clearExpire() {1075 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }1076 }10771078 async function startPairing() {1079 setExpired(false);1080 setCode(null);1081 setQrUrl(null);1082 setServerError(null);1083 clearExpire();1084 const res = await sendMessage({ type: "START_PAIRING_SERVER" });1085 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };1086 if (!r.ok || !r.data) {1087 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");1088 return;1089 }1090 const token = r.data.token;1091 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1092 setCode(formatted);1093 const qrData = `peach://handoff?token=${token}`;1094 try {1095 const QRCode = (await import("qrcode")).default;1096 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1097 setQrUrl(url);1098 } catch {1099 1100 }1101 const remaining = Math.max(0, r.data.expiresAt - Date.now());1102 expireTimer = setTimeout(() => {1103 setCode(null);1104 setQrUrl(null);1105 setExpired(true);1106 }, remaining);1107 }11081109 function handleRuntimeMsg(msg: unknown) {1110 const m = msg as { type?: string; payload?: { deviceName?: string } };1111 if (m?.type === "PAIRING_COMPLETED") {1112 clearExpire();1113 setDeviceName(m.payload?.deviceName ?? "your phone");1114 setState("trust");1115 }1116 }11171118 onMount(() => {1119 if (!props.isPro) return;1120 void startPairing();1121 1122 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1123 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1124 }1125 });11261127 onCleanup(() => {1128 clearExpire();1129 1130 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1131 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1132 }1133 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1134 });11351136 async function handleAccept(trust: boolean) {1137 setAccepting(true);1138 if (trust) {1139 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1140 }1141 await props.onSuccess();1142 }11431144 if (!props.isPro) {1145 return (1146 <div class="peach-rt-phone">1147 <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1148 <button1149 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1150 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1151 >1152 Get Peach Pro1153 </button>1154 <button1155 class="peach-btn peach-btn--ghost peach-btn--sm"1156 style="margin-top:6px"1157 onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1158 >1159 I already have a license1160 </button>1161 </div>1162 );1163 }11641165 return (1166 <div class="peach-rt-phone">1167 <Show when={state() === "waiting"}>1168 <Show when={!expired()}>1169 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1170 <p class="peach-rt-phone-code">{code()}</p>1171 <Show when={qrUrl() !== null}>1172 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1173 </Show>1174 <p class="peach-rt-phone-hint">1175 Open Peach on your phone and tap<br />1176 Settings → Send to desktop.<br />1177 Same WiFi required.1178 </p>1179 </Show>1180 <Show when={serverError()}>1181 <p class="peach-rt-error">{serverError()}</p>1182 </Show>1183 </Show>1184 <Show when={expired()}>1185 <p class="peach-rt-phone-hint">Code expired.</p>1186 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1187 Generate new code1188 </button>1189 </Show>1190 </Show>11911192 <Show when={state() === "connected"}>1193 <p class="peach-rt-phone-hint">1194 Connected to {deviceName() ?? "your phone"}.<br />1195 Receiving vault…1196 </p>1197 </Show>11981199 <Show when={state() === "trust"}>1200 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1201 Vault received from {deviceName() ?? "your phone"}.<br />1202 Trust this network for sync?1203 </p>1204 <button1205 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1206 onClick={() => void handleAccept(true)}1207 disabled={accepting()}1208 >1209 Yes, trust this network1210 </button>1211 <button1212 class="peach-btn peach-btn--ghost peach-btn--sm"1213 style="margin-top:6px"1214 onClick={() => void handleAccept(false)}1215 disabled={accepting()}1216 >1217 Not now1218 </button>1219 </Show>12201221 <Show when={state() === "error"}>1222 <p class="peach-rt-phone-hint">1223 Could not connect. Check that both devices<br />1224 are on the same WiFi network.1225 </p>1226 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1227 Try again1228 </button>1229 </Show>1230 </div>1231 );1232}1233123412351236function getDisplayError(error: string | undefined, fallback: string): string {1237 if (!error) return fallback;1238 if (1239 error === "SERVICE_WORKER_UNAVAILABLE" ||1240 error.includes("Receiving end does not exist") ||1241 error.includes("message port closed")1242 ) {1243 return "background service unavailable";1244 }1245 return error;1246}