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 type { PeachLicenseExport, VaultEntry } from "../../shared/types";1213type ExportSubview = "menu" | "peachscript" | "csv";1415interface ExportProps {16 onBack: () => void;17}1819const MIN_BACKUP_PASSWORD = 8;2021export function ExportView(props: ExportProps) {22 const [view, setView] = createSignal<ExportSubview>("menu");23 return (24 <div class="peach-overlay-view ov-enter-right" style="display:flex">25 <Show when={view() === "menu"}>26 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />27 </Show>28 <Show when={view() === "peachscript"}>29 <PeachScriptExport onBack={() => setView("menu")} />30 </Show>31 <Show when={view() === "csv"}>32 <CSVExport onBack={() => setView("menu")} />33 </Show>34 </div>35 );36}373839404142function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {43 return (44 <>45 <OverlayHeader title="export" onBack={props.onBack} />46 <div class="peach-settings-body peach-export-menu">47 <div class="peach-settings-group peach-export-card peach-export-card--primary">48 <p class="peach-settings-label">peachscript</p>49 <p class="peach-export-card-copy">50 Your vault as a string of Peach symbols you can paste into Peach51 on another device. Designed for Peach-to-Peach transfer.52 </p>53 <button54 class="peach-btn peach-btn--subtle peach-btn--sm"55 onClick={() => props.onPick("peachscript")}56 >57 Create PeachScript →58 </button>59 </div>6061 <div class="peach-settings-group peach-export-card">62 <p class="peach-settings-label">CSV (leaving peach)</p>63 <p class="peach-export-card-copy">64 Plain-text export for moving into another password manager. Anyone65 with the file can read your passwords.66 </p>67 <button68 class="peach-btn peach-btn--ghost peach-btn--sm"69 onClick={() => props.onPick("csv")}70 >71 Export as CSV →72 </button>73 </div>74 </div>75 </>76 );77}787980818283function PeachScriptExport(props: { onBack: () => void }) {84 const [pass, setPass] = createSignal("");85 const [confirm, setConfirm] = createSignal("");86 const [working, setWorking] = createSignal(false);87 const [script, setScript] = createSignal<string | null>(null);88 const [error, setError] = createSignal<string | null>(null);89 const [entryCount, setEntryCount] = createSignal(0);90 const [stats, setStats] = createSignal<{91 cards: number;92 passkeys: number;93 notes: number;94 logins: number;95 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });96 const [copied, setCopied] = createSignal(false);9798 const passReady = createMemo(99 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()100 );101102 async function handleEncode() {103 setError(null);104 setScript(null);105 setCopied(false);106 if (!passReady()) {107 setError("Backup passwords don't match or are too short.");108 return;109 }110 setWorking(true);111 try {112 const env = await assembleEnvelope(pass());113 setEntryCount(env.entryCount);114 setStats(env.stats);115 setScript(encodePeachScript(env.bytes));116 } catch (err) {117 setError(err instanceof Error ? err.message : String(err));118 } finally {119 setWorking(false);120 }121 }122123 async function handleCopy() {124 const s = script();125 if (!s) return;126 try {127 await navigator.clipboard.writeText(s);128 setCopied(true);129 setTimeout(() => setCopied(false), 2000);130 } catch (err) {131 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");132 }133 }134135 function handleDownload() {136 const s = script();137 if (!s) return;138 139 140 141 142 143 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });144 const url = URL.createObjectURL(blob);145 const date = new Date().toISOString().slice(0, 10);146 const a = document.createElement("a");147 a.href = url;148 a.download = `peach-backup-${date}.peach`;149 document.body.appendChild(a);150 a.click();151 document.body.removeChild(a);152 setTimeout(() => URL.revokeObjectURL(url), 5000);153 }154155 return (156 <>157 <OverlayHeader title="peachscript" onBack={props.onBack} />158 <div class="peach-settings-body">159 <Show160 when={script()}161 fallback={162 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">163 <p class="peach-settings-label">backup password</p>164 <p class="peach-export-card-copy">165 Choose a password to lock this PeachScript. You'll need it166 to restore on another device.167 </p>168 <div class="peach-settings-stack peach-export-password-stack">169 <input170 ref={(el) => queueMicrotask(() => el.focus())}171 class="peach-settings-input"172 type="password"173 placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}174 value={pass()}175 onInput={(e) => setPass((e.target as HTMLInputElement).value)}176 onKeyDown={(e) => {177 if (e.key === "Enter" && passReady() && !working()) {178 e.preventDefault();179 void handleEncode();180 }181 }}182 />183 <input184 class="peach-settings-input"185 type="password"186 placeholder="confirm backup password"187 value={confirm()}188 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}189 onKeyDown={(e) => {190 if (e.key === "Enter" && passReady() && !working()) {191 e.preventDefault();192 void handleEncode();193 }194 }}195 />196 <Show when={confirm().length > 0 && pass() !== confirm()}>197 <p class="peach-export-note" style="color:var(--peach-t2)">198 Passwords don't match199 </p>200 </Show>201 <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>202 <p class="peach-export-note" style="color:var(--peach-t2)">203 At least {MIN_BACKUP_PASSWORD} characters204 </p>205 </Show>206 </div>207 <button208 class="peach-btn peach-btn--subtle peach-btn--sm"209 onClick={() => void handleEncode()}210 disabled={working() || !passReady()}211 >212 {working() ? "encoding…" : "Generate PeachScript"}213 </button>214 </div>215 }216 >217 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">218 <p class="peach-settings-label">peachscript</p>219 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />220 <p class="peach-export-card-copy" style="margin:0 0 8px">221 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}222 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}223 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}224 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}225 </p>226 <div style="display:flex;gap:8px;">227 <button228 class="peach-btn peach-btn--subtle peach-btn--sm"229 style="flex:1;"230 onClick={() => void handleCopy()}231 >232 {copied() ? "copied ✓" : "Copy"}233 </button>234 <button235 class="peach-btn peach-btn--subtle peach-btn--sm"236 style="flex:1;"237 onClick={handleDownload}238 >239 Download .peach240 </button>241 </div>242 <p class="peach-export-card-copy" style="margin-top:8px">243 Paste the script — or import the .peach file — into Peach on244 your other device to restore your vault. Won't work anywhere else.245 </p>246 </div>247 </Show>248 <Show when={error()}>249 <div class="peach-settings-group">250 <div class="peach-restore-error">{error()}</div>251 </div>252 </Show>253 </div>254 </>255 );256}257258259260261262function CSVExport(props: { onBack: () => void }) {263 const [acknowledged, setAcknowledged] = createSignal(false);264 const [working, setWorking] = createSignal(false);265 const [error, setError] = createSignal<string | null>(null);266 const [done, setDone] = createSignal<string | null>(null);267268 async function handleExport() {269 setError(null);270 setDone(null);271 setWorking(true);272 try {273 const res = await sendMessage({ type: "GET_ENTRIES" });274 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];275 276 277 278 279 const active = entries280 .filter((e) => e.deletedAt === undefined)281 .filter((e) => !(e.passkey && !e.password))282 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));283 const excludedPasskeys = entries.filter(284 (e) => e.deletedAt === undefined && e.passkey285 ).length;286287 if (active.length === 0) {288 setError("Your vault has no entries to export.");289 return;290 }291292 const csv = buildCsv(active);293 const filename = `peach-export-${dateStamp()}.csv`;294 downloadBlob(csv, "text/csv", filename);295 const suffix = excludedPasskeys > 0296 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`297 : "";298 setDone(299 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`300 );301 } catch (err) {302 setError(err instanceof Error ? err.message : String(err));303 } finally {304 setWorking(false);305 }306 }307308 return (309 <>310 <OverlayHeader title="export as CSV" onBack={props.onBack} />311 <div class="peach-settings-body">312 <div class="peach-settings-group peach-export-card">313 <div class="peach-csv-warning">314 This export is unencrypted. Anyone who gets this file can read315 your passwords. Only use this to move to another password316 manager — delete it once the import is done.317 </div>318 <Show when={!acknowledged()}>319 <button320 class="peach-btn peach-btn--ghost peach-btn--sm"321 onClick={() => setAcknowledged(true)}322 >323 I understand — continue324 </button>325 </Show>326 <Show when={acknowledged()}>327 <button328 class="peach-btn peach-btn--subtle peach-btn--sm"329 onClick={() => void handleExport()}330 disabled={working()}331 >332 {working() ? "exporting…" : "Download CSV"}333 </button>334 </Show>335 </div>336 <Show when={error()}>337 <div class="peach-settings-group">338 <div class="peach-restore-error">{error()}</div>339 </div>340 </Show>341 <Show when={done()}>342 <div class="peach-settings-group">343 <div class="peach-export-success">344 <span class="peach-export-success-check">✓</span>345 <span>{done()}</span>346 </div>347 </div>348 </Show>349 </div>350 </>351 );352}353354355356357358function OverlayHeader(props: { title: string; onBack: () => void }) {359 return (360 <div class="peach-overlay-header">361 <button362 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"363 onClick={props.onBack}364 aria-label="Back"365 >366 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">367 <path d="M6 1L1 6L6 11" />368 </svg>369 </button>370 <span class="peach-overlay-title">{props.title}</span>371 <div class="peach-overlay-spacer"></div>372 </div>373 );374}375376interface AssembledEnvelope {377 bytes: Uint8Array;378 entryCount: number;379 stats: { cards: number; passkeys: number; notes: number; logins: number };380}381382async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {383 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });384 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];385386 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });387 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};388389 let license: PeachLicenseExport | undefined;390 try {391 const info = await sendMessage({ type: "GET_LICENSE" });392 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;393 if (data?.valid && data.plan === "pro") {394 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });395 const keyStr = (keyRes as { ok: boolean; data?: string }).data;396 if (keyStr) {397 license = {398 key: keyStr,399 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),400 };401 }402 }403 } catch {404 405 }406407 const stats = countTypes(entries);408 const bytes = await buildPayload({409 entries,410 settings: settings as Parameters<typeof buildPayload>[0]["settings"],411 license,412 passphrase,413 });414 return { bytes, entryCount: entries.length, stats };415}416417function countTypes(entries: VaultEntry[]): {418 cards: number;419 passkeys: number;420 notes: number;421 logins: number;422} {423 let cards = 0;424 let passkeys = 0;425 let notes = 0;426 let logins = 0;427 for (const e of entries) {428 if (e.deletedAt) continue;429 if (e.passkey) {430 passkeys++;431 432 433 if (e.type === "login" && e.password) logins++;434 continue;435 }436 if (e.type === "card") cards++;437 else if (e.type === "note") notes++;438 else logins++;439 }440 return { cards, passkeys, notes, logins };441}442443function buildCsv(entries: VaultEntry[]): string {444 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];445 const rows = entries.map((e) => {446 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";447 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";448 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";449 return [450 e.title,451 e.url ?? "",452 username,453 password,454 e.notes ?? "",455 type,456 Array.isArray(e.tags) ? e.tags.join("|") : "",457 e.totp ?? "",458 ]459 .map((v) =>460 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`461 )462 .join(",");463 });464 return [headers.join(","), ...rows].join("\n");465}466467function dateStamp(): string {468 return new Date().toISOString().split("T")[0];469}470471function downloadBlob(content: string, mimeType: string, filename: string): void {472 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));473 const a = document.createElement("a");474 a.href = url;475 a.download = filename;476 document.body.appendChild(a);477 a.click();478 document.body.removeChild(a);479 URL.revokeObjectURL(url);480}