// 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 type { VaultEntry } from "../../shared/types";
import { CJKImportView } from "./CJKImport";
interface ImportProps {
mode?: "existing" | "onboarding";
replacesExistingVault?: boolean;
onBack: () => void;
onImport: () => void | Promise;
}
type ImportMergeMode = "add" | "replace";
export function ImportView(props: ImportProps) {
const [showCjkImport, setShowCjkImport] = createSignal(false);
return (
setShowCjkImport(true)}
/>
}
>
setShowCjkImport(false)}
onDone={async () => {
setShowCjkImport(false);
await props.onImport();
}}
/>
);
}
function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {
const mode = () => props.mode ?? "existing";
// PeachScript paste flow
const [scriptInput, setScriptInput] = createSignal("");
const [scriptPass, setScriptPass] = createSignal("");
const [scriptWorking, setScriptWorking] = createSignal(false);
const [scriptError, setScriptError] = createSignal(null);
const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);
// File flow (existing format detection)
const [preview, setPreview] = createSignal(null);
const [rawImport, setRawImport] = createSignal(null);
const [backupPassword, setBackupPassword] = createSignal("");
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 needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);
const needsMasterPassword = createMemo(() => mode() === "onboarding");
const masterPasswordReady = createMemo(
() =>
masterPassword().length >= 8 && masterPassword() === masterConfirm()
);
const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));
async function refreshExistingCount() {
if (mode() !== "existing") return;
const res = await sendMessage({ type: "GET_ENTRIES" });
const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];
setExistingCount(list.length);
}
void refreshExistingCount();
// ---------------------------------------------------------------------
// PeachScript flow
// ---------------------------------------------------------------------
async function handlePeachScriptImport() {
setScriptError(null);
setScriptResult(null);
if (!scriptLooksValid()) {
setScriptError("Paste your PeachScript first.");
return;
}
if (!scriptPass().trim()) {
setScriptError("Enter the backup password.");
return;
}
setScriptWorking(true);
try {
const bytes = decodePeachScript(normalisePeachScript(scriptInput()));
const payload = await parsePayload(bytes, scriptPass());
// Onboarding: create a fresh vault first.
if (mode() === "onboarding") {
if (!masterPasswordReady()) {
throw new Error("MASTER_PASSWORD_REQUIRED");
}
const createRes = await sendMessage({
type: "CREATE_VAULT",
payload: { password: masterPassword() },
});
if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");
}
const restoreRes = await sendMessage({
type: "RESTORE_ENTRIES",
payload: {
entries: payload.entries,
mode: "merge",
settings: payload.settings,
},
});
if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");
if (payload.license?.key) {
await sendMessage({
type: "ACTIVATE_LICENSE",
payload: { key: payload.license.key },
}).catch(() => {});
}
setScriptResult({ entryCount: payload.entries.length });
await props.onImport();
} catch (err) {
const code = err instanceof Error ? err.message : String(err);
setScriptError(getImportErrorCopy(code));
} finally {
setScriptWorking(false);
}
}
// ---------------------------------------------------------------------
// File flow (Bitwarden / 1Password / etc.)
// ---------------------------------------------------------------------
async function parseSource(text: string, password?: string) {
try {
const result = await parseImportSource(text, password);
setPreview(result);
setError(null);
if (result.format === "unknown") {
setError(
"We couldn't recognise this file format. Peach supports: Peach JSON backups, 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 file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
setPreview(null);
setRawImport(null);
setBackupPassword("");
setError(null);
setAwaitingReplaceConfirm(false);
const text = await file.text();
setRawImport(text);
await parseSource(text);
}
async function handleUnlockBackup() {
const raw = rawImport();
if (!raw || !backupPassword().trim()) return;
await parseSource(raw, backupPassword());
}
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 (needsBackupPassword() && !backupPassword().trim()) {
setError("Enter the backup password used for this export.");
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 (mode() === "existing" && 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 (mode() === "existing" && 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 (mode() === "existing" && 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");
}
if (currentPreview.licenseKey) {
await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});
}
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 (
import
}
>
Restoring now replaces the locked local vault on this device with
a fresh one from your backup or export file.
{/* PeachScript paste — primary path */}
paste peachscript
{mode() === "onboarding"
? "Paste a PeachScript backup from another Peach device to restore your vault."
: "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}
{/* File import — for migrating from other managers */}
import a file
Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO
credential-exchange passkeys, or a Peach JSON 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.
{/* Legacy CJK — collapsed, secondary */}
Have an old CJK phrase backup?
CJK phrase backups from earlier versions of Peach are still
supported. After restoring, we recommend creating a fresh
PeachScript or QR backup.
);
}
function getImportErrorCopy(error: string): string {
switch (error) {
case "UNSUPPORTED_IMPORT_FORMAT":
return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";
case "INVALID_EXPORT_PASSWORD":
case "INVALID_PASSPHRASE":
return "Wrong backup password. This is the password you chose when you created the backup.";
case "INVALID_PEACH_EXPORT":
return "This Peach backup file looks corrupted or incomplete.";
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 "NO_ENTRIES_IMPORTED":
return "No importable password entries were found in that file.";
default:
return error;
}
}