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 { encodeMayan, MAX_PAYLOAD_BYTES as MAYAN_MAX_BYTES } from "../../crypto/mayan";12import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1314type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1516interface ExportProps {17 onBack: () => void;18}1920const MIN_BACKUP_PASSWORD = 8;2122export function ExportView(props: ExportProps) {23 const [view, setView] = createSignal<ExportSubview>("menu");24 return (25 <div class="peach-overlay-view ov-enter-right" style="display:flex">26 <Show when={view() === "menu"}>27 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />28 </Show>29 <Show when={view() === "peachscript"}>30 <PeachScriptExport onBack={() => setView("menu")} />31 </Show>32 <Show when={view() === "qr"}>33 <QRExport onBack={() => setView("menu")} />34 </Show>35 <Show when={view() === "csv"}>36 <CSVExport onBack={() => setView("menu")} />37 </Show>38 </div>39 );40}414243444546function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {47 return (48 <>49 <OverlayHeader title="export" onBack={props.onBack} />50 <div class="peach-settings-body peach-export-menu">51 <div class="peach-settings-group peach-export-card peach-export-card--primary">52 <p class="peach-settings-label">peachscript</p>53 <p class="peach-export-card-copy">54 Your vault as a string of Peach symbols you can paste into Peach55 on another device. Designed for Peach-to-Peach transfer.56 </p>57 <button58 class="peach-btn peach-btn--subtle peach-btn--sm"59 onClick={() => props.onPick("peachscript")}60 >61 Create PeachScript →62 </button>63 </div>6465 <div class="peach-settings-group peach-export-card">66 <p class="peach-settings-label">print backup</p>67 <p class="peach-export-card-copy">68 Save a Mayan code you can print and store. Scan it with Peach on any69 device to restore.70 </p>71 <button72 class="peach-btn peach-btn--subtle peach-btn--sm"73 onClick={() => props.onPick("qr")}74 >75 Save Mayan code →76 </button>77 </div>7879 <div class="peach-settings-group peach-export-card">80 <p class="peach-settings-label">CSV (leaving peach)</p>81 <p class="peach-export-card-copy">82 Plain-text export for moving into another password manager. Anyone83 with the file can read your passwords.84 </p>85 <button86 class="peach-btn peach-btn--ghost peach-btn--sm"87 onClick={() => props.onPick("csv")}88 >89 Export as CSV →90 </button>91 </div>92 </div>93 </>94 );95}96979899100101function PeachScriptExport(props: { onBack: () => void }) {102 const [pass, setPass] = createSignal("");103 const [confirm, setConfirm] = createSignal("");104 const [working, setWorking] = createSignal(false);105 const [script, setScript] = createSignal<string | null>(null);106 const [error, setError] = createSignal<string | null>(null);107 const [entryCount, setEntryCount] = createSignal(0);108 const [stats, setStats] = createSignal<{109 cards: number;110 passkeys: number;111 notes: number;112 logins: number;113 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });114 const [copied, setCopied] = createSignal(false);115116 const passReady = createMemo(117 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()118 );119120 async function handleEncode() {121 setError(null);122 setScript(null);123 setCopied(false);124 if (!passReady()) {125 setError("Backup passwords don't match or are too short.");126 return;127 }128 setWorking(true);129 try {130 const env = await assembleEnvelope(pass());131 setEntryCount(env.entryCount);132 setStats(env.stats);133 setScript(encodePeachScript(env.bytes));134 } catch (err) {135 setError(err instanceof Error ? err.message : String(err));136 } finally {137 setWorking(false);138 }139 }140141 async function handleCopy() {142 const s = script();143 if (!s) return;144 try {145 await navigator.clipboard.writeText(s);146 setCopied(true);147 setTimeout(() => setCopied(false), 2000);148 } catch (err) {149 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");150 }151 }152153 function handleDownload() {154 const s = script();155 if (!s) return;156 157 158 159 160 161 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });162 const url = URL.createObjectURL(blob);163 const date = new Date().toISOString().slice(0, 10);164 const a = document.createElement("a");165 a.href = url;166 a.download = `peach-backup-${date}.peach`;167 document.body.appendChild(a);168 a.click();169 document.body.removeChild(a);170 setTimeout(() => URL.revokeObjectURL(url), 5000);171 }172173 return (174 <>175 <OverlayHeader title="peachscript" onBack={props.onBack} />176 <div class="peach-settings-body">177 <Show178 when={script()}179 fallback={180 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">181 <p class="peach-settings-label">backup password</p>182 <p class="peach-export-card-copy">183 Choose a password to lock this PeachScript. You'll need it184 to restore on another device.185 </p>186 <div class="peach-settings-stack peach-export-password-stack">187 <input188 ref={(el) => queueMicrotask(() => el.focus())}189 class="peach-settings-input"190 type="password"191 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}192 value={pass()}193 onInput={(e) => setPass((e.target as HTMLInputElement).value)}194 onKeyDown={(e) => {195 if (e.key === "Enter" && passReady() && !working()) {196 e.preventDefault();197 void handleEncode();198 }199 }}200 />201 <input202 class="peach-settings-input"203 type="password"204 placeholder="confirm backup password"205 value={confirm()}206 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}207 onKeyDown={(e) => {208 if (e.key === "Enter" && passReady() && !working()) {209 e.preventDefault();210 void handleEncode();211 }212 }}213 />214 <Show when={confirm().length > 0 && pass() !== confirm()}>215 <p class="peach-export-note" style="color:var(--peach-t2)">216 Passwords don't match217 </p>218 </Show>219 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>220 <p class="peach-export-note" style="color:var(--peach-t2)">221 At least {MIN_BACKUP_PASSWORD} characters222 </p>223 </Show>224 </div>225 <button226 class="peach-btn peach-btn--subtle peach-btn--sm"227 onClick={() => void handleEncode()}228 disabled={working() || !passReady()}229 >230 {working() ? "encoding…" : "Generate PeachScript"}231 </button>232 </div>233 }234 >235 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">236 <p class="peach-settings-label">peachscript</p>237 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />238 <p class="peach-export-card-copy" style="margin:0 0 8px">239 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}240 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}241 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}242 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}243 </p>244 <div style="display:flex;gap:8px;">245 <button246 class="peach-btn peach-btn--subtle peach-btn--sm"247 style="flex:1;"248 onClick={() => void handleCopy()}249 >250 {copied() ? "copied ✓" : "Copy"}251 </button>252 <button253 class="peach-btn peach-btn--subtle peach-btn--sm"254 style="flex:1;"255 onClick={handleDownload}256 >257 Download .peach258 </button>259 </div>260 <p class="peach-export-card-copy" style="margin-top:8px">261 Paste the script — or import the .peach file — into Peach on262 your other device to restore your vault. Won't work anywhere else.263 </p>264 </div>265 </Show>266 <Show when={error()}>267 <div class="peach-settings-group">268 <div class="peach-restore-error">{error()}</div>269 </div>270 </Show>271 </div>272 </>273 );274}275276277278279280function QRExport(props: { onBack: () => void }) {281 const [pass, setPass] = createSignal("");282 const [confirm, setConfirm] = createSignal("");283 const [working, setWorking] = createSignal(false);284 const [pngUrl, setPngUrl] = createSignal<string | null>(null);285 const [error, setError] = createSignal<string | null>(null);286 const [entryCount, setEntryCount] = createSignal(0);287 let canvasHost: HTMLDivElement | undefined;288289 const passReady = createMemo(290 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()291 );292293 async function handleEncode() {294 setError(null);295 setPngUrl(null);296 if (!passReady()) {297 setError("Backup passwords don't match or are too short.");298 return;299 }300 setWorking(true);301 try {302 const env = await assembleEnvelope(pass());303 if (env.bytes.length > MAYAN_MAX_BYTES) {304 setError(305 `This vault is too large for a Mayan code backup (${env.bytes.length} bytes). Use PeachScript instead.`,306 );307 return;308 }309 setEntryCount(env.entryCount);310 311 const screen = encodeMayan(env.bytes, 3);312 const print = encodeMayan(env.bytes, 4);313 if (!screen.printable) {314 315 setError(316 `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — too large for one A4 page. Save as PNG for digital backup.`,317 );318 }319 if (canvasHost) {320 canvasHost.innerHTML = "";321 canvasHost.appendChild(screen.canvas);322 }323 setPngUrl(print.canvas.toDataURL("image/png"));324 } catch (err) {325 setError(err instanceof Error ? err.message : String(err));326 } finally {327 setWorking(false);328 }329 }330331 function handleSave() {332 const url = pngUrl();333 if (!url) return;334 const a = document.createElement("a");335 a.href = url;336 a.download = `peach-mayan-backup-${dateStamp()}.png`;337 document.body.appendChild(a);338 a.click();339 document.body.removeChild(a);340 }341342 return (343 <>344 <OverlayHeader title="print backup" onBack={props.onBack} />345 <div class="peach-settings-body">346 <Show347 when={pngUrl()}348 fallback={349 <div class="peach-settings-group peach-export-card peach-export-card--primary">350 <p class="peach-settings-label">backup password</p>351 <p class="peach-export-card-copy">352 Set a password to lock this Mayan code backup. You'll need it353 to restore — without it the printed code is unreadable.354 </p>355 <div class="peach-settings-stack peach-export-password-stack">356 <input357 ref={(el) => queueMicrotask(() => el.focus())}358 class="peach-settings-input"359 type="password"360 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}361 value={pass()}362 onInput={(e) => setPass((e.target as HTMLInputElement).value)}363 onKeyDown={(e) => {364 if (e.key === "Enter" && passReady() && !working()) {365 e.preventDefault();366 void handleEncode();367 }368 }}369 />370 <input371 class="peach-settings-input"372 type="password"373 placeholder="confirm backup password"374 value={confirm()}375 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}376 onKeyDown={(e) => {377 if (e.key === "Enter" && passReady() && !working()) {378 e.preventDefault();379 void handleEncode();380 }381 }}382 />383 </div>384 <button385 class="peach-btn peach-btn--subtle peach-btn--sm"386 onClick={() => void handleEncode()}387 disabled={working() || !passReady()}388 >389 {working() ? "encoding…" : "Generate Mayan code"}390 </button>391 </div>392 }393 >394 <div class="peach-settings-group peach-export-card peach-export-card--primary">395 <div class="peach-qr-canvas-wrap" ref={canvasHost} />396 <p class="peach-export-card-copy" style="text-align:center">397 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}398 </p>399 <button400 class="peach-btn peach-btn--subtle peach-btn--sm"401 onClick={handleSave}402 >403 Save PNG404 </button>405 <p class="peach-export-card-copy" style="margin-top:8px">406 Print this and keep it somewhere safe. Scan it with Peach to407 restore your vault on any device.408 </p>409 </div>410 </Show>411 <Show when={error()}>412 <div class="peach-settings-group">413 <div class="peach-restore-error">{error()}</div>414 </div>415 </Show>416 </div>417 </>418 );419}420421422423424425function CSVExport(props: { onBack: () => void }) {426 const [acknowledged, setAcknowledged] = createSignal(false);427 const [working, setWorking] = createSignal(false);428 const [error, setError] = createSignal<string | null>(null);429 const [done, setDone] = createSignal<string | null>(null);430431 async function handleExport() {432 setError(null);433 setDone(null);434 setWorking(true);435 try {436 const res = await sendMessage({ type: "GET_ENTRIES" });437 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];438 439 440 441 442 const active = entries443 .filter((e) => e.deletedAt === undefined)444 .filter((e) => !(e.passkey && !e.password))445 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));446 const excludedPasskeys = entries.filter(447 (e) => e.deletedAt === undefined && e.passkey448 ).length;449450 if (active.length === 0) {451 setError("Your vault has no entries to export.");452 return;453 }454455 const csv = buildCsv(active);456 const filename = `peach-export-${dateStamp()}.csv`;457 downloadBlob(csv, "text/csv", filename);458 const suffix = excludedPasskeys > 0459 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`460 : "";461 setDone(462 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`463 );464 } catch (err) {465 setError(err instanceof Error ? err.message : String(err));466 } finally {467 setWorking(false);468 }469 }470471 return (472 <>473 <OverlayHeader title="export as CSV" onBack={props.onBack} />474 <div class="peach-settings-body">475 <div class="peach-settings-group peach-export-card">476 <div class="peach-csv-warning">477 This export is unencrypted. Anyone who gets this file can read478 your passwords. Only use this to move to another password479 manager — delete it once the import is done.480 </div>481 <Show when={!acknowledged()}>482 <button483 class="peach-btn peach-btn--ghost peach-btn--sm"484 onClick={() => setAcknowledged(true)}485 >486 I understand — continue487 </button>488 </Show>489 <Show when={acknowledged()}>490 <button491 class="peach-btn peach-btn--subtle peach-btn--sm"492 onClick={() => void handleExport()}493 disabled={working()}494 >495 {working() ? "exporting…" : "Download CSV"}496 </button>497 </Show>498 </div>499 <Show when={error()}>500 <div class="peach-settings-group">501 <div class="peach-restore-error">{error()}</div>502 </div>503 </Show>504 <Show when={done()}>505 <div class="peach-settings-group">506 <div class="peach-export-success">507 <span class="peach-export-success-check">✓</span>508 <span>{done()}</span>509 </div>510 </div>511 </Show>512 </div>513 </>514 );515}516517518519520521function OverlayHeader(props: { title: string; onBack: () => void }) {522 return (523 <div class="peach-overlay-header">524 <button525 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"526 onClick={props.onBack}527 aria-label="Back"528 >529 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">530 <path d="M6 1L1 6L6 11" />531 </svg>532 </button>533 <span class="peach-overlay-title">{props.title}</span>534 <div class="peach-overlay-spacer"></div>535 </div>536 );537}538539interface AssembledEnvelope {540 bytes: Uint8Array;541 entryCount: number;542 stats: { cards: number; passkeys: number; notes: number; logins: number };543}544545async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {546 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });547 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];548549 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });550 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};551552 let license: PeachLicenseExport | undefined;553 try {554 const info = await sendMessage({ type: "GET_LICENSE" });555 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;556 if (data?.valid && data.plan === "pro") {557 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });558 const keyStr = (keyRes as { ok: boolean; data?: string }).data;559 if (keyStr) {560 license = {561 key: keyStr,562 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),563 };564 }565 }566 } catch {567 568 }569570 const stats = countTypes(entries);571 const bytes = await buildPayload({572 entries,573 settings: settings as Parameters<typeof buildPayload>[0]["settings"],574 license,575 passphrase,576 });577 return { bytes, entryCount: entries.length, stats };578}579580function countTypes(entries: VaultEntry[]): {581 cards: number;582 passkeys: number;583 notes: number;584 logins: number;585} {586 let cards = 0;587 let passkeys = 0;588 let notes = 0;589 let logins = 0;590 for (const e of entries) {591 if (e.deletedAt) continue;592 if (e.passkey) {593 passkeys++;594 595 596 if (e.type === "login" && e.password) logins++;597 continue;598 }599 if (e.type === "card") cards++;600 else if (e.type === "note") notes++;601 else logins++;602 }603 return { cards, passkeys, notes, logins };604}605606function buildCsv(entries: VaultEntry[]): string {607 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];608 const rows = entries.map((e) => {609 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";610 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";611 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";612 return [613 e.title,614 e.url ?? "",615 username,616 password,617 e.notes ?? "",618 type,619 Array.isArray(e.tags) ? e.tags.join("|") : "",620 e.totp ?? "",621 ]622 .map((v) =>623 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`624 )625 .join(",");626 });627 return [headers.join(","), ...rows].join("\n");628}629630function dateStamp(): string {631 return new Date().toISOString().split("T")[0];632}633634function monthYear(): string {635 const d = new Date();636 const months = [637 "January", "February", "March", "April", "May", "June",638 "July", "August", "September", "October", "November", "December",639 ];640 return `${months[d.getMonth()]} ${d.getFullYear()}`;641}642643function downloadBlob(content: string, mimeType: string, filename: string): void {644 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));645 const a = document.createElement("a");646 a.href = url;647 a.download = filename;648 document.body.appendChild(a);649 a.click();650 document.body.removeChild(a);651 URL.revokeObjectURL(url);652}