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 ref={(el) => queueMicrotask(() => el.focus())}173 class="peach-settings-input"174 type="password"175 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}176 value={pass()}177 onInput={(e) => setPass((e.target as HTMLInputElement).value)}178 onKeyDown={(e) => {179 if (e.key === "Enter" && passReady() && !working()) {180 e.preventDefault();181 void handleEncode();182 }183 }}184 />185 <input186 class="peach-settings-input"187 type="password"188 placeholder="confirm backup password"189 value={confirm()}190 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}191 onKeyDown={(e) => {192 if (e.key === "Enter" && passReady() && !working()) {193 e.preventDefault();194 void handleEncode();195 }196 }}197 />198 <Show when={confirm().length > 0 && pass() !== confirm()}>199 <p class="peach-export-note" style="color:var(--peach-t2)">200 Passwords don't match201 </p>202 </Show>203 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>204 <p class="peach-export-note" style="color:var(--peach-t2)">205 At least {MIN_BACKUP_PASSWORD} characters206 </p>207 </Show>208 </div>209 <button210 class="peach-btn peach-btn--subtle peach-btn--sm"211 onClick={() => void handleEncode()}212 disabled={working() || !passReady()}213 >214 {working() ? "encoding…" : "Generate PeachScript"}215 </button>216 </div>217 }218 >219 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">220 <p class="peach-settings-label">peachscript</p>221 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />222 <p class="peach-export-card-copy" style="margin:0 0 8px">223 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}224 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}225 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}226 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}227 </p>228 <button229 class="peach-btn peach-btn--subtle peach-btn--sm"230 onClick={() => void handleCopy()}231 >232 {copied() ? "copied ✓" : "Copy PeachScript"}233 </button>234 <p class="peach-export-card-copy" style="margin-top:8px">235 Paste this into Peach on your other device to restore your236 vault. It won't work anywhere else.237 </p>238 </div>239 </Show>240 <Show when={error()}>241 <div class="peach-settings-group">242 <div class="peach-restore-error">{error()}</div>243 </div>244 </Show>245 </div>246 </>247 );248}249250251252253254function QRExport(props: { onBack: () => void }) {255 const [pass, setPass] = createSignal("");256 const [confirm, setConfirm] = createSignal("");257 const [working, setWorking] = createSignal(false);258 const [pngUrl, setPngUrl] = createSignal<string | null>(null);259 const [error, setError] = createSignal<string | null>(null);260 const [entryCount, setEntryCount] = createSignal(0);261 let canvasHost: HTMLDivElement | undefined;262263 const passReady = createMemo(264 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()265 );266267 async function handleEncode() {268 setError(null);269 setPngUrl(null);270 if (!passReady()) {271 setError("Backup passwords don't match or are too short.");272 return;273 }274 setWorking(true);275 try {276 const env = await assembleEnvelope(pass());277 if (env.bytes.length > QR_MAX_BYTES) {278 setError(279 `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`280 );281 return;282 }283 setEntryCount(env.entryCount);284 const canvas = await renderPeachQR(env.bytes);285 const url = await renderPeachQRDataURL(env.bytes);286 if (canvasHost) {287 canvasHost.innerHTML = "";288 canvasHost.appendChild(canvas);289 }290 setPngUrl(url);291 } catch (err) {292 setError(err instanceof Error ? err.message : String(err));293 } finally {294 setWorking(false);295 }296 }297298 function handleSave() {299 const url = pngUrl();300 if (!url) return;301 const a = document.createElement("a");302 a.href = url;303 a.download = `peach-qr-backup-${dateStamp()}.png`;304 document.body.appendChild(a);305 a.click();306 document.body.removeChild(a);307 }308309 return (310 <>311 <OverlayHeader title="print backup" onBack={props.onBack} />312 <div class="peach-settings-body">313 <Show314 when={pngUrl()}315 fallback={316 <div class="peach-settings-group peach-export-card peach-export-card--primary">317 <p class="peach-settings-label">backup password</p>318 <p class="peach-export-card-copy">319 Set a password to lock this QR backup. You'll need it to320 restore — without it the printed code is unreadable.321 </p>322 <div class="peach-settings-stack peach-export-password-stack">323 <input324 ref={(el) => queueMicrotask(() => el.focus())}325 class="peach-settings-input"326 type="password"327 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}328 value={pass()}329 onInput={(e) => setPass((e.target as HTMLInputElement).value)}330 onKeyDown={(e) => {331 if (e.key === "Enter" && passReady() && !working()) {332 e.preventDefault();333 void handleEncode();334 }335 }}336 />337 <input338 class="peach-settings-input"339 type="password"340 placeholder="confirm backup password"341 value={confirm()}342 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}343 onKeyDown={(e) => {344 if (e.key === "Enter" && passReady() && !working()) {345 e.preventDefault();346 void handleEncode();347 }348 }}349 />350 </div>351 <button352 class="peach-btn peach-btn--subtle peach-btn--sm"353 onClick={() => void handleEncode()}354 disabled={working() || !passReady()}355 >356 {working() ? "encoding…" : "Generate QR code"}357 </button>358 </div>359 }360 >361 <div class="peach-settings-group peach-export-card peach-export-card--primary">362 <div class="peach-qr-canvas-wrap" ref={canvasHost} />363 <p class="peach-export-card-copy" style="text-align:center">364 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}365 </p>366 <button367 class="peach-btn peach-btn--subtle peach-btn--sm"368 onClick={handleSave}369 >370 Save PNG371 </button>372 <p class="peach-export-card-copy" style="margin-top:8px">373 Print this and keep it somewhere safe. Scan it with Peach to374 restore your vault on any device.375 </p>376 </div>377 </Show>378 <Show when={error()}>379 <div class="peach-settings-group">380 <div class="peach-restore-error">{error()}</div>381 </div>382 </Show>383 </div>384 </>385 );386}387388389390391392function CSVExport(props: { onBack: () => void }) {393 const [acknowledged, setAcknowledged] = createSignal(false);394 const [working, setWorking] = createSignal(false);395 const [error, setError] = createSignal<string | null>(null);396 const [done, setDone] = createSignal<string | null>(null);397398 async function handleExport() {399 setError(null);400 setDone(null);401 setWorking(true);402 try {403 const res = await sendMessage({ type: "GET_ENTRIES" });404 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];405 406 407 408 409 const active = entries410 .filter((e) => e.deletedAt === undefined)411 .filter((e) => !(e.passkey && !e.password))412 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));413 const excludedPasskeys = entries.filter(414 (e) => e.deletedAt === undefined && e.passkey415 ).length;416417 if (active.length === 0) {418 setError("Your vault has no entries to export.");419 return;420 }421422 const csv = buildCsv(active);423 const filename = `peach-export-${dateStamp()}.csv`;424 downloadBlob(csv, "text/csv", filename);425 const suffix = excludedPasskeys > 0426 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`427 : "";428 setDone(429 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`430 );431 } catch (err) {432 setError(err instanceof Error ? err.message : String(err));433 } finally {434 setWorking(false);435 }436 }437438 return (439 <>440 <OverlayHeader title="export as CSV" onBack={props.onBack} />441 <div class="peach-settings-body">442 <div class="peach-settings-group peach-export-card">443 <div class="peach-csv-warning">444 This export is unencrypted. Anyone who gets this file can read445 your passwords. Only use this to move to another password446 manager — delete it once the import is done.447 </div>448 <Show when={!acknowledged()}>449 <button450 class="peach-btn peach-btn--ghost peach-btn--sm"451 onClick={() => setAcknowledged(true)}452 >453 I understand — continue454 </button>455 </Show>456 <Show when={acknowledged()}>457 <button458 class="peach-btn peach-btn--subtle peach-btn--sm"459 onClick={() => void handleExport()}460 disabled={working()}461 >462 {working() ? "exporting…" : "Download CSV"}463 </button>464 </Show>465 </div>466 <Show when={error()}>467 <div class="peach-settings-group">468 <div class="peach-restore-error">{error()}</div>469 </div>470 </Show>471 <Show when={done()}>472 <div class="peach-settings-group">473 <div class="peach-export-success">474 <span class="peach-export-success-check">✓</span>475 <span>{done()}</span>476 </div>477 </div>478 </Show>479 </div>480 </>481 );482}483484485486487488function OverlayHeader(props: { title: string; onBack: () => void }) {489 return (490 <div class="peach-overlay-header">491 <button492 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"493 onClick={props.onBack}494 aria-label="Back"495 >496 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">497 <path d="M6 1L1 6L6 11" />498 </svg>499 </button>500 <span class="peach-overlay-title">{props.title}</span>501 <div class="peach-overlay-spacer"></div>502 </div>503 );504}505506interface AssembledEnvelope {507 bytes: Uint8Array;508 entryCount: number;509 stats: { cards: number; passkeys: number; notes: number; logins: number };510}511512async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {513 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });514 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];515516 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });517 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};518519 let license: PeachLicenseExport | undefined;520 try {521 const info = await sendMessage({ type: "GET_LICENSE" });522 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;523 if (data?.valid && data.plan === "pro") {524 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });525 const keyStr = (keyRes as { ok: boolean; data?: string }).data;526 if (keyStr) {527 license = {528 key: keyStr,529 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),530 };531 }532 }533 } catch {534 535 }536537 const stats = countTypes(entries);538 const bytes = await buildPayload({539 entries,540 settings: settings as Parameters<typeof buildPayload>[0]["settings"],541 license,542 passphrase,543 });544 return { bytes, entryCount: entries.length, stats };545}546547function countTypes(entries: VaultEntry[]): {548 cards: number;549 passkeys: number;550 notes: number;551 logins: number;552} {553 let cards = 0;554 let passkeys = 0;555 let notes = 0;556 let logins = 0;557 for (const e of entries) {558 if (e.deletedAt) continue;559 if (e.passkey) {560 passkeys++;561 562 563 if (e.type === "login" && e.password) logins++;564 continue;565 }566 if (e.type === "card") cards++;567 else if (e.type === "note") notes++;568 else logins++;569 }570 return { cards, passkeys, notes, logins };571}572573function buildCsv(entries: VaultEntry[]): string {574 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];575 const rows = entries.map((e) => {576 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";577 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";578 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";579 return [580 e.title,581 e.url ?? "",582 username,583 password,584 e.notes ?? "",585 type,586 Array.isArray(e.tags) ? e.tags.join("|") : "",587 e.totp ?? "",588 ]589 .map((v) =>590 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`591 )592 .join(",");593 });594 return [headers.join(","), ...rows].join("\n");595}596597function dateStamp(): string {598 return new Date().toISOString().split("T")[0];599}600601function monthYear(): string {602 const d = new Date();603 const months = [604 "January", "February", "March", "April", "May", "June",605 "July", "August", "September", "October", "November", "December",606 ];607 return `${months[d.getMonth()]} ${d.getFullYear()}`;608}609610function downloadBlob(content: string, mimeType: string, filename: string): void {611 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));612 const a = document.createElement("a");613 a.href = url;614 a.download = filename;615 document.body.appendChild(a);616 a.click();617 document.body.removeChild(a);618 URL.revokeObjectURL(url);619}