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.
|
* 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user