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.paperSize === "A3") {314 setError(315 `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — print on A3 paper for best results.`,316 );317 } else if (screen.paperSize === "A3-required") {318 setError(319 `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — A3 paper required to print at full resolution. Save as PNG for digital backup.`,320 );321 }322 if (canvasHost) {323 canvasHost.innerHTML = "";324 canvasHost.appendChild(screen.canvas);325 }326 setPngUrl(print.canvas.toDataURL("image/png"));327 } catch (err) {328 setError(err instanceof Error ? err.message : String(err));329 } finally {330 setWorking(false);331 }332 }333334 function handleSave() {335 const url = pngUrl();336 if (!url) return;337 const a = document.createElement("a");338 a.href = url;339 a.download = `peach-mayan-backup-${dateStamp()}.png`;340 document.body.appendChild(a);341 a.click();342 document.body.removeChild(a);343 }344345 return (346 <>347 <OverlayHeader title="print backup" onBack={props.onBack} />348 <div class="peach-settings-body">349 <Show350 when={pngUrl()}351 fallback={352 <div class="peach-settings-group peach-export-card peach-export-card--primary">353 <p class="peach-settings-label">backup password</p>354 <p class="peach-export-card-copy">355 Set a password to lock this Mayan code backup. You'll need it356 to restore — without it the printed code is unreadable.357 </p>358 <div class="peach-settings-stack peach-export-password-stack">359 <input360 ref={(el) => queueMicrotask(() => el.focus())}361 class="peach-settings-input"362 type="password"363 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}364 value={pass()}365 onInput={(e) => setPass((e.target as HTMLInputElement).value)}366 onKeyDown={(e) => {367 if (e.key === "Enter" && passReady() && !working()) {368 e.preventDefault();369 void handleEncode();370 }371 }}372 />373 <input374 class="peach-settings-input"375 type="password"376 placeholder="confirm backup password"377 value={confirm()}378 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}379 onKeyDown={(e) => {380 if (e.key === "Enter" && passReady() && !working()) {381 e.preventDefault();382 void handleEncode();383 }384 }}385 />386 </div>387 <button388 class="peach-btn peach-btn--subtle peach-btn--sm"389 onClick={() => void handleEncode()}390 disabled={working() || !passReady()}391 >392 {working() ? "encoding…" : "Generate Mayan code"}393 </button>394 </div>395 }396 >397 <div class="peach-settings-group peach-export-card peach-export-card--primary">398 <div class="peach-qr-canvas-wrap" ref={canvasHost} />399 <p class="peach-export-card-copy" style="text-align:center">400 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}401 </p>402 <button403 class="peach-btn peach-btn--subtle peach-btn--sm"404 onClick={handleSave}405 >406 Save PNG407 </button>408 <p class="peach-export-card-copy" style="margin-top:8px">409 Print this and keep it somewhere safe. Scan it with Peach to410 restore your vault on any device.411 </p>412 </div>413 </Show>414 <Show when={error()}>415 <div class="peach-settings-group">416 <div class="peach-restore-error">{error()}</div>417 </div>418 </Show>419 </div>420 </>421 );422}423424425426427428function CSVExport(props: { onBack: () => void }) {429 const [acknowledged, setAcknowledged] = createSignal(false);430 const [working, setWorking] = createSignal(false);431 const [error, setError] = createSignal<string | null>(null);432 const [done, setDone] = createSignal<string | null>(null);433434 async function handleExport() {435 setError(null);436 setDone(null);437 setWorking(true);438 try {439 const res = await sendMessage({ type: "GET_ENTRIES" });440 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];441 442 443 444 445 const active = entries446 .filter((e) => e.deletedAt === undefined)447 .filter((e) => !(e.passkey && !e.password))448 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));449 const excludedPasskeys = entries.filter(450 (e) => e.deletedAt === undefined && e.passkey451 ).length;452453 if (active.length === 0) {454 setError("Your vault has no entries to export.");455 return;456 }457458 const csv = buildCsv(active);459 const filename = `peach-export-${dateStamp()}.csv`;460 downloadBlob(csv, "text/csv", filename);461 const suffix = excludedPasskeys > 0462 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`463 : "";464 setDone(465 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`466 );467 } catch (err) {468 setError(err instanceof Error ? err.message : String(err));469 } finally {470 setWorking(false);471 }472 }473474 return (475 <>476 <OverlayHeader title="export as CSV" onBack={props.onBack} />477 <div class="peach-settings-body">478 <div class="peach-settings-group peach-export-card">479 <div class="peach-csv-warning">480 This export is unencrypted. Anyone who gets this file can read481 your passwords. Only use this to move to another password482 manager — delete it once the import is done.483 </div>484 <Show when={!acknowledged()}>485 <button486 class="peach-btn peach-btn--ghost peach-btn--sm"487 onClick={() => setAcknowledged(true)}488 >489 I understand — continue490 </button>491 </Show>492 <Show when={acknowledged()}>493 <button494 class="peach-btn peach-btn--subtle peach-btn--sm"495 onClick={() => void handleExport()}496 disabled={working()}497 >498 {working() ? "exporting…" : "Download CSV"}499 </button>500 </Show>501 </div>502 <Show when={error()}>503 <div class="peach-settings-group">504 <div class="peach-restore-error">{error()}</div>505 </div>506 </Show>507 <Show when={done()}>508 <div class="peach-settings-group">509 <div class="peach-export-success">510 <span class="peach-export-success-check">✓</span>511 <span>{done()}</span>512 </div>513 </div>514 </Show>515 </div>516 </>517 );518}519520521522523524function OverlayHeader(props: { title: string; onBack: () => void }) {525 return (526 <div class="peach-overlay-header">527 <button528 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"529 onClick={props.onBack}530 aria-label="Back"531 >532 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">533 <path d="M6 1L1 6L6 11" />534 </svg>535 </button>536 <span class="peach-overlay-title">{props.title}</span>537 <div class="peach-overlay-spacer"></div>538 </div>539 );540}541542interface AssembledEnvelope {543 bytes: Uint8Array;544 entryCount: number;545 stats: { cards: number; passkeys: number; notes: number; logins: number };546}547548async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {549 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });550 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];551552 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });553 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};554555 let license: PeachLicenseExport | undefined;556 try {557 const info = await sendMessage({ type: "GET_LICENSE" });558 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;559 if (data?.valid && data.plan === "pro") {560 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });561 const keyStr = (keyRes as { ok: boolean; data?: string }).data;562 if (keyStr) {563 license = {564 key: keyStr,565 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),566 };567 }568 }569 } catch {570 571 }572573 const stats = countTypes(entries);574 const bytes = await buildPayload({575 entries,576 settings: settings as Parameters<typeof buildPayload>[0]["settings"],577 license,578 passphrase,579 });580 return { bytes, entryCount: entries.length, stats };581}582583function countTypes(entries: VaultEntry[]): {584 cards: number;585 passkeys: number;586 notes: number;587 logins: number;588} {589 let cards = 0;590 let passkeys = 0;591 let notes = 0;592 let logins = 0;593 for (const e of entries) {594 if (e.deletedAt) continue;595 if (e.passkey) {596 passkeys++;597 598 599 if (e.type === "login" && e.password) logins++;600 continue;601 }602 if (e.type === "card") cards++;603 else if (e.type === "note") notes++;604 else logins++;605 }606 return { cards, passkeys, notes, logins };607}608609function buildCsv(entries: VaultEntry[]): string {610 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];611 const rows = entries.map((e) => {612 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";613 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";614 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";615 return [616 e.title,617 e.url ?? "",618 username,619 password,620 e.notes ?? "",621 type,622 Array.isArray(e.tags) ? e.tags.join("|") : "",623 e.totp ?? "",624 ]625 .map((v) =>626 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`627 )628 .join(",");629 });630 return [headers.join(","), ...rows].join("\n");631}632633function dateStamp(): string {634 return new Date().toISOString().split("T")[0];635}636637function monthYear(): string {638 const d = new Date();639 const months = [640 "January", "February", "March", "April", "May", "June",641 "July", "August", "September", "October", "November", "December",642 ];643 return `${months[d.getMonth()]} ${d.getFullYear()}`;644}645646function downloadBlob(content: string, mimeType: string, filename: string): void {647 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));648 const a = document.createElement("a");649 a.href = url;650 a.download = filename;651 document.body.appendChild(a);652 a.click();653 document.body.removeChild(a);654 URL.revokeObjectURL(url);655}