// Signal-conditioned views in this file must use , never a
// signal-conditioned early return — see Projects.md §4.1.
import { createMemo, createSignal, Show } from "solid-js";
import { sendMessage } from "../App";
import { PeachWordmark } from "../components/PeachMark";
import {
parseImportSource,
type ImportParseResult,
} from "../../shared/import";
import {
decodePeachScript,
isPeachScript,
normalisePeachScript,
} from "../../crypto/peachScript";
import { parsePayload } from "../../crypto/peachPayload";
import { IMPORT_LIMITS } from "../../shared/import-limits";
import type { VaultEntry } from "../../shared/types";
type ImportSubview = "menu" | "peachscript" | "file";
type ImportMergeMode = "add" | "replace";
interface ImportProps {
mode?: "existing" | "onboarding";
replacesExistingVault?: boolean;
/** Skip the menu and open straight into a specific subview. */
initialView?: ImportSubview;
onBack: () => void;
onImport: () => void | Promise;
}
function OnboardingMasterPasswordFields(props: {
idPrefix: string;
copy: string;
masterPlaceholder: string;
password: string;
confirm: string;
onPasswordInput: (value: string) => void;
onConfirmInput: (value: string) => void;
}) {
const [showPassword, setShowPassword] = createSignal(false);
return (
<>
{props.copy}
props.onPasswordInput((event.target as HTMLInputElement).value)}
/>
props.onConfirmInput((event.target as HTMLInputElement).value)}
/>
0 && props.password !== props.confirm}>
Passwords don't match
>
);
}
export function ImportView(props: ImportProps) {
const [view, setView] = createSignal(props.initialView ?? "menu");
// When FileImport detects a .peach file, it stashes the contents here and
// routes the view to "peachscript" so the existing decode flow handles it.
const [peachScriptPrefill, setPeachScriptPrefill] = createSignal("");
const mode = () => props.mode ?? "existing";
const isOnboarding = () => mode() === "onboarding";
// When the caller deep-links into a subview, the back arrow exits the
// ImportView entirely. Otherwise it returns to the import menu.
const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));
return (
Restoring now replaces the locked local vault on this device with
a fresh one from your backup.
paste peachscript
{isOnboarding
? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."
: "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}
>
);
}
// ---------------------------------------------------------------------------
// File flow (Bitwarden / 1Password / etc.)
// ---------------------------------------------------------------------------
function FileImport(props: {
mode: "existing" | "onboarding";
replacesExistingVault?: boolean;
onBack: () => void;
onImport: () => void | Promise;
/** Called when the user picks a .peach file from this picker — the parent
* routes them to the PeachScript subview with the contents prefilled. */
onLoadPeachFile?: (content: string) => void;
}) {
const isOnboarding = props.mode === "onboarding";
const needsMasterPassword = () => isOnboarding;
const [preview, setPreview] = createSignal(null);
const [masterPassword, setMasterPassword] = createSignal("");
const [masterConfirm, setMasterConfirm] = createSignal("");
const [importing, setImporting] = createSignal(false);
const [error, setError] = createSignal(null);
const [mergeMode, setMergeMode] = createSignal("add");
const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);
const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);
const [duplicateWarning, setDuplicateWarning] = createSignal(0);
const [existingCount, setExistingCount] = createSignal(0);
const masterPasswordReady = createMemo(
() => masterPassword().length >= 8 && masterPassword() === masterConfirm()
);
async function refreshExistingCount() {
if (isOnboarding) return;
const res = await sendMessage({ type: "GET_ENTRIES" });
const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];
setExistingCount(list.length);
}
void refreshExistingCount();
async function parseSource(text: string) {
try {
const result = await parseImportSource(text);
setPreview(result);
setError(null);
if (result.format === "unknown") {
setError(
"We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."
);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setPreview(null);
setError(getImportErrorCopy(message));
return null;
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setPreview(null);
setError(null);
setAwaitingReplaceConfirm(false);
if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {
setError("That file is too large.");
input.value = "";
return;
}
const text = await file.text();
// Route .peach files to the PeachScript subview. Detection is two-pronged:
// either the filename ends in .peach, or the body is entirely PUA chars
// (the canonical PeachScript shape). The two-pronged check protects
// against renamed-extension imports and against text editors that
// accidentally save with a different extension.
const looksLikePeach =
file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);
if (looksLikePeach) {
if (props.onLoadPeachFile) {
props.onLoadPeachFile(text);
input.value = "";
return;
}
setError("This is a .peach file. Open the PeachScript card to import it.");
return;
}
await parseSource(text);
}
async function countDuplicates(incoming: ImportParseResult["entries"]): Promise {
if (incoming.length === 0) return 0;
const res = await sendMessage({ type: "GET_ENTRIES" });
const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);
const keyOf = (title?: string, url?: string, username?: string) =>
`${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;
const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));
let dupes = 0;
for (const entry of incoming) {
const u = "username" in entry ? entry.username : undefined;
const url = "url" in entry ? entry.url : undefined;
if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;
}
return dupes;
}
async function handleImport() {
const currentPreview = preview();
if (!currentPreview) return;
if (needsMasterPassword()) {
if (masterPassword().length < 8) {
setError("Master password must be at least 8 characters.");
return;
}
if (!masterPasswordReady()) {
setError("Master passwords do not match.");
return;
}
}
if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {
setAwaitingReplaceConfirm(true);
return;
}
setImporting(true);
setError(null);
setDuplicateWarning(0);
setImportProgress(null);
try {
if (currentPreview.entries.length === 0) {
throw new Error("NO_ENTRIES_IMPORTED");
}
if (needsMasterPassword()) {
const createResponse = await sendMessage({
type: "CREATE_VAULT",
payload: { password: masterPassword() },
});
if (!(createResponse as { ok: boolean }).ok) {
throw new Error("CREATE_VAULT_FAILED");
}
}
if (!isOnboarding && mergeMode() === "replace") {
const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });
if (!(clearRes as { ok: boolean }).ok) {
throw new Error("CLEAR_VAULT_FAILED");
}
}
let dupes = 0;
if (!isOnboarding && mergeMode() === "add") {
dupes = await countDuplicates(currentPreview.entries);
}
let imported = 0;
setImportProgress({ current: 0, total: currentPreview.entries.length });
const batchRes = await sendMessage({
type: "BATCH_ADD_ENTRIES",
payload: { entries: currentPreview.entries },
});
if ((batchRes as { ok: boolean }).ok) {
imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;
setImportProgress({ current: imported, total: currentPreview.entries.length });
} else {
for (const entry of currentPreview.entries) {
const response = await sendMessage({
type: "ADD_ENTRY",
payload: entry,
});
if ((response as { ok: boolean }).ok) imported += 1;
setImportProgress({ current: imported, total: currentPreview.entries.length });
}
}
if (imported === 0) {
throw new Error("NO_ENTRIES_IMPORTED");
}
setDuplicateWarning(dupes);
await props.onImport();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(getImportErrorCopy(message));
setImporting(false);
setImportProgress(null);
return;
}
setImporting(false);
setImportProgress(null);
}
return (
<>
Restoring now replaces the locked local vault on this device with
a fresh one from your backup.
{duplicateWarning()} {duplicateWarning() === 1 ? "entry appears" : "entries appear"} to be duplicates of existing ones. They were still imported — you can review and delete them in your vault.
);
}
function SubviewHeader(props: {
title: string;
onBack: () => void;
mode: "existing" | "onboarding";
}) {
// Onboarding sub-flows reuse the lock-screen header; existing-vault flows
// use the standard overlay back-arrow header.
return (
}
>
);
}
function getImportErrorCopy(error: string): string {
// UI-7 / MV-5: oversized-import errors carry a variable detail after the
// code, so match the prefix and never surface the raw string.
if (error.startsWith("IMPORT_TOO_LARGE")) {
return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real PeachScript backup, please report it — backups never approach this size.";
}
switch (error) {
case "UNSUPPORTED_IMPORT_FORMAT":
return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";
case "INVALID_PASSPHRASE":
return "Wrong backup password. This is the password you chose when you created the backup.";
case "INVALID_PEACHSCRIPT_CHARACTER":
return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";
case "PAYLOAD_TOO_SHORT":
return "PeachScript looks truncated. Copy the full backup string and try again.";
case "BAD_MAGIC":
case "UNSUPPORTED_VERSION":
return "This PeachScript was created by a different version of Peach.";
case "CHECKSUM_FAILED":
return "PeachScript appears corrupted. Re-copy from the source device.";
case "CREATE_VAULT_FAILED":
return "Peach couldn't create a new local vault before importing.";
case "CLEAR_VAULT_FAILED":
return "Peach couldn't clear the existing vault before replacing it.";
case "MASTER_PASSWORD_REQUIRED":
return "Choose a master password for your new vault first.";
case "RESTORE_FAILED":
return "Restore failed. Your existing vault is unchanged.";
case "vault_locked":
return "Peach is locked. Open Peach with your master password and try again.";
case "refuse to replace with empty entry set":
return "This PeachScript decoded to zero entries — refusing to wipe the vault.";
case "NO_ENTRIES_IMPORTED":
return "No importable password entries were found in that file.";
case "PEACHSCRIPT_TOO_LARGE":
return "That backup is too large.";
default:
// Never echo raw error strings — internal messages must not reach the UI.
return "Import failed. Please try again.";
}
}