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. * 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 { Box, Text, useInput } from 'ink';
import TextInput from '../components/TextInput.js'; import TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.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 { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logo } from '../theme.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. * Status message type.
*/ */
type StatusType = 'idle' | 'loading' | 'error' | 'success'; 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. * 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 { export function SeedInputScreen(): React.ReactElement {
const { navigate } = useNavigation(); const { navigate } = useNavigation();
@@ -30,9 +87,28 @@ export function SeedInputScreen(): React.ReactElement {
const [seedPhrase, setSeedPhrase] = useState(''); const [seedPhrase, setSeedPhrase] = useState('');
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [statusType, setStatusType] = useState<StatusType>('idle'); const [statusType, setStatusType] = useState<StatusType>('idle');
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
const [isSubmitting, setIsSubmitting] = useState(false); 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. * 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 handleSubmit = useCallback(async () => {
const seed = seedPhrase.trim(); const seed = seedPhrase.trim();
// Basic validation
if (!seed) { if (!seed) {
showStatus('Please enter your seed phrase', 'error'); showStatus('Please enter your seed phrase', 'error');
return; return;
@@ -59,60 +160,66 @@ export function SeedInputScreen(): React.ReactElement {
return; return;
} }
// Show loading status await doInitialize(seed);
showStatus('Initializing wallet...', 'loading'); }, [seedPhrase, doInitialize, showStatus]);
setStatus('Initializing wallet...');
setIsSubmitting(true);
try { /**
// Initialize wallet and create AppService * Handles selecting a mnemonic file from the list.
await initializeWallet(seed); */
const handleFileSelect = useCallback(async (index: number) => {
const entry = mnemonicFiles[index];
if (!entry) return;
await doInitialize(entry.mnemonic);
}, [mnemonicFiles, doInitialize]);
showStatus('Wallet initialized successfully!', 'success'); // Keyboard navigation
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
useInput((input, key) => { useInput((input, key) => {
if (isSubmitting) return; if (isSubmitting) return;
// Tab to switch focus // Tab / Shift-Tab to cycle focus sections
if (key.tab) { 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 // Arrow keys inside the file list
if (key.return && focusedElement === 'button') { 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(); handleSubmit();
} }
}); });
// Get status color // Derived style helpers
const statusColor = statusType === 'error' ? colors.error : const statusColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success : statusType === 'success' ? colors.success :
statusType === 'loading' ? colors.info : statusType === 'loading' ? colors.info :
colors.textMuted; colors.textMuted;
// Get border color based on status
const inputBorderColor = statusType === 'error' ? colors.error : const inputBorderColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success : statusType === 'success' ? colors.success :
focusedElement === 'input' ? colors.focus : focusedSection === 'input' ? colors.focus :
colors.border; colors.borderMuted;
return ( return (
<Box flexDirection='column' alignItems='center' paddingY={1}> <Box flexDirection='column' alignItems='center' paddingY={1}>
@@ -123,16 +230,84 @@ export function SeedInputScreen(): React.ReactElement {
{/* Title */} {/* Title */}
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text> <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} /> <Box marginY={1} />
{/* Input section */}
<Box flexDirection='column' width={64}> <Box flexDirection='column' width={64}>
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text> {/* ── Saved Wallets ─────────────────────────────────── */}
<Box {mnemonicFiles.length > 0 && (
borderStyle='single' <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} borderColor={inputBorderColor}
paddingX={1} paddingX={1}
marginTop={1} marginTop={1}
@@ -142,7 +317,7 @@ export function SeedInputScreen(): React.ReactElement {
onChange={setSeedPhrase} onChange={setSeedPhrase}
onSubmit={handleSubmit} onSubmit={handleSubmit}
placeholder='Enter your seed phrase...' placeholder='Enter your seed phrase...'
focus={focusedElement === 'input' && !isSubmitting} focus={focusedSection === 'input' && !isSubmitting}
/> />
</Box> </Box>
@@ -162,7 +337,7 @@ export function SeedInputScreen(): React.ReactElement {
<Box justifyContent='center' marginTop={1}> <Box justifyContent='center' marginTop={1}>
<Button <Button
label='Continue' label='Continue'
focused={focusedElement === 'button'} focused={focusedSection === 'button'}
disabled={isSubmitting} disabled={isSubmitting}
shortcut='Enter' shortcut='Enter'
/> />
@@ -172,7 +347,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */} {/* Help text */}
<Box marginTop={2}> <Box marginTop={2}>
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
Tab: navigate Enter: submit q: quit Tab: navigate sections Enter: submit Esc: back
</Text> </Text>
</Box> </Box>
</Box> </Box>