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}1819202122type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string; suggestions: string[] } };23let _zxcvbn: ZxcvbnFn | null = null;24async function getZxcvbn(): Promise<ZxcvbnFn> {25 if (_zxcvbn) return _zxcvbn;26 const mod = await import("zxcvbn");27 _zxcvbn = mod.default as ZxcvbnFn;28 return _zxcvbn;29}3031323334const STRENGTH_REQUIRED = 3;3536export function ExportView(props: ExportProps) {37 const [view, setView] = createSignal<ExportSubview>("menu");38 return (39 <div class="peach-overlay-view ov-enter-right" style="display:flex">40 <Show when={view() === "menu"}>41 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />42 </Show>43 <Show when={view() === "peachscript"}>44 <PeachScriptExport onBack={() => setView("menu")} />45 </Show>46 <Show when={view() === "csv"}>47 <CSVExport onBack={() => setView("menu")} />48 </Show>49 </div>50 );51}525354555657function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {58 return (59 <>60 <OverlayHeader title="export" onBack={props.onBack} />61 <div class="peach-settings-body peach-export-menu">62 <div class="peach-settings-group peach-export-card peach-export-card--primary">63 <p class="peach-settings-label">peachscript</p>64 <p class="peach-export-card-copy">65 Your vault as a string of Peach symbols you can paste into Peach66 on another device. Designed for Peach-to-Peach transfer.67 </p>68 <button69 class="peach-btn peach-btn--subtle peach-btn--sm"70 onClick={() => props.onPick("peachscript")}71 >72 Create PeachScript →73 </button>74 </div>7576 <div class="peach-settings-group peach-export-card">77 <p class="peach-settings-label">CSV (leaving peach)</p>78 <p class="peach-export-card-copy">79 Plain-text export for moving into another password manager. Anyone80 with the file can read your passwords.81 </p>82 <button83 class="peach-btn peach-btn--ghost peach-btn--sm"84 onClick={() => props.onPick("csv")}85 >86 Export as CSV →87 </button>88 </div>89 </div>90 </>91 );92}939495969798function PeachScriptExport(props: { onBack: () => void }) {99 const [pass, setPass] = createSignal("");100 const [confirm, setConfirm] = createSignal("");101 const [strengthScore, setStrengthScore] = createSignal<number>(0);102 const [strengthHint, setStrengthHint] = createSignal<string>("");103 const [working, setWorking] = createSignal(false);104 const [script, setScript] = createSignal<string | null>(null);105 const [error, setError] = createSignal<string | null>(null);106 const [entryCount, setEntryCount] = createSignal(0);107 const [stats, setStats] = createSignal<{108 cards: number;109 passkeys: number;110 notes: number;111 logins: number;112 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });113 const [copied, setCopied] = createSignal(false);114115 const passReady = createMemo(116 () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()117 );118119 function handlePassInput(value: string) {120 setPass(value);121 if (!value) {122 setStrengthScore(0);123 setStrengthHint("");124 return;125 }126 127 void getZxcvbn().then((zxcvbn) => {128 if (pass() !== value) return; 129 const result = zxcvbn(value);130 setStrengthScore(result.score);131 const hint = result.feedback.warning || result.feedback.suggestions[0] || "";132 setStrengthHint(hint);133 });134 }135136 async function handleEncode() {137 setError(null);138 setScript(null);139 setCopied(false);140 if (!passReady()) {141 setError("Backup passwords don't match or are too short.");142 return;143 }144 setWorking(true);145 try {146 const env = await assembleEnvelope(pass());147 setEntryCount(env.entryCount);148 setStats(env.stats);149 setScript(encodePeachScript(env.bytes));150 } catch (err) {151 setError(err instanceof Error ? err.message : String(err));152 } finally {153 setWorking(false);154 }155 }156157 async function handleCopy() {158 const s = script();159 if (!s) return;160 try {161 await navigator.clipboard.writeText(s);162 setCopied(true);163 setTimeout(() => setCopied(false), 2000);164 } catch (err) {165 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");166 }167 }168169 function handleDownload() {170 const s = script();171 if (!s) return;172 173 174 175 176 177 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });178 const url = URL.createObjectURL(blob);179 const date = new Date().toISOString().slice(0, 10);180 const a = document.createElement("a");181 a.href = url;182 a.download = `peach-backup-${date}.peach`;183 document.body.appendChild(a);184 a.click();185 document.body.removeChild(a);186 setTimeout(() => URL.revokeObjectURL(url), 5000);187 }188189 return (190 <>191 <OverlayHeader title="peachscript" onBack={props.onBack} />192 <div class="peach-settings-body">193 <Show194 when={script()}195 fallback={196 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">197 <p class="peach-settings-label">backup password</p>198 <p class="peach-export-card-copy">199 Choose a password to lock this PeachScript. You'll need it200 to restore on another device.201 </p>202 <div class="peach-settings-stack peach-export-password-stack">203 <input204 ref={(el) => queueMicrotask(() => el.focus())}205 class="peach-settings-input"206 type="password"207 placeholder="backup password"208 value={pass()}209 onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}210 onKeyDown={(e) => {211 if (e.key === "Enter" && passReady() && !working()) {212 e.preventDefault();213 void handleEncode();214 }215 }}216 />217 <Show when={pass().length > 0}>218 <div class="peach-export-strength-track">219 <div220 class="peach-export-strength-fill"221 style={{222 width: `${(strengthScore() / 4) * 100}%`,223 background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],224 }}225 />226 </div>227 <Show when={strengthScore() < STRENGTH_REQUIRED}>228 <p class="peach-export-strength-warn">229 {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}230 </p>231 </Show>232 <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>233 <p class="peach-export-strength-hint">{strengthHint()}</p>234 </Show>235 </Show>236 <input237 class="peach-settings-input"238 type="password"239 placeholder="confirm backup password"240 value={confirm()}241 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}242 onKeyDown={(e) => {243 if (e.key === "Enter" && passReady() && !working()) {244 e.preventDefault();245 void handleEncode();246 }247 }}248 />249 <Show when={confirm().length > 0 && pass() !== confirm()}>250 <p class="peach-export-note" style="color:var(--peach-t2)">251 Passwords don't match252 </p>253 </Show>254 </div>255 <button256 class="peach-btn peach-btn--subtle peach-btn--sm"257 onClick={() => void handleEncode()}258 disabled={working() || !passReady()}259 >260 {working() ? "encoding…" : "Generate PeachScript"}261 </button>262 </div>263 }264 >265 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">266 <p class="peach-settings-label">peachscript</p>267 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />268 <p class="peach-export-card-copy" style="margin:0 0 8px">269 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}270 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}271 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}272 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}273 </p>274 <div style="display:flex;gap:8px;">275 <button276 class="peach-btn peach-btn--subtle peach-btn--sm"277 style="flex:1;"278 onClick={() => void handleCopy()}279 >280 {copied() ? "copied ✓" : "Copy"}281 </button>282 <button283 class="peach-btn peach-btn--subtle peach-btn--sm"284 style="flex:1;"285 onClick={handleDownload}286 >287 Download .peach288 </button>289 </div>290 <p class="peach-export-card-copy" style="margin-top:8px">291 Paste the script — or import the .peach file — into Peach on292 your other device to restore your vault. Won't work anywhere else.293 </p>294 </div>295 </Show>296 <Show when={error()}>297 <div class="peach-settings-group">298 <div class="peach-restore-error">{error()}</div>299 </div>300 </Show>301 </div>302 </>303 );304}305306307308309310function CSVExport(props: { onBack: () => void }) {311 const [acknowledged, setAcknowledged] = createSignal(false);312 const [working, setWorking] = createSignal(false);313 const [error, setError] = createSignal<string | null>(null);314 const [done, setDone] = createSignal<string | null>(null);315316 async function handleExport() {317 setError(null);318 setDone(null);319 setWorking(true);320 try {321 const res = await sendMessage({ type: "GET_ENTRIES" });322 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];323 324 325 326 327 const active = entries328 .filter((e) => e.deletedAt === undefined)329 .filter((e) => !(e.passkey && !e.password))330 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));331 const excludedPasskeys = entries.filter(332 (e) => e.deletedAt === undefined && e.passkey333 ).length;334335 if (active.length === 0) {336 setError("Your vault has no entries to export.");337 return;338 }339340 const csv = buildCsv(active);341 const filename = `peach-export-${dateStamp()}.csv`;342 downloadBlob(csv, "text/csv", filename);343 const suffix = excludedPasskeys > 0344 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`345 : "";346 setDone(347 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`348 );349 } catch (err) {350 setError(err instanceof Error ? err.message : String(err));351 } finally {352 setWorking(false);353 }354 }355356 return (357 <>358 <OverlayHeader title="export as CSV" onBack={props.onBack} />359 <div class="peach-settings-body">360 <div class="peach-settings-group peach-export-card">361 <div class="peach-csv-warning">362 This export is unencrypted. Anyone who gets this file can read363 your passwords. Only use this to move to another password364 manager — delete it once the import is done.365 </div>366 <Show when={!acknowledged()}>367 <button368 class="peach-btn peach-btn--ghost peach-btn--sm"369 onClick={() => setAcknowledged(true)}370 >371 I understand — continue372 </button>373 </Show>374 <Show when={acknowledged()}>375 <button376 class="peach-btn peach-btn--subtle peach-btn--sm"377 onClick={() => void handleExport()}378 disabled={working()}379 >380 {working() ? "exporting…" : "Download CSV"}381 </button>382 </Show>383 </div>384 <Show when={error()}>385 <div class="peach-settings-group">386 <div class="peach-restore-error">{error()}</div>387 </div>388 </Show>389 <Show when={done()}>390 <div class="peach-settings-group">391 <div class="peach-export-success">392 <span class="peach-export-success-check">✓</span>393 <span>{done()}</span>394 </div>395 </div>396 </Show>397 </div>398 </>399 );400}401402403404405406function OverlayHeader(props: { title: string; onBack: () => void }) {407 return (408 <div class="peach-overlay-header">409 <button410 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"411 onClick={props.onBack}412 aria-label="Back"413 >414 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">415 <path d="M6 1L1 6L6 11" />416 </svg>417 </button>418 <span class="peach-overlay-title">{props.title}</span>419 <div class="peach-overlay-spacer"></div>420 </div>421 );422}423424interface AssembledEnvelope {425 bytes: Uint8Array;426 entryCount: number;427 stats: { cards: number; passkeys: number; notes: number; logins: number };428}429430async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {431 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });432 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];433434 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });435 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};436437 let license: PeachLicenseExport | undefined;438 try {439 const info = await sendMessage({ type: "GET_LICENSE" });440 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;441 if (data?.valid && data.plan === "pro") {442 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });443 const keyStr = (keyRes as { ok: boolean; data?: string }).data;444 if (keyStr) {445 license = {446 key: keyStr,447 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),448 };449 }450 }451 } catch {452 453 }454455 const stats = countTypes(entries);456 const bytes = await buildPayload({457 entries,458 settings: settings as Parameters<typeof buildPayload>[0]["settings"],459 license,460 passphrase,461 });462 return { bytes, entryCount: entries.length, stats };463}464465function countTypes(entries: VaultEntry[]): {466 cards: number;467 passkeys: number;468 notes: number;469 logins: number;470} {471 let cards = 0;472 let passkeys = 0;473 let notes = 0;474 let logins = 0;475 for (const e of entries) {476 if (e.deletedAt) continue;477 if (e.passkey) {478 passkeys++;479 480 481 if (e.type === "login" && e.password) logins++;482 continue;483 }484 if (e.type === "card") cards++;485 else if (e.type === "note") notes++;486 else logins++;487 }488 return { cards, passkeys, notes, logins };489}490491492493494495496497export function csvSafe(value: string): string {498 return /^[=+\-@\t\r]/.test(value) ? "'" + value : value;499}500501function buildCsv(entries: VaultEntry[]): string {502 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];503 const rows = entries.map((e) => {504 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";505 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";506 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";507 return [508 e.title,509 e.url ?? "",510 username,511 password,512 e.notes ?? "",513 type,514 Array.isArray(e.tags) ? e.tags.join("|") : "",515 e.totp ?? "",516 ]517 .map((v) =>518 `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`519 )520 .join(",");521 });522 return [headers.join(","), ...rows].join("\n");523}524525function dateStamp(): string {526 return new Date().toISOString().split("T")[0];527}528529function downloadBlob(content: string, mimeType: string, filename: string): void {530 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));531 const a = document.createElement("a");532 a.href = url;533 a.download = filename;534 document.body.appendChild(a);535 a.click();536 document.body.removeChild(a);537 URL.revokeObjectURL(url);538}