Add saved-wallets to the import screen. Reads from file

This commit is contained in:
2026-03-16 07:38:22 +00:00
parent dd275593cd
commit be52f73e64

View File

@@ -1,10 +1,11 @@
/**
* Seed Input Screen - Initial screen for wallet seed phrase entry.
*
* Allows users to enter their BIP39 seed phrase to initialize the wallet.
* Allows users to enter their BIP39 seed phrase to initialize the wallet,
* or select from previously saved mnemonic files on disk.
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.js';
@@ -12,14 +13,70 @@ import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logo } from '../theme.js';
import fs from 'fs';
import path from 'path';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic } from '@bitauth/libauth';
/**
* Status message type.
*/
type StatusType = 'idle' | 'loading' | 'error' | 'success';
/**
* Parsed mnemonic file entry with the derived seed phrase ready for wallet init.
*/
interface MnemonicFileEntry {
filename: string;
/** Friendly label derived from filename or the URL comment field. */
label: string;
/** The BIP39 mnemonic phrase derived from the file's entropy. */
mnemonic: string;
}
/**
* Focus sections the user can tab between.
* When saved wallets exist the file list is shown first.
*/
type FocusSection = 'files' | 'input' | 'button';
/**
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL,
* and converts the entropy back to a BIP39 English mnemonic phrase.
*/
function loadMnemonicFiles(): MnemonicFileEntry[] {
const cwd = process.cwd();
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-'));
const entries: MnemonicFileEntry[] = [];
for (const filename of filenames) {
try {
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim();
const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject();
console.log(raw);
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
console.log(mnemonicResult);
if (typeof mnemonicResult === 'string') continue;
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
const label = raw.comment
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
} catch {
// Skip files that can't be parsed
}
}
return entries;
}
/**
* Seed Input Screen Component.
* Provides seed phrase entry for wallet initialization.
* Provides seed phrase entry for wallet initialization and a selectable
* list of previously saved mnemonic files.
*/
export function SeedInputScreen(): React.ReactElement {
const { navigate } = useNavigation();
@@ -30,9 +87,28 @@ export function SeedInputScreen(): React.ReactElement {
const [seedPhrase, setSeedPhrase] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [statusType, setStatusType] = useState<StatusType>('idle');
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
const [isSubmitting, setIsSubmitting] = useState(false);
// Mnemonic file list state
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
// Focus: when saved wallets exist default to the file list, otherwise the input.
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
useEffect(() => {
const entries = loadMnemonicFiles();
setMnemonicFiles(entries);
if (entries.length > 0) setFocusedSection('files');
}, []);
/**
* The ordered list of focusable sections (files section only when entries exist).
*/
const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'button']
: ['input', 'button'];
/**
* Shows a status message with the given type.
*/
@@ -42,12 +118,37 @@ export function SeedInputScreen(): React.ReactElement {
}, []);
/**
* Handles seed phrase submission.
* Shared wallet initialization handler used by both manual entry and file selection.
*/
const doInitialize = useCallback(async (seed: string) => {
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
try {
await initializeWallet(seed);
showStatus('Wallet initialized successfully!', 'success');
setStatus('Wallet ready');
setSeedPhrase('');
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
}, [initializeWallet, navigate, showStatus, setStatus]);
/**
* Handles manual seed phrase submission with validation.
*/
const handleSubmit = useCallback(async () => {
const seed = seedPhrase.trim();
// Basic validation
if (!seed) {
showStatus('Please enter your seed phrase', 'error');
return;
@@ -59,60 +160,66 @@ export function SeedInputScreen(): React.ReactElement {
return;
}
// Show loading status
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
await doInitialize(seed);
}, [seedPhrase, doInitialize, showStatus]);
try {
// Initialize wallet and create AppService
await initializeWallet(seed);
/**
* Handles selecting a mnemonic file from the list.
*/
const handleFileSelect = useCallback(async (index: number) => {
const entry = mnemonicFiles[index];
if (!entry) return;
await doInitialize(entry.mnemonic);
}, [mnemonicFiles, doInitialize]);
showStatus('Wallet initialized successfully!', 'success');
setStatus('Wallet ready');
// Clear sensitive data before navigating
setSeedPhrase('');
// Navigate to wallet state screen
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
// Handle keyboard navigation
// Keyboard navigation
useInput((input, key) => {
if (isSubmitting) return;
// Tab to switch focus
// Tab / Shift-Tab to cycle focus sections
if (key.tab) {
setFocusedElement(prev => prev === 'input' ? 'button' : 'input');
setFocusedSection((prev) => {
const idx = focusSections.indexOf(prev);
const next = key.shift
? (idx - 1 + focusSections.length) % focusSections.length
: (idx + 1) % focusSections.length;
return focusSections[next]!;
});
return;
}
// Enter on button submits
if (key.return && focusedElement === 'button') {
// Arrow keys inside the file list
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
if (key.upArrow) {
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedFileIndex((prev) => Math.min(mnemonicFiles.length - 1, prev + 1));
return;
}
if (key.return) {
handleFileSelect(selectedFileIndex);
return;
}
}
// Enter on button submits manual seed
if (key.return && focusedSection === 'button') {
handleSubmit();
}
});
// Get status color
// Derived style helpers
const statusColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
statusType === 'loading' ? colors.info :
colors.textMuted;
// Get border color based on status
const inputBorderColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
focusedElement === 'input' ? colors.focus :
colors.border;
focusedSection === 'input' ? colors.focus :
colors.borderMuted;
return (
<Box flexDirection='column' alignItems='center' paddingY={1}>
@@ -123,16 +230,84 @@ export function SeedInputScreen(): React.ReactElement {
{/* Title */}
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
<Text color={colors.textMuted}>Enter your seed phrase to get started</Text>
<Text color={colors.textMuted}>Enter your seed phrase or select a saved wallet</Text>
{/* Spacer */}
<Box marginY={1} />
{/* Input section */}
<Box flexDirection='column' width={64}>
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
<Box
borderStyle='single'
{/* ── Saved Wallets ─────────────────────────────────── */}
{mnemonicFiles.length > 0 && (
<Box flexDirection='column' marginBottom={1}>
<Box marginBottom={1}>
<Text color={colors.primary} bold>
{'▸ '}Saved Wallets
</Text>
<Text color={colors.textMuted}> ({mnemonicFiles.length})</Text>
</Box>
<Box
flexDirection='column'
borderStyle='single'
borderColor={focusedSection === 'files' ? colors.focus : colors.borderMuted}
paddingX={1}
>
{mnemonicFiles.map((entry, idx) => {
const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex;
return (
<Box key={entry.filename} paddingY={0}>
<Text
color={isHighlighted ? colors.bg : colors.textMuted}
backgroundColor={isHighlighted ? colors.focus : undefined}
bold={isHighlighted}
>
{isHighlighted ? ' ▶ ' : ' '}
</Text>
<Text
color={isHighlighted ? colors.bg : colors.text}
backgroundColor={isHighlighted ? colors.focus : undefined}
bold={isHighlighted}
>
{` ${entry.label} `}
</Text>
<Text
color={isHighlighted ? colors.bg : colors.textMuted}
backgroundColor={isHighlighted ? colors.focus : undefined}
dimColor={!isHighlighted}
>
{` (${entry.filename})`}
</Text>
</Box>
);
})}
</Box>
{focusedSection === 'files' && (
<Box marginTop={0} paddingX={1}>
<Text color={colors.textMuted} dimColor>
navigate Enter: load wallet
</Text>
</Box>
)}
</Box>
)}
{/* ── Divider between sections ──────────────────────── */}
{mnemonicFiles.length > 0 && (
<Box marginBottom={1} justifyContent='center'>
<Text color={colors.borderMuted}>{'─'.repeat(20)} or {'─'.repeat(20)}</Text>
</Box>
)}
{/* ── Manual Seed Entry ─────────────────────────────── */}
<Text color={colors.primary} bold>
{'▸ '}Manual Entry
</Text>
<Box marginTop={1}>
<Text color={colors.text}>Seed Phrase (12 or 24 words):</Text>
</Box>
<Box
borderStyle='single'
borderColor={inputBorderColor}
paddingX={1}
marginTop={1}
@@ -142,7 +317,7 @@ export function SeedInputScreen(): React.ReactElement {
onChange={setSeedPhrase}
onSubmit={handleSubmit}
placeholder='Enter your seed phrase...'
focus={focusedElement === 'input' && !isSubmitting}
focus={focusedSection === 'input' && !isSubmitting}
/>
</Box>
@@ -162,7 +337,7 @@ export function SeedInputScreen(): React.ReactElement {
<Box justifyContent='center' marginTop={1}>
<Button
label='Continue'
focused={focusedElement === 'button'}
focused={focusedSection === 'button'}
disabled={isSubmitting}
shortcut='Enter'
/>
@@ -172,7 +347,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */}
<Box marginTop={2}>
<Text color={colors.textMuted} dimColor>
Tab: navigate Enter: submit q: quit
Tab: navigate sections Enter: submit Esc: back
</Text>
</Box>
</Box>