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 return (158 <>159 <OverlayHeader title="peachscript" onBack={props.onBack} />160 <div class="peach-settings-body">161 <Show162 when={script()}163 fallback={164 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">165 <p class="peach-settings-label">backup password</p>166 <p class="peach-export-card-copy">167 Choose a password to lock this PeachScript. You'll need it168 to restore on another device.169 </p>170 <div class="peach-settings-stack peach-export-password-stack">171 <input172 class="peach-settings-input"173 type="password"174 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}175 value={pass()}176 onInput={(e) => setPass((e.target as HTMLInputElement).value)}177 />178 <input179 class="peach-settings-input"180 type="password"181 placeholder="confirm backup password"182 value={confirm()}183 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}184 />185 <Show when={confirm().length > 0 && pass() !== confirm()}>186 <p class="peach-export-note" style="color:var(--peach-t2)">187 Passwords don't match188 </p>189 </Show>190 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>191 <p class="peach-export-note" style="color:var(--peach-t2)">192 At least {MIN_BACKUP_PASSWORD} characters193 </p>194 </Show>195 </div>196 <button197 class="peach-btn peach-btn--subtle peach-btn--sm"198 onClick={() => void handleEncode()}199 disabled={working() || !passReady()}200 >201 {working() ? "encoding…" : "Generate PeachScript"}202 </button>203 </div>204 }205 >206 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">207 <p class="peach-settings-label">peachscript</p>208 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />209 <p class="peach-export-card-copy" style="margin:0 0 8px">210 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}211 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}212 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}213 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}214 </p>215 <button216 class="peach-btn peach-btn--subtle peach-btn--sm"217 onClick={() => void handleCopy()}218 >219 {copied() ? "copied ✓" : "Copy PeachScript"}220 </button>221 <p class="peach-export-card-copy" style="margin-top:8px">222 Paste this into Peach on your other device to restore your223 vault. It won't work anywhere else.224 </p>225 </div>226 </Show>227 <Show when={error()}>228 <div class="peach-settings-group">229 <div class="peach-restore-error">{error()}</div>230 </div>231 </Show>232 </div>233 </>234 );235}236237238239240241function QRExport(props: { onBack: () => void }) {242 const [pass, setPass] = createSignal("");243 const [confirm, setConfirm] = createSignal("");244 const [working, setWorking] = createSignal(false);245 const [pngUrl, setPngUrl] = createSignal<string | null>(null);246 const [error, setError] = createSignal<string | null>(null);247 const [entryCount, setEntryCount] = createSignal(0);248 let canvasHost: HTMLDivElement | undefined;249250 const passReady = createMemo(251 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()252 );253254 async function handleEncode() {255 setError(null);256 setPngUrl(null);257 if (!passReady()) {258 setError("Backup passwords don't match or are too short.");259 return;260 }261 setWorking(true);262 try {263 const env = await assembleEnvelope(pass());264 if (env.bytes.length > QR_MAX_BYTES) {265 setError(266 `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`267 );268 return;269 }270 setEntryCount(env.entryCount);271 const canvas = await renderPeachQR(env.bytes);272 const url = await renderPeachQRDataURL(env.bytes);273 if (canvasHost) {274 canvasHost.innerHTML = "";275 canvasHost.appendChild(canvas);276 }277 setPngUrl(url);278 } catch (err) {279 setError(err instanceof Error ? err.message : String(err));280 } finally {281 setWorking(false);282 }283 }284285 function handleSave() {286 const url = pngUrl();287 if (!url) return;288 const a = document.createElement("a");289 a.href = url;290 a.download = `peach-qr-backup-${dateStamp()}.png`;291 document.body.appendChild(a);292 a.click();293 document.body.removeChild(a);294 }295296 return (297 <>298 <OverlayHeader title="print backup" onBack={props.onBack} />299 <div class="peach-settings-body">300 <Show301 when={pngUrl()}302 fallback={303 <div class="peach-settings-group peach-export-card peach-export-card--primary">304 <p class="peach-settings-label">backup password</p>305 <p class="peach-export-card-copy">306 Set a password to lock this QR backup. You'll need it to307 restore — without it the printed code is unreadable.308 </p>309 <div class="peach-settings-stack peach-export-password-stack">310 <input311 class="peach-settings-input"312 type="password"313 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}314 value={pass()}315 onInput={(e) => setPass((e.target as HTMLInputElement).value)}316 />317 <input318 class="peach-settings-input"319 type="password"320 placeholder="confirm backup password"321 value={confirm()}322 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}323 />324 </div>325 <button326 class="peach-btn peach-btn--subtle peach-btn--sm"327 onClick={() => void handleEncode()}328 disabled={working() || !passReady()}329 >330 {working() ? "encoding…" : "Generate QR code"}331 </button>332 </div>333 }334 >335 <div class="peach-settings-group peach-export-card peach-export-card--primary">336 <div class="peach-qr-canvas-wrap" ref={canvasHost} />337 <p class="peach-export-card-copy" style="text-align:center">338 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}339 </p>340 <button341 class="peach-btn peach-btn--subtle peach-btn--sm"342 onClick={handleSave}343 >344 Save PNG345 </button>346 <p class="peach-export-card-copy" style="margin-top:8px">347 Print this and keep it somewhere safe. Scan it with Peach to348 restore your vault on any device.349 </p>350 </div>351 </Show>352 <Show when={error()}>353 <div class="peach-settings-group">354 <div class="peach-restore-error">{error()}</div>355 </div>356 </Show>357 </div>358 </>359 );360}361362363364365366function CSVExport(props: { onBack: () => void }) {367 const [acknowledged, setAcknowledged] = createSignal(false);368 const [working, setWorking] = createSignal(false);369 const [error, setError] = createSignal<string | null>(null);370 const [done, setDone] = createSignal<string | null>(null);371372 async function handleExport() {373 setError(null);374 setDone(null);375 setWorking(true);376 try {377 const res = await sendMessage({ type: "GET_ENTRIES" });378 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];379 const active = entries.filter(380 (e) => e.deletedAt === undefined && e.type !== "passkey"381 );382 const excludedPasskeys = entries.filter(383 (e) => e.deletedAt === undefined && e.type === "passkey"384 ).length;385386 if (active.length === 0) {387 setError("Your vault has no entries to export.");388 return;389 }390391 const csv = buildCsv(active);392 const filename = `peach-export-${dateStamp()}.csv`;393 downloadBlob(csv, "text/csv", filename);394 const suffix = excludedPasskeys > 0395 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`396 : "";397 setDone(398 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`399 );400 } catch (err) {401 setError(err instanceof Error ? err.message : String(err));402 } finally {403 setWorking(false);404 }405 }406407 return (408 <>409 <OverlayHeader title="export as CSV" onBack={props.onBack} />410 <div class="peach-settings-body">411 <div class="peach-settings-group peach-export-card">412 <div class="peach-csv-warning">413 This export is unencrypted. Anyone who gets this file can read414 your passwords. Only use this to move to another password415 manager — delete it once the import is done.416 </div>417 <Show when={!acknowledged()}>418 <button419 class="peach-btn peach-btn--ghost peach-btn--sm"420 onClick={() => setAcknowledged(true)}421 >422 I understand — continue423 </button>424 </Show>425 <Show when={acknowledged()}>426 <button427 class="peach-btn peach-btn--subtle peach-btn--sm"428 onClick={() => void handleExport()}429 disabled={working()}430 >431 {working() ? "exporting…" : "Download CSV"}432 </button>433 </Show>434 </div>435 <Show when={error()}>436 <div class="peach-settings-group">437 <div class="peach-restore-error">{error()}</div>438 </div>439 </Show>440 <Show when={done()}>441 <div class="peach-settings-group">442 <div class="peach-export-success">443 <span class="peach-export-success-check">✓</span>444 <span>{done()}</span>445 </div>446 </div>447 </Show>448 </div>449 </>450 );451}452453454455456457function OverlayHeader(props: { title: string; onBack: () => void }) {458 return (459 <div class="peach-overlay-header">460 <button461 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"462 onClick={props.onBack}463 aria-label="Back"464 >465 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">466 <path d="M6 1L1 6L6 11" />467 </svg>468 </button>469 <span class="peach-overlay-title">{props.title}</span>470 <div class="peach-overlay-spacer"></div>471 </div>472 );473}474475interface AssembledEnvelope {476 bytes: Uint8Array;477 entryCount: number;478 stats: { cards: number; passkeys: number; notes: number; logins: number };479}480481async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {482 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });483 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];484485 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });486 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};487488 let license: PeachLicenseExport | undefined;489 try {490 const info = await sendMessage({ type: "GET_LICENSE" });491 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;492 if (data?.valid && data.plan === "pro") {493 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });494 const keyStr = (keyRes as { ok: boolean; data?: string }).data;495 if (keyStr) {496 license = {497 key: keyStr,498 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),499 };500 }501 }502 } catch {503 504 }505506 const stats = countTypes(entries);507 const bytes = await buildPayload({508 entries,509 settings: settings as Parameters<typeof buildPayload>[0]["settings"],510 license,511 passphrase,512 });513 return { bytes, entryCount: entries.length, stats };514}515516function countTypes(entries: VaultEntry[]): {517 cards: number;518 passkeys: number;519 notes: number;520 logins: number;521} {522 let cards = 0;523 let passkeys = 0;524 let notes = 0;525 let logins = 0;526 for (const e of entries) {527 if (e.deletedAt) continue;528 if (e.type === "card") cards++;529 else if (e.type === "passkey") passkeys++;530 else if (e.type === "note") notes++;531 else logins++;532 }533 return { cards, passkeys, notes, logins };534}535536function buildCsv(entries: VaultEntry[]): string {537 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];538 const rows = entries.map((e) => {539 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";540 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";541 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";542 return [543 e.title,544 e.url ?? "",545 username,546 password,547 e.notes ?? "",548 type,549 Array.isArray(e.tags) ? e.tags.join("|") : "",550 e.totp ?? "",551 ]552 .map((v) =>553 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`554 )555 .join(",");556 });557 return [headers.join(","), ...rows].join("\n");558}559560function dateStamp(): string {561 return new Date().toISOString().split("T")[0];562}563564function monthYear(): string {565 const d = new Date();566 const months = [567 "January", "February", "March", "April", "May", "June",568 "July", "August", "September", "October", "November", "December",569 ];570 return `${months[d.getMonth()]} ${d.getFullYear()}`;571}572573function downloadBlob(content: string, mimeType: string, filename: string): void {574 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));575 const a = document.createElement("a");576 a.href = url;577 a.download = filename;578 document.body.appendChild(a);579 a.click();580 document.body.removeChild(a);581 URL.revokeObjectURL(url);582}