12345import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { buildPayload } from "../../crypto/peachPayload";9import { encodePeachScript } from "../../crypto/peachScript";10import { RuneCanvas } from "../components/RuneCanvas";11import {12 QR_MAX_BYTES,13 renderPeachQR,14 renderPeachQRDataURL,15} from "../../crypto/peachQR";16import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1718type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1920interface ExportProps {21 onBack: () => void;22}2324const MIN_BACKUP_PASSWORD = 8;2526export function ExportView(props: ExportProps) {27 const [view, setView] = createSignal<ExportSubview>("menu");28 return (29 <div class="peach-overlay-view ov-enter-right" style="display:flex">30 <Show when={view() === "menu"}>31 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />32 </Show>33 <Show when={view() === "peachscript"}>34 <PeachScriptExport onBack={() => setView("menu")} />35 </Show>36 <Show when={view() === "qr"}>37 <QRExport onBack={() => setView("menu")} />38 </Show>39 <Show when={view() === "csv"}>40 <CSVExport onBack={() => setView("menu")} />41 </Show>42 </div>43 );44}454647484950function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {51 return (52 <>53 <OverlayHeader title="export" onBack={props.onBack} />54 <div class="peach-settings-body peach-export-menu">55 <div class="peach-settings-group peach-export-card peach-export-card--primary">56 <p class="peach-settings-label">peachscript</p>57 <p class="peach-export-card-copy">58 Your vault as a string of Peach symbols you can paste into Peach59 on another device. Designed for Peach-to-Peach transfer.60 </p>61 <button62 class="peach-btn peach-btn--subtle peach-btn--sm"63 onClick={() => props.onPick("peachscript")}64 >65 Create PeachScript →66 </button>67 </div>6869 <div class="peach-settings-group peach-export-card">70 <p class="peach-settings-label">print backup</p>71 <p class="peach-export-card-copy">72 Save a QR code you can print and store. Scan it with Peach on any73 device to restore.74 </p>75 <button76 class="peach-btn peach-btn--subtle peach-btn--sm"77 onClick={() => props.onPick("qr")}78 >79 Save QR code →80 </button>81 </div>8283 <div class="peach-settings-group peach-export-card">84 <p class="peach-settings-label">CSV (leaving peach)</p>85 <p class="peach-export-card-copy">86 Plain-text export for moving into another password manager. Anyone87 with the file can read your passwords.88 </p>89 <button90 class="peach-btn peach-btn--ghost peach-btn--sm"91 onClick={() => props.onPick("csv")}92 >93 Export as CSV →94 </button>95 </div>96 </div>97 </>98 );99}100101102103104105function PeachScriptExport(props: { onBack: () => void }) {106 const [pass, setPass] = createSignal("");107 const [confirm, setConfirm] = createSignal("");108 const [working, setWorking] = createSignal(false);109 const [script, setScript] = createSignal<string | null>(null);110 const [error, setError] = createSignal<string | null>(null);111 const [entryCount, setEntryCount] = createSignal(0);112 const [stats, setStats] = createSignal<{113 cards: number;114 passkeys: number;115 notes: number;116 logins: number;117 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });118 const [copied, setCopied] = createSignal(false);119120 const passReady = createMemo(121 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()122 );123124 async function handleEncode() {125 setError(null);126 setScript(null);127 setCopied(false);128 if (!passReady()) {129 setError("Backup passwords don't match or are too short.");130 return;131 }132 setWorking(true);133 try {134 const env = await assembleEnvelope(pass());135 setEntryCount(env.entryCount);136 setStats(env.stats);137 setScript(encodePeachScript(env.bytes));138 } catch (err) {139 setError(err instanceof Error ? err.message : String(err));140 } finally {141 setWorking(false);142 }143 }144145 async function handleCopy() {146 const s = script();147 if (!s) return;148 try {149 await navigator.clipboard.writeText(s);150 setCopied(true);151 setTimeout(() => setCopied(false), 2000);152 } catch (err) {153 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");154 }155 }156157 function handleDownload() {158 const s = script();159 if (!s) return;160 161 162 163 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });164 const url = URL.createObjectURL(blob);165 const stamp = new Date()166 .toISOString()167 .replace(/[:T]/g, "-")168 .replace(/\..*$/, "");169 const a = document.createElement("a");170 a.href = url;171 a.download = `peach-vault-${stamp}.peach`;172 document.body.appendChild(a);173 a.click();174 document.body.removeChild(a);175 setTimeout(() => URL.revokeObjectURL(url), 5000);176 }177178 return (179 <>180 <OverlayHeader title="peachscript" onBack={props.onBack} />181 <div class="peach-settings-body">182 <Show183 when={script()}184 fallback={185 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">186 <p class="peach-settings-label">backup password</p>187 <p class="peach-export-card-copy">188 Choose a password to lock this PeachScript. You'll need it189 to restore on another device.190 </p>191 <div class="peach-settings-stack peach-export-password-stack">192 <input193 ref={(el) => queueMicrotask(() => el.focus())}194 class="peach-settings-input"195 type="password"196 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}197 value={pass()}198 onInput={(e) => setPass((e.target as HTMLInputElement).value)}199 onKeyDown={(e) => {200 if (e.key === "Enter" && passReady() && !working()) {201 e.preventDefault();202 void handleEncode();203 }204 }}205 />206 <input207 class="peach-settings-input"208 type="password"209 placeholder="confirm backup password"210 value={confirm()}211 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}212 onKeyDown={(e) => {213 if (e.key === "Enter" && passReady() && !working()) {214 e.preventDefault();215 void handleEncode();216 }217 }}218 />219 <Show when={confirm().length > 0 && pass() !== confirm()}>220 <p class="peach-export-note" style="color:var(--peach-t2)">221 Passwords don't match222 </p>223 </Show>224 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>225 <p class="peach-export-note" style="color:var(--peach-t2)">226 At least {MIN_BACKUP_PASSWORD} characters227 </p>228 </Show>229 </div>230 <button231 class="peach-btn peach-btn--subtle peach-btn--sm"232 onClick={() => void handleEncode()}233 disabled={working() || !passReady()}234 >235 {working() ? "encoding…" : "Generate PeachScript"}236 </button>237 </div>238 }239 >240 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">241 <p class="peach-settings-label">peachscript</p>242 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />243 <p class="peach-export-card-copy" style="margin:0 0 8px">244 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}245 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}246 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}247 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}248 </p>249 <div style="display:flex;gap:8px;">250 <button251 class="peach-btn peach-btn--subtle peach-btn--sm"252 style="flex:1;"253 onClick={() => void handleCopy()}254 >255 {copied() ? "copied ✓" : "Copy"}256 </button>257 <button258 class="peach-btn peach-btn--subtle peach-btn--sm"259 style="flex:1;"260 onClick={handleDownload}261 >262 Download .peach263 </button>264 </div>265 <p class="peach-export-card-copy" style="margin-top:8px">266 Paste the script — or import the .peach file — into Peach on267 your other device to restore your vault. Won't work anywhere else.268 </p>269 </div>270 </Show>271 <Show when={error()}>272 <div class="peach-settings-group">273 <div class="peach-restore-error">{error()}</div>274 </div>275 </Show>276 </div>277 </>278 );279}280281282283284285function QRExport(props: { onBack: () => void }) {286 const [pass, setPass] = createSignal("");287 const [confirm, setConfirm] = createSignal("");288 const [working, setWorking] = createSignal(false);289 const [pngUrl, setPngUrl] = createSignal<string | null>(null);290 const [error, setError] = createSignal<string | null>(null);291 const [entryCount, setEntryCount] = createSignal(0);292 let canvasHost: HTMLDivElement | undefined;293294 const passReady = createMemo(295 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()296 );297298 async function handleEncode() {299 setError(null);300 setPngUrl(null);301 if (!passReady()) {302 setError("Backup passwords don't match or are too short.");303 return;304 }305 setWorking(true);306 try {307 const env = await assembleEnvelope(pass());308 if (env.bytes.length > QR_MAX_BYTES) {309 setError(310 `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`311 );312 return;313 }314 setEntryCount(env.entryCount);315 const canvas = await renderPeachQR(env.bytes);316 const url = await renderPeachQRDataURL(env.bytes);317 if (canvasHost) {318 canvasHost.innerHTML = "";319 canvasHost.appendChild(canvas);320 }321 setPngUrl(url);322 } catch (err) {323 setError(err instanceof Error ? err.message : String(err));324 } finally {325 setWorking(false);326 }327 }328329 function handleSave() {330 const url = pngUrl();331 if (!url) return;332 const a = document.createElement("a");333 a.href = url;334 a.download = `peach-qr-backup-${dateStamp()}.png`;335 document.body.appendChild(a);336 a.click();337 document.body.removeChild(a);338 }339340 return (341 <>342 <OverlayHeader title="print backup" onBack={props.onBack} />343 <div class="peach-settings-body">344 <Show345 when={pngUrl()}346 fallback={347 <div class="peach-settings-group peach-export-card peach-export-card--primary">348 <p class="peach-settings-label">backup password</p>349 <p class="peach-export-card-copy">350 Set a password to lock this QR backup. You'll need it to351 restore — without it the printed code is unreadable.352 </p>353 <div class="peach-settings-stack peach-export-password-stack">354 <input355 ref={(el) => queueMicrotask(() => el.focus())}356 class="peach-settings-input"357 type="password"358 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}359 value={pass()}360 onInput={(e) => setPass((e.target as HTMLInputElement).value)}361 onKeyDown={(e) => {362 if (e.key === "Enter" && passReady() && !working()) {363 e.preventDefault();364 void handleEncode();365 }366 }}367 />368 <input369 class="peach-settings-input"370 type="password"371 placeholder="confirm backup password"372 value={confirm()}373 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}374 onKeyDown={(e) => {375 if (e.key === "Enter" && passReady() && !working()) {376 e.preventDefault();377 void handleEncode();378 }379 }}380 />381 </div>382 <button383 class="peach-btn peach-btn--subtle peach-btn--sm"384 onClick={() => void handleEncode()}385 disabled={working() || !passReady()}386 >387 {working() ? "encoding…" : "Generate QR code"}388 </button>389 </div>390 }391 >392 <div class="peach-settings-group peach-export-card peach-export-card--primary">393 <div class="peach-qr-canvas-wrap" ref={canvasHost} />394 <p class="peach-export-card-copy" style="text-align:center">395 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}396 </p>397 <button398 class="peach-btn peach-btn--subtle peach-btn--sm"399 onClick={handleSave}400 >401 Save PNG402 </button>403 <p class="peach-export-card-copy" style="margin-top:8px">404 Print this and keep it somewhere safe. Scan it with Peach to405 restore your vault on any device.406 </p>407 </div>408 </Show>409 <Show when={error()}>410 <div class="peach-settings-group">411 <div class="peach-restore-error">{error()}</div>412 </div>413 </Show>414 </div>415 </>416 );417}418419420421422423function CSVExport(props: { onBack: () => void }) {424 const [acknowledged, setAcknowledged] = createSignal(false);425 const [working, setWorking] = createSignal(false);426 const [error, setError] = createSignal<string | null>(null);427 const [done, setDone] = createSignal<string | null>(null);428429 async function handleExport() {430 setError(null);431 setDone(null);432 setWorking(true);433 try {434 const res = await sendMessage({ type: "GET_ENTRIES" });435 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];436 437 438 439 440 const active = entries441 .filter((e) => e.deletedAt === undefined)442 .filter((e) => !(e.passkey && !e.password))443 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));444 const excludedPasskeys = entries.filter(445 (e) => e.deletedAt === undefined && e.passkey446 ).length;447448 if (active.length === 0) {449 setError("Your vault has no entries to export.");450 return;451 }452453 const csv = buildCsv(active);454 const filename = `peach-export-${dateStamp()}.csv`;455 downloadBlob(csv, "text/csv", filename);456 const suffix = excludedPasskeys > 0457 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`458 : "";459 setDone(460 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`461 );462 } catch (err) {463 setError(err instanceof Error ? err.message : String(err));464 } finally {465 setWorking(false);466 }467 }468469 return (470 <>471 <OverlayHeader title="export as CSV" onBack={props.onBack} />472 <div class="peach-settings-body">473 <div class="peach-settings-group peach-export-card">474 <div class="peach-csv-warning">475 This export is unencrypted. Anyone who gets this file can read476 your passwords. Only use this to move to another password477 manager — delete it once the import is done.478 </div>479 <Show when={!acknowledged()}>480 <button481 class="peach-btn peach-btn--ghost peach-btn--sm"482 onClick={() => setAcknowledged(true)}483 >484 I understand — continue485 </button>486 </Show>487 <Show when={acknowledged()}>488 <button489 class="peach-btn peach-btn--subtle peach-btn--sm"490 onClick={() => void handleExport()}491 disabled={working()}492 >493 {working() ? "exporting…" : "Download CSV"}494 </button>495 </Show>496 </div>497 <Show when={error()}>498 <div class="peach-settings-group">499 <div class="peach-restore-error">{error()}</div>500 </div>501 </Show>502 <Show when={done()}>503 <div class="peach-settings-group">504 <div class="peach-export-success">505 <span class="peach-export-success-check">✓</span>506 <span>{done()}</span>507 </div>508 </div>509 </Show>510 </div>511 </>512 );513}514515516517518519function OverlayHeader(props: { title: string; onBack: () => void }) {520 return (521 <div class="peach-overlay-header">522 <button523 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"524 onClick={props.onBack}525 aria-label="Back"526 >527 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">528 <path d="M6 1L1 6L6 11" />529 </svg>530 </button>531 <span class="peach-overlay-title">{props.title}</span>532 <div class="peach-overlay-spacer"></div>533 </div>534 );535}536537interface AssembledEnvelope {538 bytes: Uint8Array;539 entryCount: number;540 stats: { cards: number; passkeys: number; notes: number; logins: number };541}542543async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {544 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });545 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];546547 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });548 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};549550 let license: PeachLicenseExport | undefined;551 try {552 const info = await sendMessage({ type: "GET_LICENSE" });553 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;554 if (data?.valid && data.plan === "pro") {555 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });556 const keyStr = (keyRes as { ok: boolean; data?: string }).data;557 if (keyStr) {558 license = {559 key: keyStr,560 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),561 };562 }563 }564 } catch {565 566 }567568 const stats = countTypes(entries);569 const bytes = await buildPayload({570 entries,571 settings: settings as Parameters<typeof buildPayload>[0]["settings"],572 license,573 passphrase,574 });575 return { bytes, entryCount: entries.length, stats };576}577578function countTypes(entries: VaultEntry[]): {579 cards: number;580 passkeys: number;581 notes: number;582 logins: number;583} {584 let cards = 0;585 let passkeys = 0;586 let notes = 0;587 let logins = 0;588 for (const e of entries) {589 if (e.deletedAt) continue;590 if (e.passkey) {591 passkeys++;592 593 594 if (e.type === "login" && e.password) logins++;595 continue;596 }597 if (e.type === "card") cards++;598 else if (e.type === "note") notes++;599 else logins++;600 }601 return { cards, passkeys, notes, logins };602}603604function buildCsv(entries: VaultEntry[]): string {605 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];606 const rows = entries.map((e) => {607 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";608 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";609 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";610 return [611 e.title,612 e.url ?? "",613 username,614 password,615 e.notes ?? "",616 type,617 Array.isArray(e.tags) ? e.tags.join("|") : "",618 e.totp ?? "",619 ]620 .map((v) =>621 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`622 )623 .join(",");624 });625 return [headers.join(","), ...rows].join("\n");626}627628function dateStamp(): string {629 return new Date().toISOString().split("T")[0];630}631632function monthYear(): string {633 const d = new Date();634 const months = [635 "January", "February", "March", "April", "May", "June",636 "July", "August", "September", "October", "November", "December",637 ];638 return `${months[d.getMonth()]} ${d.getFullYear()}`;639}640641function downloadBlob(content: string, mimeType: string, filename: string): void {642 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));643 const a = document.createElement("a");644 a.href = url;645 a.download = filename;646 document.body.appendChild(a);647 a.click();648 document.body.removeChild(a);649 URL.revokeObjectURL(url);650}