Add saved-wallets to the import screen. Reads from file
This commit is contained in:
@@ -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,14 +230,82 @@ 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>
|
||||
{/* ── 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}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user