12345import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { buildPayload } from "../../crypto/peachPayload";9import { encodePeachScript } from "../../crypto/peachScript";10import { IMPORT_LIMITS } from "../../shared/import-limits";11import { RuneCanvas } from "../components/RuneCanvas";12import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1314type ExportSubview = "menu" | "peachscript" | "csv";1516interface ExportProps {17 onBack: () => void;18}192021222324type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } };25let _zxcvbn: ZxcvbnFn | null = null;26async function getZxcvbn(): Promise<ZxcvbnFn> {27 if (_zxcvbn) return _zxcvbn;28 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([29 import("@zxcvbn-ts/core"),30 import("@zxcvbn-ts/language-common"),31 import("@zxcvbn-ts/language-en"),32 ]);33 zxcvbnOptions.setOptions({34 translations: en.translations,35 graphs: common.adjacencyGraphs,36 dictionary: { ...common.dictionary, ...en.dictionary },37 });38 _zxcvbn = (pw: string) => zxcvbn(pw);39 return _zxcvbn;40}4142434445const STRENGTH_REQUIRED = 3;4647export function ExportView(props: ExportProps) {48 const [view, setView] = createSignal<ExportSubview>("menu");49 return (50 <div class="peach-overlay-view ov-enter-right" style="display:flex">51 <Show when={view() === "menu"}>52 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />53 </Show>54 <Show when={view() === "peachscript"}>55 <PeachScriptExport onBack={() => setView("menu")} />56 </Show>57 <Show when={view() === "csv"}>58 <CSVExport onBack={() => setView("menu")} />59 </Show>60 </div>61 );62}636465666768function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {69 return (70 <>71 <OverlayHeader title="export" onBack={props.onBack} />72 <div class="peach-settings-body peach-export-menu">73 <div class="peach-settings-group peach-export-card peach-export-card--primary">74 <p class="peach-settings-label">peachscript</p>75 <p class="peach-export-card-copy">76 Your vault as a string of Peach symbols you can paste into Peach77 on another device. Designed for Peach-to-Peach transfer.78 </p>79 <button80 class="peach-btn peach-btn--subtle peach-btn--sm"81 onClick={() => props.onPick("peachscript")}82 >83 Create PeachScript →84 </button>85 </div>8687 <div class="peach-settings-group peach-export-card">88 <p class="peach-settings-label">CSV (leaving peach)</p>89 <p class="peach-export-card-copy">90 Plain-text export for moving into another password manager. Anyone91 with the file can read your passwords.92 </p>93 <button94 class="peach-btn peach-btn--ghost peach-btn--sm"95 onClick={() => props.onPick("csv")}96 >97 Export as CSV →98 </button>99 </div>100 </div>101 </>102 );103}104105106107108109function PeachScriptExport(props: { onBack: () => void }) {110 const [pass, setPass] = createSignal("");111 const [confirm, setConfirm] = createSignal("");112 const [strengthScore, setStrengthScore] = createSignal<number>(0);113 const [strengthHint, setStrengthHint] = createSignal<string>("");114 const [working, setWorking] = createSignal(false);115 const [script, setScript] = createSignal<string | null>(null);116 const [error, setError] = createSignal<string | null>(null);117 const [entryCount, setEntryCount] = createSignal(0);118 const [stats, setStats] = createSignal<{119 cards: number;120 passkeys: number;121 notes: number;122 logins: number;123 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });124 const [copied, setCopied] = createSignal(false);125126 const passReady = createMemo(127 () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()128 );129130 function handlePassInput(value: string) {131 setPass(value);132 if (!value) {133 setStrengthScore(0);134 setStrengthHint("");135 return;136 }137 138 void getZxcvbn().then((zxcvbn) => {139 if (pass() !== value) return; 140 const result = zxcvbn(value);141 setStrengthScore(result.score);142 const hint = result.feedback.warning || result.feedback.suggestions[0] || "";143 setStrengthHint(hint);144 });145 }146147 async function handleEncode() {148 setError(null);149 setScript(null);150 setCopied(false);151 if (!passReady()) {152 setError("Backup passwords don't match or are too short.");153 return;154 }155 setWorking(true);156 try {157 const env = await assembleEnvelope(pass());158 setEntryCount(env.entryCount);159 setStats(env.stats);160 if (env.bytes.length > IMPORT_LIMITS.MAX_JSON_BYTES) {161 throw new Error("PEACHSCRIPT_EXPORT_TOO_LARGE");162 }163 setScript(encodePeachScript(env.bytes));164 } catch (err) {165 setError(166 err instanceof Error && err.message === "PEACHSCRIPT_EXPORT_TOO_LARGE"167 ? "This backup is too large to import later (limit: 10 MiB)."168 : "Couldn't create PeachScript backup.",169 );170 } finally {171 setWorking(false);172 }173 }174175 async function handleCopy() {176 const s = script();177 if (!s) return;178 try {179 await navigator.clipboard.writeText(s);180 setCopied(true);181 setTimeout(() => setCopied(false), 2000);182 } catch (err) {183 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");184 }185 }186187 function handleDownload() {188 const s = script();189 if (!s) return;190 191 192 193 194 195 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });196 const url = URL.createObjectURL(blob);197 const date = new Date().toISOString().slice(0, 10);198 const a = document.createElement("a");199 a.href = url;200 a.download = `peach-backup-${date}.peach`;201 document.body.appendChild(a);202 a.click();203 document.body.removeChild(a);204 setTimeout(() => URL.revokeObjectURL(url), 5000);205 }206207 return (208 <>209 <OverlayHeader title="peachscript" onBack={props.onBack} />210 <div class="peach-settings-body">211 <Show212 when={script()}213 fallback={214 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">215 <p class="peach-settings-label">backup password</p>216 <p class="peach-export-card-copy">217 Choose a password to lock this PeachScript. You'll need it218 to restore on another device.219 </p>220 <div class="peach-settings-stack peach-export-password-stack">221 <input222 ref={(el) => queueMicrotask(() => el.focus())}223 class="peach-settings-input"224 type="password"225 placeholder="backup password"226 value={pass()}227 onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}228 onKeyDown={(e) => {229 if (e.key === "Enter" && passReady() && !working()) {230 e.preventDefault();231 void handleEncode();232 }233 }}234 />235 <Show when={pass().length > 0}>236 <div class="peach-export-strength-track">237 <div238 class="peach-export-strength-fill"239 style={{240 width: `${(strengthScore() / 4) * 100}%`,241 background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],242 }}243 />244 </div>245 <Show when={strengthScore() < STRENGTH_REQUIRED}>246 <p class="peach-export-strength-warn">247 {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}248 </p>249 </Show>250 <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>251 <p class="peach-export-strength-hint">{strengthHint()}</p>252 </Show>253 </Show>254 <input255 class="peach-settings-input"256 type="password"257 placeholder="confirm backup password"258 value={confirm()}259 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}260 onKeyDown={(e) => {261 if (e.key === "Enter" && passReady() && !working()) {262 e.preventDefault();263 void handleEncode();264 }265 }}266 />267 <Show when={confirm().length > 0 && pass() !== confirm()}>268 <p class="peach-export-note" style="color:var(--peach-t2)">269 Passwords don't match270 </p>271 </Show>272 </div>273 <button274 class="peach-btn peach-btn--subtle peach-btn--sm"275 onClick={() => void handleEncode()}276 disabled={working() || !passReady()}277 >278 {working() ? "encoding…" : "Generate PeachScript"}279 </button>280 </div>281 }282 >283 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">284 <p class="peach-settings-label">peachscript</p>285 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />286 <p class="peach-export-card-copy" style="margin:0 0 8px">287 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}288 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}289 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}290 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}291 </p>292 <div style="display:flex;gap:8px;">293 <button294 class="peach-btn peach-btn--subtle peach-btn--sm"295 style="flex:1;"296 onClick={() => void handleCopy()}297 >298 {copied() ? "copied ✓" : "Copy"}299 </button>300 <button301 class="peach-btn peach-btn--subtle peach-btn--sm"302 style="flex:1;"303 onClick={handleDownload}304 >305 Download .peach306 </button>307 </div>308 <p class="peach-export-card-copy" style="margin-top:8px">309 Paste the script — or import the .peach file — into Peach on310 your other device to restore your vault. Won't work anywhere else.311 </p>312 </div>313 </Show>314 <Show when={error()}>315 <div class="peach-settings-group">316 <div class="peach-restore-error">{error()}</div>317 </div>318 </Show>319 </div>320 </>321 );322}323324325326327328function CSVExport(props: { onBack: () => void }) {329 const [acknowledged, setAcknowledged] = createSignal(false);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: "GET_ENTRIES" });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 <button394 class="peach-btn peach-btn--subtle peach-btn--sm"395 onClick={() => void handleExport()}396 disabled={working()}397 >398 {working() ? "exporting…" : "Download CSV"}399 </button>400 </Show>401 </div>402 <Show when={error()}>403 <div class="peach-settings-group">404 <div class="peach-restore-error">{error()}</div>405 </div>406 </Show>407 <Show when={done()}>408 <div class="peach-settings-group">409 <div class="peach-export-success">410 <span class="peach-export-success-check">✓</span>411 <span>{done()}</span>412 </div>413 </div>414 </Show>415 </div>416 </>417 );418}419420421422423424function OverlayHeader(props: { title: string; onBack: () => void }) {425 return (426 <div class="peach-overlay-header">427 <button428 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"429 onClick={props.onBack}430 aria-label="Back"431 >432 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">433 <path d="M6 1L1 6L6 11" />434 </svg>435 </button>436 <span class="peach-overlay-title">{props.title}</span>437 <div class="peach-overlay-spacer"></div>438 </div>439 );440}441442interface AssembledEnvelope {443 bytes: Uint8Array;444 entryCount: number;445 stats: { cards: number; passkeys: number; notes: number; logins: number };446}447448async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {449 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });450 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];451452 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });453 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};454455 let license: PeachLicenseExport | undefined;456 try {457 const info = await sendMessage({ type: "GET_LICENSE" });458 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;459 if (data?.valid && data.plan === "pro") {460 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });461 const keyStr = (keyRes as { ok: boolean; data?: string }).data;462 if (keyStr) {463 license = {464 key: keyStr,465 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),466 };467 }468 }469 } catch {470 471 }472473 const stats = countTypes(entries);474 const bytes = await buildPayload({475 entries,476 settings: settings as Parameters<typeof buildPayload>[0]["settings"],477 license,478 passphrase,479 });480 return { bytes, entryCount: entries.length, stats };481}482483function countTypes(entries: VaultEntry[]): {484 cards: number;485 passkeys: number;486 notes: number;487 logins: number;488} {489 let cards = 0;490 let passkeys = 0;491 let notes = 0;492 let logins = 0;493 for (const e of entries) {494 if (e.deletedAt) continue;495 if (e.passkey) {496 passkeys++;497 498 499 if (e.type === "login" && e.password) logins++;500 continue;501 }502 if (e.type === "card") cards++;503 else if (e.type === "note") notes++;504 else logins++;505 }506 return { cards, passkeys, notes, logins };507}508509510511512513514515export function csvSafe(value: string): string {516 return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value;517}518519function buildCsv(entries: VaultEntry[]): string {520 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];521 const rows = entries.map((e) => {522 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";523 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";524 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";525 return [526 e.title,527 e.url ?? "",528 username,529 password,530 e.notes ?? "",531 type,532 Array.isArray(e.tags) ? e.tags.join("|") : "",533 e.totp ?? "",534 ]535 .map((v) =>536 `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`537 )538 .join(",");539 });540 return [headers.join(","), ...rows].join("\n");541}542543function dateStamp(): string {544 return new Date().toISOString().split("T")[0];545}546547function downloadBlob(content: string, mimeType: string, filename: string): void {548 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));549 const a = document.createElement("a");550 a.href = url;551 a.download = filename;552 document.body.appendChild(a);553 a.click();554 document.body.removeChild(a);555 URL.revokeObjectURL(url);556}