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 Mayan 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 Mayan 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 164 165 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });166 const url = URL.createObjectURL(blob);167 const date = new Date().toISOString().slice(0, 10);168 const a = document.createElement("a");169 a.href = url;170 a.download = `peach-backup-${date}.peach`;171 document.body.appendChild(a);172 a.click();173 document.body.removeChild(a);174 setTimeout(() => URL.revokeObjectURL(url), 5000);175 }176177 return (178 <>179 <OverlayHeader title="peachscript" onBack={props.onBack} />180 <div class="peach-settings-body">181 <Show182 when={script()}183 fallback={184 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">185 <p class="peach-settings-label">backup password</p>186 <p class="peach-export-card-copy">187 Choose a password to lock this PeachScript. You'll need it188 to restore on another device.189 </p>190 <div class="peach-settings-stack peach-export-password-stack">191 <input192 ref={(el) => queueMicrotask(() => el.focus())}193 class="peach-settings-input"194 type="password"195 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}196 value={pass()}197 onInput={(e) => setPass((e.target as HTMLInputElement).value)}198 onKeyDown={(e) => {199 if (e.key === "Enter" && passReady() && !working()) {200 e.preventDefault();201 void handleEncode();202 }203 }}204 />205 <input206 class="peach-settings-input"207 type="password"208 placeholder="confirm backup password"209 value={confirm()}210 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}211 onKeyDown={(e) => {212 if (e.key === "Enter" && passReady() && !working()) {213 e.preventDefault();214 void handleEncode();215 }216 }}217 />218 <Show when={confirm().length > 0 && pass() !== confirm()}>219 <p class="peach-export-note" style="color:var(--peach-t2)">220 Passwords don't match221 </p>222 </Show>223 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>224 <p class="peach-export-note" style="color:var(--peach-t2)">225 At least {MIN_BACKUP_PASSWORD} characters226 </p>227 </Show>228 </div>229 <button230 class="peach-btn peach-btn--subtle peach-btn--sm"231 onClick={() => void handleEncode()}232 disabled={working() || !passReady()}233 >234 {working() ? "encoding…" : "Generate PeachScript"}235 </button>236 </div>237 }238 >239 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">240 <p class="peach-settings-label">peachscript</p>241 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />242 <p class="peach-export-card-copy" style="margin:0 0 8px">243 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}244 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}245 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}246 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}247 </p>248 <div style="display:flex;gap:8px;">249 <button250 class="peach-btn peach-btn--subtle peach-btn--sm"251 style="flex:1;"252 onClick={() => void handleCopy()}253 >254 {copied() ? "copied ✓" : "Copy"}255 </button>256 <button257 class="peach-btn peach-btn--subtle peach-btn--sm"258 style="flex:1;"259 onClick={handleDownload}260 >261 Download .peach262 </button>263 </div>264 <p class="peach-export-card-copy" style="margin-top:8px">265 Paste the script — or import the .peach file — into Peach on266 your other device to restore your vault. Won't work anywhere else.267 </p>268 </div>269 </Show>270 <Show when={error()}>271 <div class="peach-settings-group">272 <div class="peach-restore-error">{error()}</div>273 </div>274 </Show>275 </div>276 </>277 );278}279280281282283284function QRExport(props: { onBack: () => void }) {285 const [pass, setPass] = createSignal("");286 const [confirm, setConfirm] = createSignal("");287 const [working, setWorking] = createSignal(false);288 const [pngUrl, setPngUrl] = createSignal<string | null>(null);289 const [error, setError] = createSignal<string | null>(null);290 const [entryCount, setEntryCount] = createSignal(0);291 let canvasHost: HTMLDivElement | undefined;292293 const passReady = createMemo(294 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()295 );296297 async function handleEncode() {298 setError(null);299 setPngUrl(null);300 if (!passReady()) {301 setError("Backup passwords don't match or are too short.");302 return;303 }304 setWorking(true);305 try {306 const env = await assembleEnvelope(pass());307 if (env.bytes.length > QR_MAX_BYTES) {308 setError(309 `This vault is too large for a Mayan code backup (${env.bytes.length} bytes). Use PeachScript instead.`310 );311 return;312 }313 setEntryCount(env.entryCount);314 const canvas = await renderPeachQR(env.bytes);315 const url = await renderPeachQRDataURL(env.bytes);316 if (canvasHost) {317 canvasHost.innerHTML = "";318 canvasHost.appendChild(canvas);319 }320 setPngUrl(url);321 } catch (err) {322 setError(err instanceof Error ? err.message : String(err));323 } finally {324 setWorking(false);325 }326 }327328 function handleSave() {329 const url = pngUrl();330 if (!url) return;331 const a = document.createElement("a");332 a.href = url;333 a.download = `peach-mayan-backup-${dateStamp()}.png`;334 document.body.appendChild(a);335 a.click();336 document.body.removeChild(a);337 }338339 return (340 <>341 <OverlayHeader title="print backup" onBack={props.onBack} />342 <div class="peach-settings-body">343 <Show344 when={pngUrl()}345 fallback={346 <div class="peach-settings-group peach-export-card peach-export-card--primary">347 <p class="peach-settings-label">backup password</p>348 <p class="peach-export-card-copy">349 Set a password to lock this Mayan code backup. You'll need it350 to restore — without it the printed code is unreadable.351 </p>352 <div class="peach-settings-stack peach-export-password-stack">353 <input354 ref={(el) => queueMicrotask(() => el.focus())}355 class="peach-settings-input"356 type="password"357 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}358 value={pass()}359 onInput={(e) => setPass((e.target as HTMLInputElement).value)}360 onKeyDown={(e) => {361 if (e.key === "Enter" && passReady() && !working()) {362 e.preventDefault();363 void handleEncode();364 }365 }}366 />367 <input368 class="peach-settings-input"369 type="password"370 placeholder="confirm backup password"371 value={confirm()}372 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}373 onKeyDown={(e) => {374 if (e.key === "Enter" && passReady() && !working()) {375 e.preventDefault();376 void handleEncode();377 }378 }}379 />380 </div>381 <button382 class="peach-btn peach-btn--subtle peach-btn--sm"383 onClick={() => void handleEncode()}384 disabled={working() || !passReady()}385 >386 {working() ? "encoding…" : "Generate Mayan code"}387 </button>388 </div>389 }390 >391 <div class="peach-settings-group peach-export-card peach-export-card--primary">392 <div class="peach-qr-canvas-wrap" ref={canvasHost} />393 <p class="peach-export-card-copy" style="text-align:center">394 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}395 </p>396 <button397 class="peach-btn peach-btn--subtle peach-btn--sm"398 onClick={handleSave}399 >400 Save PNG401 </button>402 <p class="peach-export-card-copy" style="margin-top:8px">403 Print this and keep it somewhere safe. Scan it with Peach to404 restore your vault on any device.405 </p>406 </div>407 </Show>408 <Show when={error()}>409 <div class="peach-settings-group">410 <div class="peach-restore-error">{error()}</div>411 </div>412 </Show>413 </div>414 </>415 );416}417418419420421422function CSVExport(props: { onBack: () => void }) {423 const [acknowledged, setAcknowledged] = createSignal(false);424 const [working, setWorking] = createSignal(false);425 const [error, setError] = createSignal<string | null>(null);426 const [done, setDone] = createSignal<string | null>(null);427428 async function handleExport() {429 setError(null);430 setDone(null);431 setWorking(true);432 try {433 const res = await sendMessage({ type: "GET_ENTRIES" });434 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];435 436 437 438 439 const active = entries440 .filter((e) => e.deletedAt === undefined)441 .filter((e) => !(e.passkey && !e.password))442 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));443 const excludedPasskeys = entries.filter(444 (e) => e.deletedAt === undefined && e.passkey445 ).length;446447 if (active.length === 0) {448 setError("Your vault has no entries to export.");449 return;450 }451452 const csv = buildCsv(active);453 const filename = `peach-export-${dateStamp()}.csv`;454 downloadBlob(csv, "text/csv", filename);455 const suffix = excludedPasskeys > 0456 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`457 : "";458 setDone(459 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`460 );461 } catch (err) {462 setError(err instanceof Error ? err.message : String(err));463 } finally {464 setWorking(false);465 }466 }467468 return (469 <>470 <OverlayHeader title="export as CSV" onBack={props.onBack} />471 <div class="peach-settings-body">472 <div class="peach-settings-group peach-export-card">473 <div class="peach-csv-warning">474 This export is unencrypted. Anyone who gets this file can read475 your passwords. Only use this to move to another password476 manager — delete it once the import is done.477 </div>478 <Show when={!acknowledged()}>479 <button480 class="peach-btn peach-btn--ghost peach-btn--sm"481 onClick={() => setAcknowledged(true)}482 >483 I understand — continue484 </button>485 </Show>486 <Show when={acknowledged()}>487 <button488 class="peach-btn peach-btn--subtle peach-btn--sm"489 onClick={() => void handleExport()}490 disabled={working()}491 >492 {working() ? "exporting…" : "Download CSV"}493 </button>494 </Show>495 </div>496 <Show when={error()}>497 <div class="peach-settings-group">498 <div class="peach-restore-error">{error()}</div>499 </div>500 </Show>501 <Show when={done()}>502 <div class="peach-settings-group">503 <div class="peach-export-success">504 <span class="peach-export-success-check">✓</span>505 <span>{done()}</span>506 </div>507 </div>508 </Show>509 </div>510 </>511 );512}513514515516517518function OverlayHeader(props: { title: string; onBack: () => void }) {519 return (520 <div class="peach-overlay-header">521 <button522 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"523 onClick={props.onBack}524 aria-label="Back"525 >526 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">527 <path d="M6 1L1 6L6 11" />528 </svg>529 </button>530 <span class="peach-overlay-title">{props.title}</span>531 <div class="peach-overlay-spacer"></div>532 </div>533 );534}535536interface AssembledEnvelope {537 bytes: Uint8Array;538 entryCount: number;539 stats: { cards: number; passkeys: number; notes: number; logins: number };540}541542async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {543 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });544 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];545546 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });547 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};548549 let license: PeachLicenseExport | undefined;550 try {551 const info = await sendMessage({ type: "GET_LICENSE" });552 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;553 if (data?.valid && data.plan === "pro") {554 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });555 const keyStr = (keyRes as { ok: boolean; data?: string }).data;556 if (keyStr) {557 license = {558 key: keyStr,559 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),560 };561 }562 }563 } catch {564 565 }566567 const stats = countTypes(entries);568 const bytes = await buildPayload({569 entries,570 settings: settings as Parameters<typeof buildPayload>[0]["settings"],571 license,572 passphrase,573 });574 return { bytes, entryCount: entries.length, stats };575}576577function countTypes(entries: VaultEntry[]): {578 cards: number;579 passkeys: number;580 notes: number;581 logins: number;582} {583 let cards = 0;584 let passkeys = 0;585 let notes = 0;586 let logins = 0;587 for (const e of entries) {588 if (e.deletedAt) continue;589 if (e.passkey) {590 passkeys++;591 592 593 if (e.type === "login" && e.password) logins++;594 continue;595 }596 if (e.type === "card") cards++;597 else if (e.type === "note") notes++;598 else logins++;599 }600 return { cards, passkeys, notes, logins };601}602603function buildCsv(entries: VaultEntry[]): string {604 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];605 const rows = entries.map((e) => {606 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";607 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";608 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";609 return [610 e.title,611 e.url ?? "",612 username,613 password,614 e.notes ?? "",615 type,616 Array.isArray(e.tags) ? e.tags.join("|") : "",617 e.totp ?? "",618 ]619 .map((v) =>620 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`621 )622 .join(",");623 });624 return [headers.join(","), ...rows].join("\n");625}626627function dateStamp(): string {628 return new Date().toISOString().split("T")[0];629}630631function monthYear(): string {632 const d = new Date();633 const months = [634 "January", "February", "March", "April", "May", "June",635 "July", "August", "September", "October", "November", "December",636 ];637 return `${months[d.getMonth()]} ${d.getFullYear()}`;638}639640function downloadBlob(content: string, mimeType: string, filename: string): void {641 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));642 const a = document.createElement("a");643 a.href = url;644 a.download = filename;645 document.body.appendChild(a);646 a.click();647 document.body.removeChild(a);648 URL.revokeObjectURL(url);649}