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}181920212223type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } };24let _zxcvbn: ZxcvbnFn | null = null;25async function getZxcvbn(): Promise<ZxcvbnFn> {26 if (_zxcvbn) return _zxcvbn;27 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([28 import("@zxcvbn-ts/core"),29 import("@zxcvbn-ts/language-common"),30 import("@zxcvbn-ts/language-en"),31 ]);32 zxcvbnOptions.setOptions({33 translations: en.translations,34 graphs: common.adjacencyGraphs,35 dictionary: { ...common.dictionary, ...en.dictionary },36 });37 _zxcvbn = (pw: string) => zxcvbn(pw);38 return _zxcvbn;39}4041424344const STRENGTH_REQUIRED = 3;4546export function ExportView(props: ExportProps) {47 const [view, setView] = createSignal<ExportSubview>("menu");48 return (49 <div class="peach-overlay-view ov-enter-right" style="display:flex">50 <Show when={view() === "menu"}>51 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />52 </Show>53 <Show when={view() === "peachscript"}>54 <PeachScriptExport onBack={() => setView("menu")} />55 </Show>56 <Show when={view() === "csv"}>57 <CSVExport onBack={() => setView("menu")} />58 </Show>59 </div>60 );61}626364656667function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {68 return (69 <>70 <OverlayHeader title="export" onBack={props.onBack} />71 <div class="peach-settings-body peach-export-menu">72 <div class="peach-settings-group peach-export-card peach-export-card--primary">73 <p class="peach-settings-label">peachscript</p>74 <p class="peach-export-card-copy">75 Your vault as a string of Peach symbols you can paste into Peach76 on another device. Designed for Peach-to-Peach transfer.77 </p>78 <button79 class="peach-btn peach-btn--subtle peach-btn--sm"80 onClick={() => props.onPick("peachscript")}81 >82 Create PeachScript →83 </button>84 </div>8586 <div class="peach-settings-group peach-export-card">87 <p class="peach-settings-label">CSV (leaving peach)</p>88 <p class="peach-export-card-copy">89 Plain-text export for moving into another password manager. Anyone90 with the file can read your passwords.91 </p>92 <button93 class="peach-btn peach-btn--ghost peach-btn--sm"94 onClick={() => props.onPick("csv")}95 >96 Export as CSV →97 </button>98 </div>99 </div>100 </>101 );102}103104105106107108function PeachScriptExport(props: { onBack: () => void }) {109 const [pass, setPass] = createSignal("");110 const [confirm, setConfirm] = createSignal("");111 const [strengthScore, setStrengthScore] = createSignal<number>(0);112 const [strengthHint, setStrengthHint] = createSignal<string>("");113 const [working, setWorking] = createSignal(false);114 const [script, setScript] = createSignal<string | null>(null);115 const [error, setError] = createSignal<string | null>(null);116 const [entryCount, setEntryCount] = createSignal(0);117 const [stats, setStats] = createSignal<{118 cards: number;119 passkeys: number;120 notes: number;121 logins: number;122 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });123 const [copied, setCopied] = createSignal(false);124125 const passReady = createMemo(126 () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()127 );128129 function handlePassInput(value: string) {130 setPass(value);131 if (!value) {132 setStrengthScore(0);133 setStrengthHint("");134 return;135 }136 137 void getZxcvbn().then((zxcvbn) => {138 if (pass() !== value) return; 139 const result = zxcvbn(value);140 setStrengthScore(result.score);141 const hint = result.feedback.warning || result.feedback.suggestions[0] || "";142 setStrengthHint(hint);143 });144 }145146 async function handleEncode() {147 setError(null);148 setScript(null);149 setCopied(false);150 if (!passReady()) {151 setError("Backup passwords don't match or are too short.");152 return;153 }154 setWorking(true);155 try {156 const env = await assembleEnvelope(pass());157 setEntryCount(env.entryCount);158 setStats(env.stats);159 160 161 162 setScript(encodePeachScript(env.bytes));163 } catch (err) {164 setError(165 err instanceof Error && err.message === "PEACHSCRIPT_TOO_LARGE"166 ? "This vault is too large for a PeachScript backup — use Codex export instead."167 : "Couldn't create PeachScript backup.",168 );169 } finally {170 setWorking(false);171 }172 }173174 async function handleCopy() {175 const s = script();176 if (!s) return;177 try {178 await navigator.clipboard.writeText(s);179 setCopied(true);180 setTimeout(() => setCopied(false), 2000);181 } catch (err) {182 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");183 }184 }185186 function handleDownload() {187 const s = script();188 if (!s) return;189 190 191 192 193 194 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });195 const url = URL.createObjectURL(blob);196 const date = new Date().toISOString().slice(0, 10);197 const a = document.createElement("a");198 a.href = url;199 a.download = `peach-backup-${date}.peach`;200 document.body.appendChild(a);201 a.click();202 document.body.removeChild(a);203 setTimeout(() => URL.revokeObjectURL(url), 5000);204 }205206 return (207 <>208 <OverlayHeader title="peachscript" onBack={props.onBack} />209 <div class="peach-settings-body">210 <Show211 when={script()}212 fallback={213 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">214 <p class="peach-settings-label">backup password</p>215 <p class="peach-export-card-copy">216 Choose a password to lock this PeachScript. You'll need it217 to restore on another device.218 </p>219 <div class="peach-settings-stack peach-export-password-stack">220 <input221 ref={(el) => queueMicrotask(() => el.focus())}222 class="peach-settings-input"223 type="password"224 placeholder="backup password"225 value={pass()}226 onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}227 onKeyDown={(e) => {228 if (e.key === "Enter" && passReady() && !working()) {229 e.preventDefault();230 void handleEncode();231 }232 }}233 />234 <Show when={pass().length > 0}>235 <div class="peach-export-strength-track">236 <div237 class="peach-export-strength-fill"238 style={{239 width: `${(strengthScore() / 4) * 100}%`,240 background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],241 }}242 />243 </div>244 <Show when={strengthScore() < STRENGTH_REQUIRED}>245 <p class="peach-export-strength-warn">246 {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}247 </p>248 </Show>249 <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>250 <p class="peach-export-strength-hint">{strengthHint()}</p>251 </Show>252 </Show>253 <input254 class="peach-settings-input"255 type="password"256 placeholder="confirm backup password"257 value={confirm()}258 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}259 onKeyDown={(e) => {260 if (e.key === "Enter" && passReady() && !working()) {261 e.preventDefault();262 void handleEncode();263 }264 }}265 />266 <Show when={confirm().length > 0 && pass() !== confirm()}>267 <p class="peach-export-note" style="color:var(--peach-t2)">268 Passwords don't match269 </p>270 </Show>271 </div>272 <button273 class="peach-btn peach-btn--subtle peach-btn--sm"274 onClick={() => void handleEncode()}275 disabled={working() || !passReady()}276 >277 {working() ? "encoding…" : "Generate PeachScript"}278 </button>279 </div>280 }281 >282 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">283 <p class="peach-settings-label">peachscript</p>284 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />285 <p class="peach-export-card-copy" style="margin:0 0 8px">286 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}287 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}288 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}289 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}290 </p>291 <div style="display:flex;gap:8px;">292 <button293 class="peach-btn peach-btn--subtle peach-btn--sm"294 style="flex:1;"295 onClick={() => void handleCopy()}296 >297 {copied() ? "copied ✓" : "Copy"}298 </button>299 <button300 class="peach-btn peach-btn--subtle peach-btn--sm"301 style="flex:1;"302 onClick={handleDownload}303 >304 Download .peach305 </button>306 </div>307 <p class="peach-export-card-copy" style="margin-top:8px">308 Paste the script — or import the .peach file — into Peach on309 your other device to restore your vault. Won't work anywhere else.310 </p>311 </div>312 </Show>313 <Show when={error()}>314 <div class="peach-settings-group">315 <div class="peach-restore-error">{error()}</div>316 </div>317 </Show>318 </div>319 </>320 );321}322323324325326327function CSVExport(props: { onBack: () => void }) {328 const [acknowledged, setAcknowledged] = createSignal(false);329 const [masterPassword, setMasterPassword] = createSignal("");330 const [working, setWorking] = createSignal(false);331 const [error, setError] = createSignal<string | null>(null);332 const [done, setDone] = createSignal<string | null>(null);333334 async function handleExport() {335 setError(null);336 setDone(null);337 setWorking(true);338 try {339 const res = await sendMessage({ type: "EXPORT_ENTRIES", payload: { password: masterPassword() } });340 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];341 342 343 344 345 const active = entries346 .filter((e) => e.deletedAt === undefined)347 .filter((e) => !(e.passkey && !e.password))348 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));349 const excludedPasskeys = entries.filter(350 (e) => e.deletedAt === undefined && e.passkey351 ).length;352353 if (active.length === 0) {354 setError("Your vault has no entries to export.");355 return;356 }357358 const csv = buildCsv(active);359 const filename = `peach-export-${dateStamp()}.csv`;360 downloadBlob(csv, "text/csv", filename);361 const suffix = excludedPasskeys > 0362 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`363 : "";364 setDone(365 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`366 );367 } catch (err) {368 setError(err instanceof Error ? err.message : String(err));369 } finally {370 setWorking(false);371 }372 }373374 return (375 <>376 <OverlayHeader title="export as CSV" onBack={props.onBack} />377 <div class="peach-settings-body">378 <div class="peach-settings-group peach-export-card">379 <div class="peach-csv-warning">380 This export is unencrypted. Anyone who gets this file can read381 your passwords. Only use this to move to another password382 manager — delete it once the import is done.383 </div>384 <Show when={!acknowledged()}>385 <button386 class="peach-btn peach-btn--ghost peach-btn--sm"387 onClick={() => setAcknowledged(true)}388 >389 I understand — continue390 </button>391 </Show>392 <Show when={acknowledged()}>393 <label class="peach-settings-label" for="csv-export-password">master password</label>394 <input395 id="csv-export-password"396 class="peach-settings-input"397 type="password"398 value={masterPassword()}399 onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}400 placeholder="enter your master password"401 />402 <button403 class="peach-btn peach-btn--subtle peach-btn--sm"404 onClick={() => void handleExport()}405 disabled={working()}406 >407 {working() ? "exporting…" : "Download CSV"}408 </button>409 </Show>410 </div>411 <Show when={error()}>412 <div class="peach-settings-group">413 <div class="peach-restore-error">{error()}</div>414 </div>415 </Show>416 <Show when={done()}>417 <div class="peach-settings-group">418 <div class="peach-export-success">419 <span class="peach-export-success-check">✓</span>420 <span>{done()}</span>421 </div>422 </div>423 </Show>424 </div>425 </>426 );427}428429430431432433function OverlayHeader(props: { title: string; onBack: () => void }) {434 return (435 <div class="peach-overlay-header">436 <button437 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"438 onClick={props.onBack}439 aria-label="Back"440 >441 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">442 <path d="M6 1L1 6L6 11" />443 </svg>444 </button>445 <span class="peach-overlay-title">{props.title}</span>446 <div class="peach-overlay-spacer"></div>447 </div>448 );449}450451interface AssembledEnvelope {452 bytes: Uint8Array;453 entryCount: number;454 stats: { cards: number; passkeys: number; notes: number; logins: number };455}456457async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {458 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });459 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];460461 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });462 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};463464 let license: PeachLicenseExport | undefined;465 try {466 const info = await sendMessage({ type: "GET_LICENSE" });467 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;468 if (data?.valid && data.plan === "pro") {469 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });470 const keyStr = (keyRes as { ok: boolean; data?: string }).data;471 if (keyStr) {472 license = {473 key: keyStr,474 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),475 };476 }477 }478 } catch {479 480 }481482 const stats = countTypes(entries);483 const bytes = await buildPayload({484 entries,485 settings: settings as Parameters<typeof buildPayload>[0]["settings"],486 license,487 passphrase,488 });489 return { bytes, entryCount: entries.length, stats };490}491492function countTypes(entries: VaultEntry[]): {493 cards: number;494 passkeys: number;495 notes: number;496 logins: number;497} {498 let cards = 0;499 let passkeys = 0;500 let notes = 0;501 let logins = 0;502 for (const e of entries) {503 if (e.deletedAt) continue;504 if (e.passkey) {505 passkeys++;506 507 508 if (e.type === "login" && e.password) logins++;509 continue;510 }511 if (e.type === "card") cards++;512 else if (e.type === "note") notes++;513 else logins++;514 }515 return { cards, passkeys, notes, logins };516}517518519520521522523524export function csvSafe(value: string): string {525 return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value;526}527528function buildCsv(entries: VaultEntry[]): string {529 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];530 const rows = entries.map((e) => {531 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";532 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";533 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";534 return [535 e.title,536 e.url ?? "",537 username,538 password,539 e.notes ?? "",540 type,541 Array.isArray(e.tags) ? e.tags.join("|") : "",542 e.totp ?? "",543 ]544 .map((v) =>545 `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`546 )547 .join(",");548 });549 return [headers.join(","), ...rows].join("\n");550}551552function dateStamp(): string {553 return new Date().toISOString().split("T")[0];554}555556function downloadBlob(content: string, mimeType: string, filename: string): void {557 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));558 const a = document.createElement("a");559 a.href = url;560 a.download = filename;561 document.body.appendChild(a);562 a.click();563 document.body.removeChild(a);564 URL.revokeObjectURL(url);565}