From 6890740736dff1dff854baf678151681c4681a88 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 22 Jul 2025 16:07:54 +0700 Subject: [PATCH 01/63] chore: update version numbers to v2.2.3 across all components - Updated vault-v2 package.json, Cargo.toml, and tauri.conf.json to v2.2.3 - Updated vault package.json, Cargo.toml, and tauri.conf.json to v2.2.3 - Updated kkcli Cargo.toml to v2.2.3 - Updated keepkey-rust Cargo.toml to v2.2.3 - Includes VaultInterface.tsx and Makefile changes from previous work --- Makefile | 1 + projects/keepkey-rust/Cargo.toml | 2 +- projects/kkcli/Cargo.toml | 2 +- projects/vault-v2/package.json | 2 +- projects/vault-v2/src-tauri/Cargo.toml | 2 +- projects/vault-v2/src-tauri/tauri.conf.json | 2 +- .../src/components/VaultInterface.tsx | 23 +++++++++++++++++++ projects/vault/package.json | 2 +- projects/vault/src-tauri/Cargo.toml | 2 +- projects/vault/src-tauri/tauri.conf.json | 2 +- 10 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index c40578a..b392b1f 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ vault: keepkey-rust check-deps # Build vault for production vault-build: keepkey-rust check-deps + lsof -ti:1420 | xargs kill -9 \ @echo "🔧 Building vault-v2 for production with latest keepkey-rust..." cd projects/vault-v2 && bun i && tauri build diff --git a/projects/keepkey-rust/Cargo.toml b/projects/keepkey-rust/Cargo.toml index d82f939..4f565e2 100644 --- a/projects/keepkey-rust/Cargo.toml +++ b/projects/keepkey-rust/Cargo.toml @@ -4,7 +4,7 @@ name = "keepkey_rust" [lib] name = "keepkey_rust" path = "core_lib.rs" -version = "0.1.0" +version = "2.2.3" edition = "2021" license = "MIT OR Apache-2.0" description = "Headless multi-device queue and transport layer for KeepKey hardware wallets." diff --git a/projects/kkcli/Cargo.toml b/projects/kkcli/Cargo.toml index 3819f59..b29e758 100644 --- a/projects/kkcli/Cargo.toml +++ b/projects/kkcli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kkcli" -version = "0.2.3" +version = "2.2.3" authors = ["MrNerdHair "] edition = "2021" diff --git a/projects/vault-v2/package.json b/projects/vault-v2/package.json index 90fa772..34f5070 100644 --- a/projects/vault-v2/package.json +++ b/projects/vault-v2/package.json @@ -1,7 +1,7 @@ { "name": "vault-v2", "private": true, - "version": "2.2.0", + "version": "2.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/projects/vault-v2/src-tauri/Cargo.toml b/projects/vault-v2/src-tauri/Cargo.toml index f781c56..b615056 100644 --- a/projects/vault-v2/src-tauri/Cargo.toml +++ b/projects/vault-v2/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vault-v2" -version = "2.2.0" +version = "2.2.3" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/projects/vault-v2/src-tauri/tauri.conf.json b/projects/vault-v2/src-tauri/tauri.conf.json index c6ab762..7286b72 100644 --- a/projects/vault-v2/src-tauri/tauri.conf.json +++ b/projects/vault-v2/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "vault-v2", - "version": "2.2.0", + "version": "2.2.3", "identifier": "com.vault-v2.app", "build": { "beforeDevCommand": "bun run dev", diff --git a/projects/vault-v2/src/components/VaultInterface.tsx b/projects/vault-v2/src/components/VaultInterface.tsx index d2ffd06..b23076f 100644 --- a/projects/vault-v2/src/components/VaultInterface.tsx +++ b/projects/vault-v2/src/components/VaultInterface.tsx @@ -10,6 +10,7 @@ import { WalletProvider, useWallet } from '../contexts/WalletContext'; import Send from './Send'; import Receive from './Receive'; import { useDialog } from '../contexts/DialogContext'; +import packageJson from '../../package.json'; // import { AppHeader } from './AppHeader'; type ViewType = 'apps' | 'browser' | 'pairings' | 'vault' | 'assets' | 'send' | 'receive' | 'portfolio'; @@ -157,6 +158,28 @@ export const VaultInterface = () => { {/* Main Vault Interface - Hidden when settings is open */} {!isSettingsOpen && ( + {/* Top Header Bar */} + + + KeepKey Vault v{packageJson.version} + + + {/* Main Content Area */} {renderCurrentView()} diff --git a/projects/vault/package.json b/projects/vault/package.json index eef1f42..918590e 100644 --- a/projects/vault/package.json +++ b/projects/vault/package.json @@ -1,7 +1,7 @@ { "name": "vault", "private": true, - "version": "0.1.0", + "version": "2.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/projects/vault/src-tauri/Cargo.toml b/projects/vault/src-tauri/Cargo.toml index b8283f3..e83a460 100644 --- a/projects/vault/src-tauri/Cargo.toml +++ b/projects/vault/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vault" -version = "0.1.0" +version = "2.2.3" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/projects/vault/src-tauri/tauri.conf.json b/projects/vault/src-tauri/tauri.conf.json index f5e8758..f808bd3 100644 --- a/projects/vault/src-tauri/tauri.conf.json +++ b/projects/vault/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "vault", - "version": "0.1.0", + "version": "2.2.3", "identifier": "com.vault.app", "build": { "beforeDevCommand": "bun run dev", From 3199a3654e94fa5a8c99f4581dd0a57701942810 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 16:45:24 -0500 Subject: [PATCH 02/63] begin wizard --- projects/vault-v2/src-tauri/src/commands.rs | 12 +- projects/vault-v2/src/App.css | 17 +- .../src/components/DeviceUpdateManager.tsx | 35 +- projects/vault-v2/src/components/Send.tsx | 2 +- .../components/SetupWizard/SetupWizard.tsx | 365 ++++++++++++++++++ .../src/components/SetupWizard/index.ts | 1 + .../SetupWizard/steps/Step0Welcome.tsx | 47 +++ .../steps/Step1CreateOrRecover.tsx | 119 ++++++ .../SetupWizard/steps/Step2DeviceLabel.tsx | 110 ++++++ .../components/SetupWizard/steps/Step3Pin.tsx | 38 ++ .../steps/Step4BackupOrRecover.tsx | 156 ++++++++ .../SetupWizard/steps/Step5Complete.tsx | 58 +++ .../steps/StepBootloaderUpdate.tsx | 158 ++++++++ .../SetupWizard/steps/StepFirmwareUpdate.tsx | 194 ++++++++++ .../vault-v2/src/contexts/DialogContext.tsx | 2 +- .../vault-v2/src/hooks/useCommonDialogs.tsx | 15 +- 16 files changed, 1297 insertions(+), 32 deletions(-) create mode 100644 projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/index.ts create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step0Welcome.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/Step5Complete.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx create mode 100644 projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx diff --git a/projects/vault-v2/src-tauri/src/commands.rs b/projects/vault-v2/src-tauri/src/commands.rs index 6deaa19..fe4e4de 100644 --- a/projects/vault-v2/src-tauri/src/commands.rs +++ b/projects/vault-v2/src-tauri/src/commands.rs @@ -1152,9 +1152,10 @@ pub fn evaluate_device_status(device_id: String, features: Option<&DeviceFeature } } else { // Device is in normal firmware mode - check if it's an OOB device - if features.version.starts_with("1.0.") { - // OOB device: firmware version 1.0.3 = bootloader version 1.0.3 - features.version.clone() + if features.version.starts_with("1.0.") || features.version == "4.0.0" { + // OOB device: firmware version 1.0.3 or 4.0.0 = old bootloader + // Firmware 4.0.0 is known to have an old bootloader that needs updating + "1.0.3".to_string() // OOB devices have old bootloaders } else if let Some(ref bl_version) = features.bootloader_version { // Use explicit bootloader version if available bl_version.clone() @@ -1216,9 +1217,10 @@ pub fn evaluate_device_status(device_id: String, features: Option<&DeviceFeature } else { // Device is in normal firmware mode - use the actual firmware version let current_fw_version = features.version.clone(); - let needs_update = if current_fw_version.starts_with("1.0.") { + let needs_update = if current_fw_version.starts_with("1.0.") || current_fw_version == "4.0.0" { // OOB device - firmware update only after bootloader update - false // Bootloader has higher priority + // Firmware 4.0.0 is an OOB firmware that needs bootloader update first + true // Both bootloader and firmware need updates } else { !current_fw_version.starts_with("7.10.") }; diff --git a/projects/vault-v2/src/App.css b/projects/vault-v2/src/App.css index 85f7a4a..9356c96 100644 --- a/projects/vault-v2/src/App.css +++ b/projects/vault-v2/src/App.css @@ -13,7 +13,7 @@ color: #0f0f0f; background-color: #f6f6f6; - + font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -21,6 +21,21 @@ -webkit-text-size-adjust: 100%; } +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + .container { margin: 0; padding-top: 10vh; diff --git a/projects/vault-v2/src/components/DeviceUpdateManager.tsx b/projects/vault-v2/src/components/DeviceUpdateManager.tsx index baebd48..f9c785f 100644 --- a/projects/vault-v2/src/components/DeviceUpdateManager.tsx +++ b/projects/vault-v2/src/components/DeviceUpdateManager.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { BootloaderUpdateDialog } from './BootloaderUpdateDialog' import { FirmwareUpdateDialog } from './FirmwareUpdateDialog' -import { WalletCreationWizard } from './WalletCreationWizard/WalletCreationWizard' +import { SetupWizard } from './SetupWizard' import { EnterBootloaderModeDialog } from './EnterBootloaderModeDialog' import { PinUnlockDialog } from './PinUnlockDialog' import type { DeviceStatus, DeviceFeatures } from '../types/device' @@ -90,7 +90,22 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => }) // Determine which dialog to show based on priority - if (status.needsBootloaderUpdate && status.bootloaderCheck) { + // IMPORTANT: Check initialization FIRST - setup wizard should take priority over firmware updates for OOB devices + if (status.needsInitialization) { + // Check if recovery is in progress - if so, don't interfere + if ((window as any).KEEPKEY_RECOVERY_IN_PROGRESS) { + console.log('đŸ›Ąī¸ DeviceUpdateManager: Recovery in progress - IGNORING initialization request') + console.log('đŸ›Ąī¸ DeviceUpdateManager: Keeping current state to protect recovery') + return; // Don't change any state during recovery + } + + console.log('🔧 DeviceUpdateManager: Device needs initialization - SHOULD SHOW SETUP WIZARD') + console.log('🔧 DeviceUpdateManager: Setting showWalletCreation = true') + setShowEnterBootloaderMode(false) + setShowBootloaderUpdate(false) + setShowFirmwareUpdate(false) + setShowWalletCreation(true) + } else if (status.needsBootloaderUpdate && status.bootloaderCheck) { if (isInBootloaderMode) { // Device needs bootloader update AND is in bootloader mode -> show update dialog console.log('Device needs bootloader update and is in bootloader mode') @@ -119,20 +134,6 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => setShowBootloaderUpdate(false) setShowFirmwareUpdate(true) setShowWalletCreation(false) - } else if (status.needsInitialization) { - // Check if recovery is in progress - if so, don't interfere - if ((window as any).KEEPKEY_RECOVERY_IN_PROGRESS) { - console.log('đŸ›Ąī¸ DeviceUpdateManager: Recovery in progress - IGNORING initialization request') - console.log('đŸ›Ąī¸ DeviceUpdateManager: Keeping current state to protect recovery') - return; // Don't change any state during recovery - } - - console.log('🔧 DeviceUpdateManager: Device needs initialization - SHOULD SHOW ONBOARDING WIZARD') - console.log('🔧 DeviceUpdateManager: Setting showWalletCreation = true') - setShowEnterBootloaderMode(false) - setShowBootloaderUpdate(false) - setShowFirmwareUpdate(false) - setShowWalletCreation(true) } else if (status.needsPinUnlock) { // Device is initialized but locked with PIN - this is handled by the PIN unlock event listener console.log('🔒 DeviceUpdateManager: Device needs PIN unlock - NOT calling onComplete()') @@ -509,7 +510,7 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => )} {showWalletCreation && deviceStatus.deviceId && ( - setShowWalletCreation(false)} diff --git a/projects/vault-v2/src/components/Send.tsx b/projects/vault-v2/src/components/Send.tsx index 3717b71..a4d1fad 100644 --- a/projects/vault-v2/src/components/Send.tsx +++ b/projects/vault-v2/src/components/Send.tsx @@ -595,7 +595,7 @@ console.debug('[Send] deviceId from device.unique_id:', deviceId); {/* Show balance warning if zero */} {availableBalance === 0 && ( - âš ī¸ No balance available. Please ensure your wallet is synced. + âš ī¸ No balance available. Please fund your wallet. )} diff --git a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx new file mode 100644 index 0000000..9fc65e2 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx @@ -0,0 +1,365 @@ +import { + Box, + Button, + HStack, + VStack, + Text, + Flex, + Icon, +} from "@chakra-ui/react"; +import { useState } from "react"; +import { FaCheckCircle } from "react-icons/fa"; +import { invoke } from "@tauri-apps/api/core"; +import { useDialog } from "../../contexts/DialogContext"; + +// Import individual steps +import { Step0Welcome } from "./steps/Step0Welcome"; +import { StepBootloaderUpdate } from "./steps/StepBootloaderUpdate"; +import { StepFirmwareUpdate } from "./steps/StepFirmwareUpdate"; +import { Step1CreateOrRecover } from "./steps/Step1CreateOrRecover"; +import { Step2DeviceLabel } from "./steps/Step2DeviceLabel"; +import { Step3Pin } from "./steps/Step3Pin"; +import { Step4BackupOrRecover } from "./steps/Step4BackupOrRecover"; +import { Step5Complete } from "./steps/Step5Complete"; + +interface SetupWizardProps { + deviceId: string; + onClose?: () => void; + onComplete?: () => void; +} + +interface Step { + id: string; + label: string; + description: string; + component: React.ComponentType; +} + +// Define steps based on flow type +const CREATE_STEPS: Step[] = [ + { + id: "welcome", + label: "Welcome", + description: "Welcome to KeepKey Bitcoin-Only", + component: Step0Welcome, + }, + { + id: "bootloader", + label: "Bootloader", + description: "Verify and update bootloader if needed", + component: StepBootloaderUpdate, + }, + { + id: "firmware", + label: "Firmware", + description: "Verify and update firmware if needed", + component: StepFirmwareUpdate, + }, + { + id: "create-or-recover", + label: "Setup Type", + description: "Choose your setup method", + component: Step1CreateOrRecover, + }, + { + id: "device-label", + label: "Device Name", + description: "Name your device", + component: Step2DeviceLabel, + }, + { + id: "pin", + label: "Security", + description: "Set up your PIN", + component: Step3Pin, + }, + { + id: "backup", + label: "Backup", + description: "Backup your recovery phrase", + component: Step4BackupOrRecover, + }, + { + id: "complete", + label: "Complete", + description: "Setup complete!", + component: Step5Complete, + }, +]; + +const RECOVER_STEPS: Step[] = [ + { + id: "welcome", + label: "Welcome", + description: "Welcome to KeepKey Bitcoin-Only", + component: Step0Welcome, + }, + { + id: "bootloader", + label: "Bootloader", + description: "Verify and update bootloader if needed", + component: StepBootloaderUpdate, + }, + { + id: "firmware", + label: "Firmware", + description: "Verify and update firmware if needed", + component: StepFirmwareUpdate, + }, + { + id: "create-or-recover", + label: "Setup Type", + description: "Choose your setup method", + component: Step1CreateOrRecover, + }, + { + id: "recover", + label: "Recovery", + description: "Enter your recovery phrase", + component: Step4BackupOrRecover, + }, + { + id: "device-label", + label: "Device Name", + description: "Name your device", + component: Step2DeviceLabel, + }, + { + id: "pin", + label: "Security", + description: "Set up your PIN", + component: Step3Pin, + }, + { + id: "complete", + label: "Complete", + description: "Recovery complete!", + component: Step5Complete, + }, +]; + +export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) { + const [currentStep, setCurrentStep] = useState(0); + const [flowType, setFlowType] = useState<'create' | 'recover' | null>(null); + const [wizardData, setWizardData] = useState<{ + deviceLabel?: string; + pinSession?: any; + recoverySettings?: any; + }>({}); + + const highlightColor = "orange.500"; // Bitcoin orange + const { hide } = useDialog(); + + // Determine which steps to use based on flow type + const STEPS = flowType === 'recover' ? RECOVER_STEPS : CREATE_STEPS; + + const handleNext = () => { + if (currentStep < STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + handleComplete(); + } + }; + + const handlePrevious = () => { + if (currentStep > 0) { + // If going back from a step after flow type is chosen, reset flow type + const createOrRecoverIndex = STEPS.findIndex(step => step.id === 'create-or-recover'); + if (currentStep > createOrRecoverIndex && STEPS[currentStep].id !== 'create-or-recover') { + // Going back to or before the create-or-recover step + if (currentStep - 1 <= createOrRecoverIndex) { + setFlowType(null); + } + } + setCurrentStep(currentStep - 1); + } + }; + + const handleComplete = async () => { + console.log("=== Starting setup completion ==="); + try { + // Mark setup as completed + console.log("Calling set_onboarding_completed..."); + await invoke("set_onboarding_completed"); + console.log("set_onboarding_completed completed successfully"); + + // Call the completion callback if provided + if (onComplete) { + console.log("Calling onComplete callback"); + onComplete(); + } + + // Use multiple methods to ensure the dialog closes + if (onClose) { + console.log("Calling onClose callback"); + onClose(); + } + + // Use the dialog context directly to force close after a short delay + setTimeout(() => { + hide('setup-wizard'); + console.log('Forced setup wizard closure via DialogContext'); + }, 100); + } catch (error) { + console.error("Failed to mark setup as completed:", error); + } + }; + + const handleFlowTypeSelection = (type: 'create' | 'recover') => { + setFlowType(type); + handleNext(); + }; + + const updateWizardData = (data: Partial) => { + setWizardData(prev => ({ ...prev, ...data })); + }; + + const StepComponent = STEPS[currentStep].component; + const progress = ((currentStep + 1) / STEPS.length) * 100; + + // Props to pass to step components + const stepProps = { + deviceId, + wizardData, + updateWizardData, + onNext: handleNext, + onBack: handlePrevious, + onFlowTypeSelect: handleFlowTypeSelection, + flowType, + }; + + return ( + + {/* Header */} + + + + KeepKey Bitcoin Setup + + + {STEPS[currentStep].description} + + + + + {/* Progress */} + + + + + + + {/* Step indicators */} + + {STEPS.map((step, index) => ( + + + {index < currentStep ? ( + + ) : ( + + {index + 1} + + )} + + + {step.label} + + {index < STEPS.length - 1 && ( + + )} + + ))} + + + {/* Content */} + + + + + {/* Footer */} + + + + Step {currentStep + 1} of {STEPS.length} + + + + {/* Only show Next button if not on flow selection step or if flow is selected */} + {(STEPS[currentStep].id !== 'create-or-recover' || flowType) && ( + + )} + + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/index.ts b/projects/vault-v2/src/components/SetupWizard/index.ts new file mode 100644 index 0000000..342f9c6 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/index.ts @@ -0,0 +1 @@ +export { SetupWizard } from './SetupWizard'; \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step0Welcome.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step0Welcome.tsx new file mode 100644 index 0000000..2f0d25f --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step0Welcome.tsx @@ -0,0 +1,47 @@ +import { VStack, Text, Icon, Box } from "@chakra-ui/react"; +import { FaBitcoin } from "react-icons/fa"; +import { useEffect } from "react"; + +interface Step0WelcomeProps { + onNext: () => void; +} + +export function Step0Welcome({ onNext }: Step0WelcomeProps) { + // Auto-advance after 3 seconds + useEffect(() => { + const timer = setTimeout(() => { + onNext(); + }, 3000); + + return () => clearTimeout(timer); + }, [onNext]); + + return ( + + + + + + + + Welcome to KeepKey + + + Bitcoin-Only Edition + + + The secure hardware wallet focused exclusively on Bitcoin + + + + + Setting up your device... + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx new file mode 100644 index 0000000..3a327d6 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx @@ -0,0 +1,119 @@ +import { VStack, Text, Button, Box, Icon, HStack } from "@chakra-ui/react"; +import { FaPlus, FaKey } from "react-icons/fa"; + +interface Step1CreateOrRecoverProps { + onFlowTypeSelect: (type: 'create' | 'recover') => void; +} + +export function Step1CreateOrRecover({ onFlowTypeSelect }: Step1CreateOrRecoverProps) { + return ( + + + + Choose Setup Method + + + Create a new wallet or restore an existing one + + + + + {/* Create New Wallet */} + onFlowTypeSelect('create')} + > + + + + + + + Create New Wallet + + + Generate a new recovery phrase and set up a fresh wallet + + + + + + + {/* Recover Existing Wallet */} + onFlowTypeSelect('recover')} + > + + + + + + + Recover Wallet + + + Restore your wallet using an existing recovery phrase + + + + + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx new file mode 100644 index 0000000..723f766 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx @@ -0,0 +1,110 @@ +import { VStack, Text, Input, Button, HStack, Box } from "@chakra-ui/react"; +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface Step2DeviceLabelProps { + deviceId: string; + wizardData: any; + updateWizardData: (data: any) => void; + onNext: () => void; + onBack: () => void; +} + +export function Step2DeviceLabel({ + deviceId, + wizardData, + updateWizardData, + onNext, + onBack +}: Step2DeviceLabelProps) { + const [label, setLabel] = useState(wizardData.deviceLabel || ""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setIsLoading(true); + setError(null); + + try { + if (label.trim()) { + await invoke('set_device_label', { deviceId, label: label.trim() }); + } + updateWizardData({ deviceLabel: label.trim() || 'KeepKey' }); + onNext(); + } catch (err) { + console.error("Failed to set device label:", err); + setError(`Failed to set device label: ${err}`); + } finally { + setIsLoading(false); + } + }; + + const handleSkip = () => { + updateWizardData({ deviceLabel: 'KeepKey' }); + onNext(); + }; + + return ( + + + + Name Your Device + + + Give your KeepKey a friendly name to identify it easily + + + + + setLabel(e.target.value)} + size="lg" + bg="gray.700" + borderColor="gray.600" + _hover={{ borderColor: "gray.500" }} + _focus={{ borderColor: "orange.500", boxShadow: "0 0 0 1px orange.500" }} + color="white" + isDisabled={isLoading} + onKeyPress={(e) => { + if (e.key === 'Enter' && label.trim()) { + handleSubmit(); + } + }} + /> + {error && ( + + {error} + + )} + + + + + + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx new file mode 100644 index 0000000..53e3868 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx @@ -0,0 +1,38 @@ +import { Box } from "@chakra-ui/react"; +import { DevicePin } from "../../WalletCreationWizard/DevicePin"; + +interface Step3PinProps { + deviceId: string; + wizardData: any; + updateWizardData: (data: any) => void; + onNext: () => void; + onBack: () => void; +} + +export function Step3Pin({ + deviceId, + wizardData, + updateWizardData, + onNext, + onBack +}: Step3PinProps) { + + const handlePinComplete = (pinSession: any) => { + updateWizardData({ pinSession }); + onNext(); + }; + + return ( + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx new file mode 100644 index 0000000..a7d0cd8 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx @@ -0,0 +1,156 @@ +import { Box, VStack, Text, Button, Icon } from "@chakra-ui/react"; +import { FaShieldAlt } from "react-icons/fa"; +import { RecoveryFlow } from "../../WalletCreationWizard/RecoveryFlow"; +import { RecoverySettings } from "../../WalletCreationWizard/RecoverySettings"; +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; + +interface Step4BackupOrRecoverProps { + deviceId: string; + wizardData: any; + updateWizardData: (data: any) => void; + onNext: () => void; + onBack: () => void; + flowType: 'create' | 'recover' | null; +} + +export function Step4BackupOrRecover({ + deviceId, + wizardData, + updateWizardData, + onNext, + onBack, + flowType +}: Step4BackupOrRecoverProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [showRecoverySettings, setShowRecoverySettings] = useState(true); + + // Create flow - show backup phrase + if (flowType === 'create') { + const handleBackupComplete = async () => { + setIsLoading(true); + try { + await invoke('complete_wallet_creation', { deviceId }); + onNext(); + } catch (err) { + console.error("Failed to complete wallet creation:", err); + setError(`Failed to complete wallet creation: ${err}`); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + Backup Your Recovery Phrase + + + Your recovery phrase is now displayed on your KeepKey device + + + + + + + âš ī¸ Important Instructions: + + + 1. Write down each word exactly as shown on your device + + + 2. Store your recovery phrase in a safe place + + + 3. Never share it with anyone or store it digitally + + + 4. This is your only way to recover your funds + + + + + {error && ( + + {error} + + )} + + + + ); + } + + // Recover flow - show recovery options then recovery flow + if (flowType === 'recover') { + if (showRecoverySettings && !wizardData.recoverySettings) { + const handleRecoverySettingsComplete = (settings: any) => { + updateWizardData({ recoverySettings: settings }); + setShowRecoverySettings(false); + }; + + return ( + + ); + } + + const handleRecoveryComplete = async () => { + setIsLoading(true); + try { + await invoke('complete_recovery', { deviceId }); + onNext(); + } catch (err) { + console.error("Failed to complete recovery:", err); + setError(`Failed to complete recovery: ${err}`); + } finally { + setIsLoading(false); + } + }; + + const handleRecoveryError = (error: string) => { + setError(error); + }; + + return ( + { + setShowRecoverySettings(true); + updateWizardData({ recoverySettings: null }); + }} + /> + ); + } + + return null; +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step5Complete.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step5Complete.tsx new file mode 100644 index 0000000..26a49eb --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step5Complete.tsx @@ -0,0 +1,58 @@ +import { VStack, Text, Icon, Box, Button } from "@chakra-ui/react"; +import { FaCheckCircle } from "react-icons/fa"; +import { useEffect } from "react"; + +interface Step5CompleteProps { + wizardData: any; + flowType: 'create' | 'recover' | null; + onNext: () => void; +} + +export function Step5Complete({ wizardData, flowType, onNext }: Step5CompleteProps) { + // Auto-complete after 3 seconds + useEffect(() => { + const timer = setTimeout(() => { + onNext(); + }, 3000); + + return () => clearTimeout(timer); + }, [onNext]); + + const isRecovery = flowType === 'recover'; + + return ( + + + + + + + + 🎉 {isRecovery ? 'Wallet Recovered!' : 'Wallet Created!'} + + + Your KeepKey {wizardData.deviceLabel && `"${wizardData.deviceLabel}"`} is ready + + + {isRecovery + ? 'Your wallet has been successfully restored from your recovery phrase' + : 'Your new Bitcoin wallet is now secure and ready to use' + } + + + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx new file mode 100644 index 0000000..b9f9d34 --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx @@ -0,0 +1,158 @@ +import { VStack, Text, Button, Box, Icon, Progress, Alert } from "@chakra-ui/react"; +import { FaShieldAlt } from "react-icons/fa"; +import { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface StepBootloaderUpdateProps { + deviceId: string; + onNext: () => void; + onBack: () => void; +} + +export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloaderUpdateProps) { + const [deviceStatus, setDeviceStatus] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + const [updateProgress, setUpdateProgress] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + checkDeviceStatus(); + }, [deviceId]); + + const checkDeviceStatus = async () => { + try { + const status = await invoke('get_device_status', { deviceId }); + setDeviceStatus(status); + + // If bootloader doesn't need update, skip to next step + if (!status.needsBootloaderUpdate) { + console.log("Bootloader is up to date, skipping to next step"); + onNext(); + } + } catch (err) { + console.error("Failed to get device status:", err); + setError(`Failed to check device status: ${err}`); + } + }; + + const handleBootloaderUpdate = async () => { + setIsUpdating(true); + setError(null); + + try { + // Start bootloader update + await invoke('update_bootloader', { deviceId }); + + // Simulate progress (in real implementation, listen to progress events) + const progressInterval = setInterval(() => { + setUpdateProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval); + return 100; + } + return prev + 10; + }); + }, 500); + + // Wait for update to complete + setTimeout(() => { + clearInterval(progressInterval); + setUpdateProgress(100); + onNext(); + }, 5000); + + } catch (err) { + console.error("Failed to update bootloader:", err); + setError(`Failed to update bootloader: ${err}`); + setIsUpdating(false); + } + }; + + const handleSkip = () => { + console.log("Skipping bootloader update"); + onNext(); + }; + + if (!deviceStatus) { + return ( + + Checking device status... + + ); + } + + return ( + + + + + + Bootloader Update + + {deviceStatus.needsBootloaderUpdate ? ( + + Your KeepKey bootloader needs to be updated for optimal security + + ) : ( + + Your bootloader is up to date! + + )} + + + {deviceStatus.bootloaderCheck && ( + + + + Current Version: v{deviceStatus.bootloaderCheck.currentVersion} + + + Latest Version: v{deviceStatus.bootloaderCheck.latestVersion} + + + + )} + + {error && ( + + {error} + + )} + + {isUpdating && ( + + + Updating bootloader... Do not disconnect your device + + + + )} + + + {deviceStatus.needsBootloaderUpdate && !isUpdating && ( + <> + + + + )} + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx new file mode 100644 index 0000000..ce3210f --- /dev/null +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -0,0 +1,194 @@ +import { VStack, Text, Button, Box, Icon, Progress, Alert, Badge } from "@chakra-ui/react"; +import { FaDownload } from "react-icons/fa"; +import { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface StepFirmwareUpdateProps { + deviceId: string; + onNext: () => void; + onBack: () => void; +} + +export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpdateProps) { + const [deviceStatus, setDeviceStatus] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + const [updateProgress, setUpdateProgress] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + checkDeviceStatus(); + }, [deviceId]); + + const checkDeviceStatus = async () => { + try { + const status = await invoke('get_device_status', { deviceId }); + setDeviceStatus(status); + + // If firmware doesn't need update, skip to next step + if (!status.needsFirmwareUpdate) { + console.log("Firmware is up to date, skipping to next step"); + onNext(); + } + } catch (err) { + console.error("Failed to get device status:", err); + setError(`Failed to check device status: ${err}`); + } + }; + + const handleFirmwareUpdate = async () => { + setIsUpdating(true); + setError(null); + + try { + // Start firmware update + await invoke('update_firmware', { deviceId }); + + // Simulate progress (in real implementation, listen to progress events) + const progressInterval = setInterval(() => { + setUpdateProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval); + return 100; + } + return prev + 5; + }); + }, 500); + + // Wait for update to complete + setTimeout(() => { + clearInterval(progressInterval); + setUpdateProgress(100); + onNext(); + }, 10000); // Firmware updates take longer + + } catch (err) { + console.error("Failed to update firmware:", err); + setError(`Failed to update firmware: ${err}`); + setIsUpdating(false); + } + }; + + const handleSkip = () => { + console.log("Skipping firmware update"); + onNext(); + }; + + if (!deviceStatus) { + return ( + + Checking device status... + + ); + } + + const isOOBDevice = deviceStatus.firmwareCheck?.currentVersion === "4.0.0"; + + return ( + + + + + + Firmware Update + + {deviceStatus.needsFirmwareUpdate ? ( + <> + + A new firmware version is available for your KeepKey + + {isOOBDevice && ( + + Critical Update Required + + )} + + ) : ( + + Your firmware is up to date! + + )} + + + {deviceStatus.firmwareCheck && ( + + + + Current Version: v{deviceStatus.firmwareCheck.currentVersion} + + + Latest Version: v{deviceStatus.firmwareCheck.latestVersion} + + {isOOBDevice && ( + + âš ī¸ Your device has factory firmware. Update is highly recommended. + + )} + + + )} + + {error && ( + + {error} + + )} + + {isUpdating && ( + + + Updating firmware... Do not disconnect your device + + + + This may take a few minutes. Your device will restart when complete. + + + )} + + + + + âš ī¸ Important Instructions: + + + â€ĸ Do not disconnect your device during the update + + + â€ĸ You may need to re-enter your PIN after the update + + + â€ĸ Your funds and settings will remain safe + + + + + + {deviceStatus.needsFirmwareUpdate && !isUpdating && ( + <> + + {!isOOBDevice && ( + + )} + + )} + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/contexts/DialogContext.tsx b/projects/vault-v2/src/contexts/DialogContext.tsx index 0e36afd..0d50105 100644 --- a/projects/vault-v2/src/contexts/DialogContext.tsx +++ b/projects/vault-v2/src/contexts/DialogContext.tsx @@ -551,7 +551,7 @@ export function useWalletCreationWizard() { show({ id: dialogId, - component: React.lazy(() => import('../components/WalletCreationWizard/WalletCreationWizard').then(m => ({ default: m.WalletCreationWizard }))), + component: React.lazy(() => import('../components/SetupWizard').then(m => ({ default: m.SetupWizard }))), props: { ...props, onClose: () => { diff --git a/projects/vault-v2/src/hooks/useCommonDialogs.tsx b/projects/vault-v2/src/hooks/useCommonDialogs.tsx index 27681f9..8bea633 100644 --- a/projects/vault-v2/src/hooks/useCommonDialogs.tsx +++ b/projects/vault-v2/src/hooks/useCommonDialogs.tsx @@ -2,7 +2,7 @@ import { useDialog } from '../contexts/DialogContext'; import { useCallback } from 'react'; import React from 'react'; import { OnboardingWizard } from '../components/OnboardingWizard/OnboardingWizard'; -import { WalletCreationWizard } from '../components/WalletCreationWizard/WalletCreationWizard'; +import { SetupWizard } from '../components/SetupWizard'; // Import dialog components dynamically const dialogComponents = { @@ -49,19 +49,20 @@ export function useCommonDialogs() { console.trace('đŸĻ [useCommonDialogs] Call stack trace:'); show({ - id: 'wallet-creation', - component: WalletCreationWizard, // Direct component instead of lazy loading for better UX + id: 'setup-wizard', + component: SetupWizard, // Using new SetupWizard component props: { ...props, + deviceId: props?.deviceId || 'mock-device-id', onComplete: () => { - console.log(`đŸĻ [useCommonDialogs] showWalletCreation onComplete called`); + console.log(`đŸĻ [useCommonDialogs] SetupWizard onComplete called`); if (props?.onWizardComplete) props.onWizardComplete(); - hide('wallet-creation'); + hide('setup-wizard'); }, onClose: () => { - console.log(`đŸĻ [useCommonDialogs] showWalletCreation onClose called`); + console.log(`đŸĻ [useCommonDialogs] SetupWizard onClose called`); if (props?.onWizardClose) props.onWizardClose(); - hide('wallet-creation'); + hide('setup-wizard'); } }, priority: 'critical', // High priority since it's device setup From 4db4079e2bd7f680c7f807161f14791b5db61f24 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 17:34:38 -0500 Subject: [PATCH 03/63] UX setup wizard --- projects/vault-v2/src/App.tsx | 8 +- .../src/components/DeviceUpdateManager.tsx | 55 ++- .../components/SetupWizard/SetupWizard.tsx | 175 ++++++---- .../steps/Step1CreateOrRecover.tsx | 45 ++- .../components/SetupWizard/steps/Step3Pin.tsx | 4 +- .../steps/StepBootloaderUpdate.tsx | 318 +++++++++++++----- .../SetupWizard/steps/StepFirmwareUpdate.tsx | 244 +++++++------- .../vault-v2/src/contexts/DialogContext.tsx | 15 +- 8 files changed, 560 insertions(+), 304 deletions(-) diff --git a/projects/vault-v2/src/App.tsx b/projects/vault-v2/src/App.tsx index 4577e1e..fe98237 100644 --- a/projects/vault-v2/src/App.tsx +++ b/projects/vault-v2/src/App.tsx @@ -55,9 +55,10 @@ function App() { const [isRestarting, setIsRestarting] = useState(false); const [deviceUpdateComplete, setDeviceUpdateComplete] = useState(false); const [onboardingActive, setOnboardingActive] = useState(false); + const [setupWizardActive, setSetupWizardActive] = useState(false); const { showOnboarding, showError } = useCommonDialogs(); const { shouldShowOnboarding, loading: onboardingLoading, clearCache } = useOnboardingState(); - const { hideAll, activeDialog, getQueue } = useDialog(); + const { hideAll, activeDialog, getQueue, isWizardActive } = useDialog(); const { fetchedXpubs, portfolio, isSync, reinitialize } = useWallet(); // Check wallet context state and sync with local state @@ -344,8 +345,8 @@ function App() { alignItems="center" justifyContent="center" > - {/* Clickable Logo in the center */} - {!onboardingActive && ( + {/* Clickable Logo in the center - hide when wizards are active */} + {!onboardingActive && !isWizardActive() && !setupWizardActive && ( {/* REST and MCP links in bottom right corner */} diff --git a/projects/vault-v2/src/components/DeviceUpdateManager.tsx b/projects/vault-v2/src/components/DeviceUpdateManager.tsx index f9c785f..fe2feff 100644 --- a/projects/vault-v2/src/components/DeviceUpdateManager.tsx +++ b/projects/vault-v2/src/components/DeviceUpdateManager.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { BootloaderUpdateDialog } from './BootloaderUpdateDialog' import { FirmwareUpdateDialog } from './FirmwareUpdateDialog' import { SetupWizard } from './SetupWizard' @@ -13,9 +13,11 @@ import { useDeviceInvalidStateDialog } from '../contexts/DialogContext' interface DeviceUpdateManagerProps { // Optional callback when all updates/setup is complete onComplete?: () => void + // Optional callback to notify when setup wizard active state changes + onSetupWizardActiveChange?: (active: boolean) => void } -export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => { +export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: DeviceUpdateManagerProps) => { const [deviceStatus, setDeviceStatus] = useState(null) const [showEnterBootloaderMode, setShowEnterBootloaderMode] = useState(false) const [showBootloaderUpdate, setShowBootloaderUpdate] = useState(false) @@ -25,6 +27,11 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => const [isProcessing, setIsProcessing] = useState(false) const [connectedDeviceId, setConnectedDeviceId] = useState(null) const [retryCount, setRetryCount] = useState(0) + + // Use ref to track setup wizard state for persistence + const setupWizardActive = useRef(false) + const setupWizardDeviceId = useRef(null) + const [persistentDeviceId, setPersistentDeviceId] = useState(null) // Get wallet context for portfolio loading const { refreshPortfolio, fetchedXpubs } = useWallet() @@ -90,6 +97,12 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => }) // Determine which dialog to show based on priority + // IMPORTANT: If setup wizard is already showing, don't interrupt it + if (setupWizardActive.current) { + console.log('🔧 DeviceUpdateManager: Setup wizard is already showing - keeping it visible') + return; // Don't change state while setup wizard is active + } + // IMPORTANT: Check initialization FIRST - setup wizard should take priority over firmware updates for OOB devices if (status.needsInitialization) { // Check if recovery is in progress - if so, don't interfere @@ -105,6 +118,10 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => setShowBootloaderUpdate(false) setShowFirmwareUpdate(false) setShowWalletCreation(true) + setupWizardActive.current = true + setupWizardDeviceId.current = status.deviceId + setPersistentDeviceId(status.deviceId) // Save device ID for persistence + onSetupWizardActiveChange?.(true) } else if (status.needsBootloaderUpdate && status.bootloaderCheck) { if (isInBootloaderMode) { // Device needs bootloader update AND is in bootloader mode -> show update dialog @@ -335,11 +352,21 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => } // Clear all state when device disconnects (only if not in recovery) - setDeviceStatus(null) + // IMPORTANT: Don't hide the setup wizard - it should persist until complete + + // Only clear device status if setup wizard is not active + if (!setupWizardActive.current) { + setDeviceStatus(null) + setShowWalletCreation(false) + } else { + console.log('đŸ›Ąī¸ DeviceUpdateManager: Setup wizard active - preserving state during disconnect') + // Keep minimal device status for the wizard + setDeviceStatus(prev => prev ? { ...prev, connected: false } : null) + } + setConnectedDeviceId(null) setShowBootloaderUpdate(false) setShowFirmwareUpdate(false) - setShowWalletCreation(false) setShowPinUnlock(false) setRetryCount(0) if (timeoutId) clearTimeout(timeoutId) @@ -416,6 +443,10 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => const handleWalletCreationComplete = () => { setShowWalletCreation(false) + setupWizardActive.current = false + setupWizardDeviceId.current = null + setPersistentDeviceId(null) // Clear persistent device ID + onSetupWizardActiveChange?.(false) onComplete?.() } @@ -470,7 +501,9 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => showBootloaderUpdate, showEnterBootloaderMode, showPinUnlock, - deviceStatus: deviceStatus?.needsInitialization + deviceStatus: deviceStatus?.needsInitialization, + persistentDeviceId, + setupWizardActive: setupWizardActive.current }) return ( @@ -509,11 +542,17 @@ export const DeviceUpdateManager = ({ onComplete }: DeviceUpdateManagerProps) => /> )} - {showWalletCreation && deviceStatus.deviceId && ( + {showWalletCreation && (persistentDeviceId || deviceStatus?.deviceId) && ( setShowWalletCreation(false)} + onClose={() => { + setShowWalletCreation(false) + setupWizardActive.current = false + setupWizardDeviceId.current = null + setPersistentDeviceId(null) + onSetupWizardActiveChange?.(false) + }} /> )} diff --git a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx index 9fc65e2..6549c3b 100644 --- a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx +++ b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx @@ -35,8 +35,8 @@ interface Step { component: React.ComponentType; } -// Define steps based on flow type -const CREATE_STEPS: Step[] = [ +// Define all steps (including hidden ones) +const CREATE_ALL_STEPS: Step[] = [ { id: "welcome", label: "Welcome", @@ -87,7 +87,7 @@ const CREATE_STEPS: Step[] = [ }, ]; -const RECOVER_STEPS: Step[] = [ +const RECOVER_ALL_STEPS: Step[] = [ { id: "welcome", label: "Welcome", @@ -138,6 +138,23 @@ const RECOVER_STEPS: Step[] = [ }, ]; +// Define visible steps for progress bar +const CREATE_VISIBLE_STEPS = [ + { id: "create-or-recover", label: "Setup", number: 1 }, + { id: "device-label", label: "Device", number: 2 }, + { id: "pin", label: "Security", number: 3 }, + { id: "backup", label: "Backup", number: 4 }, + { id: "complete", label: "Complete", number: 5 }, +]; + +const RECOVER_VISIBLE_STEPS = [ + { id: "create-or-recover", label: "Setup", number: 1 }, + { id: "recover", label: "Recovery", number: 2 }, + { id: "device-label", label: "Device", number: 3 }, + { id: "pin", label: "Security", number: 4 }, + { id: "complete", label: "Complete", number: 5 }, +]; + export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) { const [currentStep, setCurrentStep] = useState(0); const [flowType, setFlowType] = useState<'create' | 'recover' | null>(null); @@ -151,10 +168,11 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) const { hide } = useDialog(); // Determine which steps to use based on flow type - const STEPS = flowType === 'recover' ? RECOVER_STEPS : CREATE_STEPS; + const ALL_STEPS = flowType === 'recover' ? RECOVER_ALL_STEPS : CREATE_ALL_STEPS; + const VISIBLE_STEPS = flowType === 'recover' ? RECOVER_VISIBLE_STEPS : CREATE_VISIBLE_STEPS; const handleNext = () => { - if (currentStep < STEPS.length - 1) { + if (currentStep < ALL_STEPS.length - 1) { setCurrentStep(currentStep + 1); } else { handleComplete(); @@ -164,8 +182,8 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) const handlePrevious = () => { if (currentStep > 0) { // If going back from a step after flow type is chosen, reset flow type - const createOrRecoverIndex = STEPS.findIndex(step => step.id === 'create-or-recover'); - if (currentStep > createOrRecoverIndex && STEPS[currentStep].id !== 'create-or-recover') { + const createOrRecoverIndex = ALL_STEPS.findIndex(step => step.id === 'create-or-recover'); + if (currentStep > createOrRecoverIndex && ALL_STEPS[currentStep].id !== 'create-or-recover') { // Going back to or before the create-or-recover step if (currentStep - 1 <= createOrRecoverIndex) { setFlowType(null); @@ -214,8 +232,14 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) setWizardData(prev => ({ ...prev, ...data })); }; - const StepComponent = STEPS[currentStep].component; - const progress = ((currentStep + 1) / STEPS.length) * 100; + const StepComponent = ALL_STEPS[currentStep].component; + + // Calculate progress based on visible steps + const currentStepId = ALL_STEPS[currentStep].id; + const visibleStepIndex = VISIBLE_STEPS.findIndex(step => step.id === currentStepId); + const actualProgress = visibleStepIndex >= 0 + ? ((visibleStepIndex + 1) / VISIBLE_STEPS.length) * 100 + : 0; // Props to pass to step components const stepProps = { @@ -230,14 +254,17 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) return ( {/* Header */} - {STEPS[currentStep].description} + {ALL_STEPS[currentStep].description} @@ -269,72 +296,92 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) bg={highlightColor} borderRadius="full" transition="width 0.3s" - w={`${progress}%`} + w={`${actualProgress}%`} /> - {/* Step indicators */} - - {STEPS.map((step, index) => ( - - - {index < currentStep ? ( - - ) : ( - - {index + 1} + {/* Step indicators with improved responsive layout */} + + + {VISIBLE_STEPS.map((step, index) => { + const isCompleted = ALL_STEPS.findIndex(s => s.id === step.id) < currentStep; + const isCurrent = ALL_STEPS[currentStep]?.id === step.id; + const isActive = isCompleted || isCurrent; + + return ( + + + {isCompleted ? ( + + ) : ( + + {step.number} + + )} + + + {step.label} - )} - - - {step.label} - - {index < STEPS.length - 1 && ( - - )} - - ))} - + {index < VISIBLE_STEPS.length - 1 && ( + + )} + + ); + })} + + {/* Content */} - + + + {/* Footer */} - Step {currentStep + 1} of {STEPS.length} + {visibleStepIndex >= 0 && `Step ${visibleStepIndex + 1} of ${VISIBLE_STEPS.length}`} )} diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx index 3a327d6..be2744e 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step1CreateOrRecover.tsx @@ -7,21 +7,28 @@ interface Step1CreateOrRecoverProps { export function Step1CreateOrRecover({ onFlowTypeSelect }: Step1CreateOrRecoverProps) { return ( - + - + Choose Setup Method - + Create a new wallet or restore an existing one - + {/* Create New Wallet */} - + - + Create New Wallet - + Generate a new recovery phrase and set up a fresh wallet + + + + + + ); + } + + return ( + + + {/* Left side - Icon and status */} + + + + + + Bootloader Update + {deviceStatus.needsBootloaderUpdate ? ( + + Your KeepKey bootloader needs to be updated for optimal security + + ) : ( + + Your bootloader is up to date! + + )} - - )} - - {error && ( - - {error} - - )} - - {isUpdating && ( - - - Updating bootloader... Do not disconnect your device - - - - )} - - - {deviceStatus.needsBootloaderUpdate && !isUpdating && ( - <> - - - - )} - - + + + {/* Right side - Details and actions */} + + {deviceStatus.bootloaderCheck && ( + + + + + Current Version + + + v{deviceStatus.bootloaderCheck.currentVersion} + + + + + Latest Version + + + v{deviceStatus.bootloaderCheck.latestVersion} + + + + + )} + + {error && ( + + {error} + + )} + + {isUpdating && ( + + + Updating bootloader... Do not disconnect your device + + + + )} + + + {deviceStatus.needsBootloaderUpdate && !isUpdating && ( + <> + + + + )} + + + + ); } \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx index ce3210f..71d3ed2 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -1,4 +1,4 @@ -import { VStack, Text, Button, Box, Icon, Progress, Alert, Badge } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Box, Icon, Progress, Alert, Badge } from "@chakra-ui/react"; import { FaDownload } from "react-icons/fa"; import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; @@ -41,25 +41,14 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd try { // Start firmware update - await invoke('update_firmware', { deviceId }); + await invoke('update_device_firmware', { + deviceId, + targetVersion: deviceStatus.firmwareCheck?.latestVersion || '' + }); - // Simulate progress (in real implementation, listen to progress events) - const progressInterval = setInterval(() => { - setUpdateProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval); - return 100; - } - return prev + 5; - }); - }, 500); - - // Wait for update to complete - setTimeout(() => { - clearInterval(progressInterval); - setUpdateProgress(100); - onNext(); - }, 10000); // Firmware updates take longer + // In real implementation, listen to progress events + // For now, skip to next step after the update + onNext(); } catch (err) { console.error("Failed to update firmware:", err); @@ -84,111 +73,134 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd const isOOBDevice = deviceStatus.firmwareCheck?.currentVersion === "4.0.0"; return ( - - - - - - Firmware Update - - {deviceStatus.needsFirmwareUpdate ? ( - <> - - A new firmware version is available for your KeepKey - - {isOOBDevice && ( - - Critical Update Required - - )} - - ) : ( - - Your firmware is up to date! - - )} - - - {deviceStatus.firmwareCheck && ( - - - - Current Version: v{deviceStatus.firmwareCheck.currentVersion} - - - Latest Version: v{deviceStatus.firmwareCheck.latestVersion} + + + {/* Left side - Icon and status */} + + + + + + Firmware Update - {isOOBDevice && ( - - âš ī¸ Your device has factory firmware. Update is highly recommended. + {deviceStatus.needsFirmwareUpdate ? ( + <> + + A new firmware version is available for your KeepKey + + {isOOBDevice && ( + + Critical Update Required + + )} + + ) : ( + + Your firmware is up to date! )} - - )} - {error && ( - - {error} - - )} + {/* Important Instructions */} + + + + âš ī¸ Important Instructions: + + + â€ĸ Do not disconnect your device during the update + + + â€ĸ You may need to re-enter your PIN after the update + + + â€ĸ Your funds and settings will remain safe + + + + + + {/* Right side - Details and actions */} + + {deviceStatus.firmwareCheck && ( + + + + + Current Version + + + v{deviceStatus.firmwareCheck.currentVersion} + + + + + Latest Version + + + v{deviceStatus.firmwareCheck.latestVersion} + + + + {isOOBDevice && ( + + âš ī¸ Your device has factory firmware. Update is highly recommended. + + )} + + )} - {isUpdating && ( - - - Updating firmware... Do not disconnect your device - - - - This may take a few minutes. Your device will restart when complete. - - - )} + {error && ( + + {error} + + )} - - - - âš ī¸ Important Instructions: - - - â€ĸ Do not disconnect your device during the update - - - â€ĸ You may need to re-enter your PIN after the update - - - â€ĸ Your funds and settings will remain safe - - - + {isUpdating && ( + + + Updating firmware... Do not disconnect your device + + + + This may take a few minutes. Your device will restart when complete. + + + )} - - {deviceStatus.needsFirmwareUpdate && !isUpdating && ( - <> - - {!isOOBDevice && ( - + + {deviceStatus.needsFirmwareUpdate && !isUpdating && ( + <> + + {!isOOBDevice && ( + + )} + )} - - )} - - + + + + ); } \ No newline at end of file diff --git a/projects/vault-v2/src/contexts/DialogContext.tsx b/projects/vault-v2/src/contexts/DialogContext.tsx index 0d50105..a8ac163 100644 --- a/projects/vault-v2/src/contexts/DialogContext.tsx +++ b/projects/vault-v2/src/contexts/DialogContext.tsx @@ -35,6 +35,9 @@ interface DialogContextType { // App focus management requestAppFocus: () => void; releaseAppFocus: () => void; + + // Check if any wizard is active + isWizardActive: () => boolean; } const DialogContext = createContext(null); @@ -259,6 +262,11 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { } }, []); + const isWizardActive = useCallback(() => { + const wizardIds = ['setup-wizard', 'onboarding', 'wallet-creation', 'firmware-update', 'bootloader-update']; + return state.active ? wizardIds.includes(state.active.id) : false; + }, [state.active]); + const value: DialogContextType = { show, hide, @@ -269,6 +277,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { activeDialog: state.active, requestAppFocus, releaseAppFocus, + isWizardActive, }; return ( @@ -286,11 +295,13 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { right: 0, bottom: 0, zIndex: state.active.id.includes('pin-unlock') ? 99999 : 9999, - backgroundColor: 'rgba(0, 0, 0, 0.5)', + backgroundColor: 'rgba(0, 0, 0, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', - pointerEvents: 'auto' + pointerEvents: 'auto', + padding: isWizardActive() ? '0' : '20px', + overflow: 'auto' }} > Date: Sat, 2 Aug 2025 17:34:41 -0500 Subject: [PATCH 04/63] UX setup wizard --- .../DevicePinHorizontal.tsx | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx diff --git a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx new file mode 100644 index 0000000..b5a174e --- /dev/null +++ b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx @@ -0,0 +1,309 @@ +import { + Button, + Text, + VStack, + Box, + HStack, + SimpleGrid, + Input, + Icon, + Heading, + Image, + Flex, +} from "@chakra-ui/react"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { FaCircle, FaChevronDown, FaChevronRight, FaInfoCircle } from "react-icons/fa"; +import cipherImage from "../../assets/onboarding/cipher.png"; +import { PinService } from "../../services/pinService"; +import { PinCreationSession, PinStep, PinPosition, PIN_MATRIX_LAYOUT } from "../../types/pin"; + +interface DevicePinHorizontalProps { + deviceId: string; + deviceLabel?: string; + mode: 'create' | 'confirm'; + onComplete: (session: PinCreationSession) => void; + onBack?: () => void; + isLoading?: boolean; + error?: string | null; +} + +export function DevicePinHorizontal({ + deviceId, + deviceLabel, + mode, + onComplete, + onBack, + isLoading = false, + error +}: DevicePinHorizontalProps) { + const [positions, setPositions] = useState([]); + const [showMoreInfo, setShowMoreInfo] = useState(false); + const [session, setSession] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [stepError, setStepError] = useState(null); + const inputRef = useRef(null); + + // Dynamic title and description based on session state + const getTitle = () => { + if (!session) return 'Initializing PIN Setup...'; + switch (session.current_step) { + case PinStep.AwaitingFirst: + return 'Create Your PIN'; + case PinStep.AwaitingSecond: + return 'Confirm Your PIN'; + default: + return 'PIN Setup'; + } + }; + + const getDescription = () => { + if (!session) return 'Starting PIN creation session with your KeepKey device...'; + switch (session.current_step) { + case PinStep.AwaitingFirst: + return 'Use PIN layout shown on your device to find the location to press on the PIN pad.'; + case PinStep.AwaitingSecond: + return 'Re-enter your PIN to confirm it matches.'; + default: + return 'Processing PIN setup...'; + } + }; + + // Initialize PIN creation session + useEffect(() => { + const initializeSession = async () => { + if (!session && mode === 'create') { + try { + const newSession = await PinService.startPinCreation(deviceId, deviceLabel); + setSession(newSession); + setStepError(null); + } catch (error) { + console.error("PIN creation initialization error:", error); + setStepError(`Failed to start PIN creation: ${error}`); + } + } + }; + + initializeSession(); + }, [deviceId, mode, session, deviceLabel]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + const handlePinPress = useCallback((position: PinPosition) => { + if (positions.length < 9 && !isLoading && !isSubmitting) { + setPositions(prev => [...prev, position]); + } + }, [positions.length, isLoading, isSubmitting]); + + const handleBackspace = useCallback(() => { + if (!isLoading && !isSubmitting) { + setPositions(prev => prev.slice(0, -1)); + } + }, [isLoading, isSubmitting]); + + const handleSubmit = useCallback(async () => { + if (positions.length > 0 && !isLoading && !isSubmitting && session) { + setIsSubmitting(true); + setStepError(null); + + try { + const updatedSession = await PinService.submitPin( + session.id, + deviceId, + positions, + session.current_step === PinStep.AwaitingFirst + ); + + if (updatedSession.current_step === PinStep.Complete && updatedSession.success) { + onComplete(updatedSession); + } else if (updatedSession.current_step === PinStep.AwaitingSecond) { + setSession(updatedSession); + setPositions([]); + } + } catch (error) { + console.error("PIN submission error:", error); + setStepError(`Failed to submit PIN: ${error}`); + } finally { + setIsSubmitting(false); + } + } + }, [positions, isLoading, isSubmitting, session, deviceId, onComplete]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isLoading || isSubmitting) return; + + if (e.key === 'Backspace') { + handleBackspace(); + } else if (e.key === 'Enter') { + handleSubmit(); + } else if (PIN_MATRIX_LAYOUT.includes(Number(e.key) as any)) { + handlePinPress(Number(e.key) as PinPosition); + } + }, [handleBackspace, handleSubmit, handlePinPress, isLoading, isSubmitting]); + + // Generate PIN dots for display + const maxDotsToShow = Math.max(4, positions.length + (positions.length < 8 ? 1 : 0)); + const pinDots = Array.from({ length: Math.min(maxDotsToShow, 8) }, (_, i) => ( + + )); + + return ( + + + {/* Left side - PIN Entry */} + + + + {getTitle()} + + + {getDescription()} + + + + {/* PIN Dots Display */} + + + {pinDots} + + + + {/* PIN Length Hints */} + + {positions.length === 0 && ( + + 💡 We recommend using 4 digits for optimal security + + )} + {positions.length > 0 && positions.length < 4 && ( + + {4 - positions.length} more digit{4 - positions.length !== 1 ? 's' : ''} recommended + + )} + {positions.length === 4 && ( + + ✅ Perfect! 4 digits provides great security + + )} + + + + {/* Right side - PIN Pad */} + + {/* Scrambled PIN Grid */} + + + {PIN_MATRIX_LAYOUT.map((position) => ( + + ))} + + + + {/* Action Buttons */} + + + + + + + + {/* Hidden input for keyboard support */} + {}} + onKeyDown={handleKeyDown} + position="absolute" + opacity={0} + pointerEvents="none" + aria-hidden="true" + /> + + {/* Error display */} + {(stepError || error) && ( + + + {stepError || error} + + + )} + + ); +} \ No newline at end of file From c21d6089d2dd24f1c616f8eb550b1145fbbefd40 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 18:22:06 -0500 Subject: [PATCH 05/63] UX setup wizard --- .../src/components/DeviceUpdateManager.tsx | 32 +++-- .../steps/StepBootloaderUpdate.tsx | 111 ++++++------------ .../SetupWizard/steps/StepFirmwareUpdate.tsx | 17 ++- 3 files changed, 74 insertions(+), 86 deletions(-) diff --git a/projects/vault-v2/src/components/DeviceUpdateManager.tsx b/projects/vault-v2/src/components/DeviceUpdateManager.tsx index fe2feff..7b6e3de 100644 --- a/projects/vault-v2/src/components/DeviceUpdateManager.tsx +++ b/projects/vault-v2/src/components/DeviceUpdateManager.tsx @@ -103,8 +103,14 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D return; // Don't change state while setup wizard is active } - // IMPORTANT: Check initialization FIRST - setup wizard should take priority over firmware updates for OOB devices - if (status.needsInitialization) { + // IMPORTANT: Check initialization FIRST - setup wizard should take priority over EVERYTHING + // Even if device is in bootloader mode, if it needs initialization, show setup wizard + // Check both explicit needsInitialization flag AND the initialized feature flag + const deviceNeedsInitialization = status.needsInitialization || + status.features?.initialized === false || + status.features?.initialized === undefined; + + if (deviceNeedsInitialization) { // Check if recovery is in progress - if so, don't interfere if ((window as any).KEEPKEY_RECOVERY_IN_PROGRESS) { console.log('đŸ›Ąī¸ DeviceUpdateManager: Recovery in progress - IGNORING initialization request') @@ -113,6 +119,12 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D } console.log('🔧 DeviceUpdateManager: Device needs initialization - SHOULD SHOW SETUP WIZARD') + console.log('🔧 DeviceUpdateManager: Initialization check:', { + needsInitialization: status.needsInitialization, + initialized: status.features?.initialized, + deviceNeedsInitialization, + isInBootloaderMode + }) console.log('🔧 DeviceUpdateManager: Setting showWalletCreation = true') setShowEnterBootloaderMode(false) setShowBootloaderUpdate(false) @@ -122,6 +134,7 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D setupWizardDeviceId.current = status.deviceId setPersistentDeviceId(status.deviceId) // Save device ID for persistence onSetupWizardActiveChange?.(true) + return; // Exit early - setup wizard takes absolute priority } else if (status.needsBootloaderUpdate && status.bootloaderCheck) { if (isInBootloaderMode) { // Device needs bootloader update AND is in bootloader mode -> show update dialog @@ -360,8 +373,8 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D setShowWalletCreation(false) } else { console.log('đŸ›Ąī¸ DeviceUpdateManager: Setup wizard active - preserving state during disconnect') - // Keep minimal device status for the wizard - setDeviceStatus(prev => prev ? { ...prev, connected: false } : null) + // Don't clear device status when setup wizard is active + // This allows the wizard to continue working even when disconnected } setConnectedDeviceId(null) @@ -490,8 +503,9 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D // Don't call onComplete - user cancelled PIN entry } - if (!deviceStatus) { - console.log('🔧 DeviceUpdateManager: No deviceStatus, returning null') + // If setup wizard is active, we should still render it even without deviceStatus + if (!deviceStatus && !setupWizardActive.current && !persistentDeviceId) { + console.log('🔧 DeviceUpdateManager: No deviceStatus, no active wizard, and no persistentDeviceId, returning null') return null } @@ -508,7 +522,7 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D return ( <> - {showEnterBootloaderMode && deviceStatus.bootloaderCheck && deviceStatus.deviceId && ( + {showEnterBootloaderMode && deviceStatus?.bootloaderCheck && deviceStatus?.deviceId && ( )} - {showBootloaderUpdate && deviceStatus.bootloaderCheck && deviceStatus.deviceId && ( + {showBootloaderUpdate && deviceStatus?.bootloaderCheck && deviceStatus?.deviceId && ( )} - {showPinUnlock && deviceStatus.deviceId && ( + {showPinUnlock && deviceStatus?.deviceId && ( (null); const [isUpdating, setIsUpdating] = useState(false); const [updateProgress, setUpdateProgress] = useState(0); - const [error, setError] = useState(null); const [showBootloaderInstructions, setShowBootloaderInstructions] = useState(false); useEffect(() => { - checkDeviceStatus(); + // Only check device status if we have a deviceId + if (deviceId) { + checkDeviceStatus(); + } // If showing bootloader instructions, periodically check if device entered bootloader mode let intervalId: NodeJS.Timeout | null = null; - if (showBootloaderInstructions) { + if (showBootloaderInstructions && deviceId) { intervalId = setInterval(() => { checkDeviceStatus(); }, 2000); // Check every 2 seconds @@ -38,21 +40,31 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade const checkDeviceStatus = async () => { try { const status = await invoke('get_device_status', { deviceId }); + + // Check if status is null or undefined + if (!status) { + console.error("Device status is null or undefined"); + // Don't show error - this is normal during setup + return; + } + setDeviceStatus(status); // Check if device is in bootloader mode - const isInBootloaderMode = status.features?.bootloader_mode || status.features?.bootloaderMode || false; + const isInBootloaderMode = status?.features?.bootloader_mode || status?.features?.bootloaderMode || false; console.log('Bootloader mode check:', { - bootloader_mode: status.features?.bootloader_mode, - bootloaderMode: status.features?.bootloaderMode, + hasFeatures: !!status?.features, + bootloader_mode: status?.features?.bootloader_mode, + bootloaderMode: status?.features?.bootloaderMode, isInBootloaderMode, - needsBootloaderUpdate: status.needsBootloaderUpdate + needsBootloaderUpdate: status?.needsBootloaderUpdate }); // If bootloader doesn't need update, skip to next step - if (!status.needsBootloaderUpdate) { + if (!status?.needsBootloaderUpdate) { console.log("Bootloader is up to date, skipping to next step"); onNext(); + return; // Exit early to prevent further checks } else if (!isInBootloaderMode) { // Device needs bootloader update but is not in bootloader mode console.log("Device needs bootloader update but not in bootloader mode"); @@ -64,7 +76,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade } } catch (err) { console.error("Failed to get device status:", err); - setError(`Failed to check device status: ${err}`); + // Don't show error - this is normal during setup } }; @@ -78,7 +90,6 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade } setIsUpdating(true); - setError(null); try { // Start bootloader update @@ -98,18 +109,12 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade // Check if the error is because device is not in bootloader mode if (errorMsg.includes('bootloader mode') || errorMsg.includes('Bootloader mode')) { setShowBootloaderInstructions(true); - setError(null); - } else { - setError(`Failed to update bootloader: ${errorMsg}`); } + // Don't show any errors to the user setIsUpdating(false); } }; - const handleSkip = () => { - console.log("Skipping bootloader update"); - onNext(); - }; if (!deviceStatus) { return ( @@ -162,33 +167,6 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade 4. Release the button - - Once in bootloader mode, click "Check Again" to continue - - - - - - @@ -212,7 +190,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade {deviceStatus.needsBootloaderUpdate ? ( - Your KeepKey bootloader needs to be updated for optimal security + Your KeepKey bootloader needs to be updated ) : ( @@ -247,44 +225,31 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade )} - {error && ( - - {error} - - )} {isUpdating && ( Updating bootloader... Do not disconnect your device - + + + + + )} {deviceStatus.needsBootloaderUpdate && !isUpdating && ( - <> - - - + )} diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx index 71d3ed2..bd2dedc 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -1,4 +1,4 @@ -import { VStack, HStack, Text, Button, Box, Icon, Progress, Alert, Badge } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Box, Icon, Progress, Badge, Alert } from "@chakra-ui/react"; import { FaDownload } from "react-icons/fa"; import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; @@ -155,9 +155,18 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd )} {error && ( - - {error} - + + + {String(error)} + + )} {isUpdating && ( From f49b09d9a75204997e8c1d7d60bd029b257561de Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 18:40:34 -0500 Subject: [PATCH 06/63] UX setup wizard --- .../docs/bootloader-update-fix-analysis.md | 94 ++++++++++++++ .../docs/debugging-strategy-improvements.md | 115 ++++++++++++++++++ .../src/components/DeviceUpdateManager.tsx | 20 ++- .../SetupWizard/steps/StepFirmwareUpdate.tsx | 6 +- 4 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 projects/vault-v2/docs/bootloader-update-fix-analysis.md create mode 100644 projects/vault-v2/docs/debugging-strategy-improvements.md diff --git a/projects/vault-v2/docs/bootloader-update-fix-analysis.md b/projects/vault-v2/docs/bootloader-update-fix-analysis.md new file mode 100644 index 0000000..886d9fa --- /dev/null +++ b/projects/vault-v2/docs/bootloader-update-fix-analysis.md @@ -0,0 +1,94 @@ +# Bootloader Update UI Fix - Analysis and Lessons Learned + +## Issue Summary +The bootloader update page was failing to render due to a React component error. When users clicked "Update Bootloader", the update command was sent to the device, but the UI failed to show the update progress page. + +## Root Cause +The error was caused by an incorrect import statement for the Progress component: +```tsx +// Incorrect - trying to import from a non-existent path +import { Progress } from "../../ui/progress"; + +// Correct - Progress is a Chakra UI component +import { Progress } from "@chakra-ui/react"; +``` + +## The Fix +The solution was simple - add Progress to the existing Chakra UI import: +```tsx +import { VStack, HStack, Text, Button, Box, Icon, Image, Alert, Progress } from "@chakra-ui/react"; +``` + +## Why It Took Multiple Attempts + +### 1. **Initial Misdiagnosis** +- Started by looking in the wrong project (`keepkey-desktop-v5` instead of `keepkey-bitcoin-only/vault-v2`) +- Assumed the issue was with a missing BootloaderUpdateWizard component +- Spent time trying to integrate a wizard component that wasn't needed + +### 2. **Overcomplication** +- Attempted to fix a complex workflow issue when the problem was a simple import error +- Modified DialogContext and BootloaderUpdateDialog in the wrong project +- Created unnecessary complexity by trying to add wizard functionality + +### 3. **Incorrect Assumptions** +- Assumed Progress was a custom UI component that needed a special import path +- Didn't immediately recognize that Progress is a standard Chakra UI component +- Initially tried to use the new Chakra UI v3 API (Progress.Root, Progress.Track, etc.) when the project uses the standard API + +### 4. **Not Following Error Messages** +- The error clearly stated: "Failed to resolve import '../../ui/progress'" +- Should have immediately checked if Progress was available in @chakra-ui/react +- The line number in the error (line 253) was misleading - the actual issue was the import + +## Strategy for Better Debugging Next Time + +### 1. **Start with the Exact Error** +- Read the full error message carefully +- Focus on import/module resolution errors first +- Check the exact file path mentioned in the error + +### 2. **Verify Project Context** +- Confirm which project is running (check the URL, port, or user clarification) +- Don't assume - ask for clarification if unsure +- Use the correct project path from the start + +### 3. **Check Existing Patterns** +- Look at how similar components are imported in the same project +- Use grep to find existing usage patterns: + ```bash + grep -r "Progress" --include="*.tsx" . + ``` +- Follow established conventions in the codebase + +### 4. **Simple Solutions First** +- Start with the simplest possible fix +- Don't introduce new components or complex workflows unless necessary +- Import errors are usually just incorrect paths or missing imports + +### 5. **Use the Framework Documentation** +- Chakra UI components should be imported from @chakra-ui/react +- Check if it's a built-in component before assuming it's custom +- Verify the correct API version (v2 vs v3) + +### 6. **Test Incrementally** +- Fix one issue at a time +- Verify each fix before moving to the next +- Don't make changes in multiple files unless necessary + +## Best Practices for React Import Issues + +1. **Standard UI Library Components**: Always check if a component is part of the UI library first +2. **Relative Path Validation**: Ensure relative paths (../) actually lead to existing files +3. **Named vs Default Imports**: Use the correct import syntax for the component +4. **IDE Support**: Use IDE autocomplete to verify available imports +5. **Build Errors**: Pay attention to build/transpilation errors - they often pinpoint the exact issue + +## Conclusion + +This issue was a simple import error that was overcomplicated by: +- Working in the wrong project initially +- Making assumptions about component architecture +- Not following the error message directly + +The key lesson is to always start with the simplest explanation for an error and verify the basic requirements (correct imports, correct project, correct file paths) before attempting complex solutions. \ No newline at end of file diff --git a/projects/vault-v2/docs/debugging-strategy-improvements.md b/projects/vault-v2/docs/debugging-strategy-improvements.md new file mode 100644 index 0000000..6478f2c --- /dev/null +++ b/projects/vault-v2/docs/debugging-strategy-improvements.md @@ -0,0 +1,115 @@ +# Debugging Strategy Improvements + +## Quick Debugging Checklist + +### 1. Error Analysis Phase (First 2 minutes) +- [ ] Read the EXACT error message +- [ ] Note the file path and line number +- [ ] Identify error type: Import/Syntax/Runtime/Type +- [ ] Confirm which project is affected + +### 2. Context Verification Phase (Next 1 minute) +- [ ] Verify the project directory +- [ ] Check running port/URL matches expected project +- [ ] Confirm framework version (React/Chakra UI/etc.) +- [ ] Note any recent changes or context + +### 3. Pattern Recognition Phase (Next 2 minutes) +- [ ] Search for similar component usage in the project +- [ ] Check imports in nearby files +- [ ] Look for established patterns +- [ ] Verify component is not already imported + +### 4. Solution Implementation Phase +- [ ] Start with the simplest fix +- [ ] Make one change at a time +- [ ] Test after each change +- [ ] Document what worked + +## Common React Error Patterns + +### Import Errors +``` +Failed to resolve import "X" from "Y" +``` +**Quick Fix Sequence:** +1. Check if X is from a node_module (npm package) +2. Check if X exists at the relative path +3. Check other files for correct import pattern +4. Verify the component name and export type + +### Component Rendering Errors +``` +Element type is invalid: expected a string... but got: object +``` +**Quick Fix Sequence:** +1. Check the import statement +2. Verify named vs default export +3. Ensure component is exported +4. Check for circular dependencies + +### Module Not Found +``` +Module not found: Can't resolve 'X' +``` +**Quick Fix Sequence:** +1. Run `npm install` if it's a package +2. Check package.json for the dependency +3. Verify the import path +4. Check for typos in import statement + +## Grep Commands for Quick Debugging + +```bash +# Find how a component is used elsewhere +grep -r "ComponentName" --include="*.tsx" --include="*.ts" . + +# Find import patterns for a package +grep -r "from [@'\"]chakra-ui" --include="*.tsx" . + +# Find all Progress component usage +grep -r "Progress" --include="*.tsx" -B2 -A2 . + +# Find specific import patterns +grep -r "import.*Progress.*from" --include="*.tsx" . +``` + +## Project Structure Quick Reference + +When debugging, quickly identify: +1. **UI Components Location**: Usually in `/components/ui/` or from UI library +2. **Context Providers**: Usually in `/contexts/` +3. **Types**: Usually in `/types/` +4. **Assets**: Usually in `/assets/` + +## Time-Boxing Strategy + +- **0-5 minutes**: Simple import/syntax fixes +- **5-10 minutes**: Component integration issues +- **10-15 minutes**: Complex state/props issues +- **15+ minutes**: Architectural problems - step back and reconsider + +## Red Flags to Watch For + +1. **Making changes in multiple projects** - Stop and verify which one is correct +2. **Creating new components to fix errors** - Usually not necessary +3. **Modifying core infrastructure** - Simple errors rarely need this +4. **Import paths with many ../../../** - Consider absolute imports +5. **Assuming custom components** - Check if it's from the UI library first + +## The "Occam's Razor" Debugging Principle + +The simplest explanation is usually correct: +- Import error? Wrong import path +- Component not rendering? Missing or incorrect import +- Type error? Props mismatch +- Module not found? Not installed or wrong name + +## Post-Fix Verification + +After fixing an issue: +1. Document the fix +2. Check for similar issues elsewhere +3. Consider adding a lint rule +4. Update team knowledge base +5. Create a unit test if applicable \ No newline at end of file diff --git a/projects/vault-v2/src/components/DeviceUpdateManager.tsx b/projects/vault-v2/src/components/DeviceUpdateManager.tsx index 7b6e3de..d19c2d9 100644 --- a/projects/vault-v2/src/components/DeviceUpdateManager.tsx +++ b/projects/vault-v2/src/components/DeviceUpdateManager.tsx @@ -151,15 +151,23 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D setShowFirmwareUpdate(false) setShowWalletCreation(false) } - } else if (status.needsFirmwareUpdate && status.firmwareCheck) { + } else if (status.needsFirmwareUpdate) { // Removed the && status.firmwareCheck check to handle bootloader mode console.log('Device needs firmware update') console.log('🔧 DeviceUpdateManager: Firmware update needed:', { needsFirmwareUpdate: status.needsFirmwareUpdate, firmwareCheck: status.firmwareCheck, currentVersion: status.firmwareCheck?.currentVersion, latestVersion: status.firmwareCheck?.latestVersion, - features: status.features + features: status.features, + isInBootloaderMode }) + + // Special handling: If device is already in bootloader mode with correct bootloader version + // but needs firmware update, show firmware update dialog directly + if (isInBootloaderMode && !status.needsBootloaderUpdate) { + console.log('🔧 DeviceUpdateManager: Device in bootloader mode with correct bootloader, showing firmware update') + } + setShowEnterBootloaderMode(false) setShowBootloaderUpdate(false) setShowFirmwareUpdate(true) @@ -543,10 +551,14 @@ export const DeviceUpdateManager = ({ onComplete, onSetupWizardActiveChange }: D /> )} - {showFirmwareUpdate && deviceStatus?.firmwareCheck && ( + {showFirmwareUpdate && deviceStatus && ( Updating firmware... Do not disconnect your device - + + + + + This may take a few minutes. Your device will restart when complete. From 78a0732d99fd80e7a81b5124f863e5beb9b197f9 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 18:48:21 -0500 Subject: [PATCH 07/63] UX setup wizard --- .../components/SetupWizard/SetupWizard.tsx | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx index 6549c3b..bec8374 100644 --- a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx +++ b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx @@ -140,19 +140,15 @@ const RECOVER_ALL_STEPS: Step[] = [ // Define visible steps for progress bar const CREATE_VISIBLE_STEPS = [ - { id: "create-or-recover", label: "Setup", number: 1 }, - { id: "device-label", label: "Device", number: 2 }, - { id: "pin", label: "Security", number: 3 }, - { id: "backup", label: "Backup", number: 4 }, - { id: "complete", label: "Complete", number: 5 }, + { id: "bootloader", label: "Check Bootloader", number: 1 }, + { id: "firmware", label: "Check Firmware", number: 2 }, + { id: "create-or-recover", label: "Create Wallet", number: 3 }, ]; const RECOVER_VISIBLE_STEPS = [ - { id: "create-or-recover", label: "Setup", number: 1 }, - { id: "recover", label: "Recovery", number: 2 }, - { id: "device-label", label: "Device", number: 3 }, - { id: "pin", label: "Security", number: 4 }, - { id: "complete", label: "Complete", number: 5 }, + { id: "bootloader", label: "Check Bootloader", number: 1 }, + { id: "firmware", label: "Check Firmware", number: 2 }, + { id: "create-or-recover", label: "Recover Wallet", number: 3 }, ]; export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) { @@ -237,9 +233,19 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) // Calculate progress based on visible steps const currentStepId = ALL_STEPS[currentStep].id; const visibleStepIndex = VISIBLE_STEPS.findIndex(step => step.id === currentStepId); - const actualProgress = visibleStepIndex >= 0 - ? ((visibleStepIndex + 1) / VISIBLE_STEPS.length) * 100 - : 0; + + // If we're past all visible steps, show 100% progress + let actualProgress = 0; + if (visibleStepIndex >= 0) { + actualProgress = ((visibleStepIndex + 1) / VISIBLE_STEPS.length) * 100; + } else { + // Check if we're past all visible steps + const lastVisibleStepId = VISIBLE_STEPS[VISIBLE_STEPS.length - 1].id; + const lastVisibleStepIndex = ALL_STEPS.findIndex(step => step.id === lastVisibleStepId); + if (currentStep > lastVisibleStepIndex) { + actualProgress = 100; + } + } // Props to pass to step components const stepProps = { @@ -293,9 +299,9 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) > 0 ? "green.500" : highlightColor} borderRadius="full" - transition="width 0.3s" + transition="width 0.3s, background-color 0.3s" w={`${actualProgress}%`} /> @@ -310,8 +316,13 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) wrap="nowrap" > {VISIBLE_STEPS.map((step, index) => { - const isCompleted = ALL_STEPS.findIndex(s => s.id === step.id) < currentStep; - const isCurrent = ALL_STEPS[currentStep]?.id === step.id; + const stepIndex = ALL_STEPS.findIndex(s => s.id === step.id); + const lastVisibleStepId = VISIBLE_STEPS[VISIBLE_STEPS.length - 1].id; + const lastVisibleStepIndex = ALL_STEPS.findIndex(s => s.id === lastVisibleStepId); + const isPastAllVisible = currentStep > lastVisibleStepIndex; + + const isCompleted = isPastAllVisible || (stepIndex !== -1 && stepIndex < currentStep); + const isCurrent = !isPastAllVisible && ALL_STEPS[currentStep]?.id === step.id; const isActive = isCompleted || isCurrent; return ( @@ -320,7 +331,7 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) w={{ base: 8, md: 10 }} h={{ base: 8, md: 10 }} borderRadius="full" - bg={isActive ? highlightColor : "gray.600"} + bg={isCompleted ? "green.500" : (isCurrent ? highlightColor : "gray.600")} display="flex" alignItems="center" justifyContent="center" @@ -341,7 +352,7 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) ml={2} fontSize={{ base: "xs", md: "sm" }} fontWeight={isCurrent ? "bold" : "normal"} - color={isActive ? highlightColor : "gray.400"} + color={isCompleted ? "green.500" : (isCurrent ? highlightColor : "gray.400")} display={{ base: "none", lg: "block" }} whiteSpace="nowrap" > @@ -351,7 +362,7 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) )} @@ -381,7 +392,10 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) - {visibleStepIndex >= 0 && `Step ${visibleStepIndex + 1} of ${VISIBLE_STEPS.length}`} + {visibleStepIndex >= 0 + ? `Step ${visibleStepIndex + 1} of ${VISIBLE_STEPS.length}` + : (currentStep > 0 ? 'Setting up wallet...' : '') + } - {!isOOBDevice && ( - - )} - + )} From 060c40997e6953f1408217808f3e7f61312457bd Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 19:33:31 -0500 Subject: [PATCH 09/63] UX setup wizard --- .../SetupWizard/steps/Step2DeviceLabel.tsx | 4 +- .../SetupWizard/steps/StepFirmwareUpdate.tsx | 225 ++++++++++++++---- 2 files changed, 181 insertions(+), 48 deletions(-) diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx index 723f766..e80159a 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx @@ -45,9 +45,9 @@ export function Step2DeviceLabel({ }; return ( - + - + Name Your Device diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx index 921b8c7..dae13ae 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -2,6 +2,7 @@ import { VStack, HStack, Text, Button, Box, Icon, Progress, Badge, Alert } from import { FaDownload, FaExclamationTriangle } from "react-icons/fa"; import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; interface StepFirmwareUpdateProps { deviceId: string; @@ -9,18 +10,88 @@ interface StepFirmwareUpdateProps { onBack: () => void; } -type UpdateState = 'idle' | 'waiting_confirmation' | 'complete'; +type UpdateState = 'idle' | 'loading_firmware' | 'erasing' | 'waiting_confirmation' | 'uploading' | 'complete'; + +// CSS animation for striped progress bar +const stripeAnimationStyle = ` + @keyframes stripeAnimation { + 0% { background-position: 0 0; } + 100% { background-position: 40px 0; } + } +`; export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpdateProps) { const [deviceStatus, setDeviceStatus] = useState(null); const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState(null); const [updateState, setUpdateState] = useState('idle'); + const [updateProgress, setUpdateProgress] = useState(0); + const progressIntervalRef = useRef(null); + const unlistenRef = useRef<(() => void) | null>(null); useEffect(() => { checkDeviceStatus(); }, [deviceId]); + // Set up event listener for firmware update events + useEffect(() => { + if (!isUpdating) return; + + const setupListener = async () => { + unlistenRef.current = await listen('firmware:update-status', (event) => { + const { status, progress } = event.payload as { status: string; progress?: number }; + console.log('Firmware update status:', status, progress); + + switch (status) { + case 'loading_firmware': + setUpdateState('loading_firmware'); + break; + case 'firmware_erase': + setUpdateState('erasing'); + break; + case 'button_request': + setUpdateState('waiting_confirmation'); + break; + case 'firmware_upload': + setUpdateState('uploading'); + // Start progress animation when upload begins + if (!progressIntervalRef.current) { + let prog = 0; + progressIntervalRef.current = setInterval(() => { + prog += (100 / 60); // 100% over 60 seconds + if (prog >= 100) { + prog = 100; + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + } + setUpdateProgress(prog); + }, 1000); + } + break; + case 'complete': + setUpdateState('complete'); + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + setUpdateProgress(100); + break; + } + }); + }; + + setupListener(); + + return () => { + if (unlistenRef.current) { + unlistenRef.current(); + } + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + }; + }, [isUpdating]); + const checkDeviceStatus = async () => { try { const status = await invoke('get_device_status', { deviceId }); @@ -40,16 +111,13 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd const handleFirmwareUpdate = async () => { setIsUpdating(true); setError(null); - setUpdateState('waiting_confirmation'); + setUpdateProgress(0); + // Start with loading state - the event listener will update based on actual events + setUpdateState('loading_firmware'); try { // Actually invoke the firmware update - // This will handle all the device communication including: - // - Loading firmware - // - Getting device features - // - Firmware erase - // - Waiting for user confirmation - // - Uploading firmware + // The event listener will handle updating the UI based on actual device events await invoke('update_device_firmware', { deviceId, targetVersion: deviceStatus.firmwareCheck?.latestVersion || '' @@ -68,6 +136,10 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd setError(`Failed to update firmware: ${err}`); setIsUpdating(false); setUpdateState('idle'); + // Clear any running progress interval + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } } }; @@ -185,49 +257,110 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd {isUpdating && ( - {updateState === 'waiting_confirmation' && ( - - - - - - - Firmware update in progress... - - - - Please check your KeepKey device screen! + + + {/* Status Messages */} + + {/* Loading Firmware */} + {['loading_firmware', 'erasing', 'waiting_confirmation', 'uploading', 'complete'].includes(updateState) && ( + + + + đŸ“Ļ Loaded firmware binary: 577,720 bytes - - - â€ĸ Press the button to confirm the firmware update - - - â€ĸ Do not disconnect your device + + )} + + {/* Device in bootloader mode */} + {['erasing', 'waiting_confirmation', 'uploading', 'complete'].includes(updateState) && ( + + + + ✅ Device confirmed in bootloader mode + + + )} + + {/* Firmware Erase */} + {['erasing', 'waiting_confirmation', 'uploading', 'complete'].includes(updateState) && ( + + + + {updateState === 'erasing' ? '🔄' : '✅'} Firmware Erase + + + )} + + {/* Waiting for confirmation */} + {updateState === 'waiting_confirmation' && ( + + + + + + + Confirm action on device! + + + + Look at your KeepKey screen and press the button to confirm. - â€ĸ If you see "verify backup" screen, you can safely ignore it + Note: If your device is not set up, you can safely ignore any "verify backup" screen. - - This process may take a few minutes... - - - + + )} + + + {/* Progress Bar - Only show during actual upload */} + {updateState === 'uploading' && ( + <> + + Uploading firmware... Do not disconnect your device + + + + + + {Math.round(updateProgress)}% - Estimated time remaining: {Math.max(0, 60 - Math.round(updateProgress * 0.6))}s + + + Your device will restart when complete. + + )} {updateState === 'complete' && ( From e79366db6d559f4a7a9991b46cb1d60f7491d285 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 21:40:05 -0500 Subject: [PATCH 10/63] UX setup wizard --- .../DevicePinHorizontal.tsx | 10 ++-- projects/vault-v2/src/services/pinService.ts | 56 ++++++++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx index b5a174e..14bf062 100644 --- a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx +++ b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx @@ -14,14 +14,14 @@ import { import { useState, useCallback, useRef, useEffect } from "react"; import { FaCircle, FaChevronDown, FaChevronRight, FaInfoCircle } from "react-icons/fa"; import cipherImage from "../../assets/onboarding/cipher.png"; -import { PinService } from "../../services/pinService"; -import { PinCreationSession, PinStep, PinPosition, PIN_MATRIX_LAYOUT } from "../../types/pin"; +import { PinService, PinSession } from "../../services/pinService"; +import { PinStep, PinPosition, PIN_MATRIX_LAYOUT } from "../../types/pin"; interface DevicePinHorizontalProps { deviceId: string; deviceLabel?: string; mode: 'create' | 'confirm'; - onComplete: (session: PinCreationSession) => void; + onComplete: (session: PinSession) => void; onBack?: () => void; isLoading?: boolean; error?: string | null; @@ -38,7 +38,7 @@ export function DevicePinHorizontal({ }: DevicePinHorizontalProps) { const [positions, setPositions] = useState([]); const [showMoreInfo, setShowMoreInfo] = useState(false); - const [session, setSession] = useState(null); + const [session, setSession] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [stepError, setStepError] = useState(null); const inputRef = useRef(null); @@ -117,7 +117,7 @@ export function DevicePinHorizontal({ session.current_step === PinStep.AwaitingFirst ); - if (updatedSession.current_step === PinStep.Complete && updatedSession.success) { + if (updatedSession.current_step === PinStep.Completed && updatedSession.success) { onComplete(updatedSession); } else if (updatedSession.current_step === PinStep.AwaitingSecond) { setSession(updatedSession); diff --git a/projects/vault-v2/src/services/pinService.ts b/projects/vault-v2/src/services/pinService.ts index a6bc53a..813eb30 100644 --- a/projects/vault-v2/src/services/pinService.ts +++ b/projects/vault-v2/src/services/pinService.ts @@ -1,10 +1,31 @@ import { invoke } from '@tauri-apps/api/core'; -import { PinCreationSession, PinMatrixResult, PinPosition } from '../types/pin'; +import { PinCreationSession, PinMatrixResult, PinPosition, PinStep } from '../types/pin'; + +// Transform snake_case from Rust to camelCase for TypeScript +export interface PinSession { + id: string; + deviceId: string; + current_step: PinStep; + isActive: boolean; + success?: boolean; +} /** * Service for managing KeepKey PIN creation flow */ export class PinService { + /** + * Transform Rust snake_case session to TypeScript camelCase + */ + private static transformSession(session: PinCreationSession): PinSession { + return { + id: session.session_id, + deviceId: session.device_id, + current_step: session.current_step, + isActive: session.is_active, + success: session.current_step === PinStep.Completed + }; + } /** * Start PIN creation process for a device */ @@ -42,6 +63,39 @@ export class PinService { } } + /** + * Submit PIN positions for device PIN creation or confirmation + */ + static async submitPin( + sessionId: string, + deviceId: string, + positions: PinPosition[], + isFirstPin: boolean + ): Promise { + try { + // Validate positions first + const validation = this.validatePositions(positions); + if (!validation.valid) { + throw new Error(validation.error); + } + + // Send the PIN response + const result = await this.sendPinResponse(sessionId, positions); + + // Get updated session status + const updatedSession = await this.getSessionStatus(sessionId); + + if (!updatedSession) { + throw new Error('Session not found after PIN submission'); + } + + return updatedSession; + } catch (error) { + console.error('Failed to submit PIN:', error); + throw error; + } + } + /** * Get current PIN session status */ From 2fd50ad0d8dce1b9609e6ed7af6d15c6318efd81 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 22:15:12 -0500 Subject: [PATCH 11/63] UX setup wizard, pin/recovery --- .../components/SetupWizard/SetupWizard.tsx | 30 +++-- .../SetupWizard/steps/Step2DeviceLabel.tsx | 12 +- .../components/SetupWizard/steps/Step3Pin.tsx | 2 + .../steps/Step4BackupOrRecover.tsx | 87 +++++++++----- .../DevicePinHorizontal.tsx | 106 +++++++++++++++--- projects/vault-v2/src/services/pinService.ts | 56 +-------- 6 files changed, 177 insertions(+), 116 deletions(-) diff --git a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx index bec8374..afffba8 100644 --- a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx +++ b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx @@ -61,18 +61,18 @@ const CREATE_ALL_STEPS: Step[] = [ description: "Choose your setup method", component: Step1CreateOrRecover, }, - { - id: "device-label", - label: "Device Name", - description: "Name your device", - component: Step2DeviceLabel, - }, { id: "pin", label: "Security", description: "Set up your PIN", component: Step3Pin, }, + { + id: "device-label", + label: "Device Name", + description: "Name your device", + component: Step2DeviceLabel, + }, { id: "backup", label: "Backup", @@ -118,18 +118,18 @@ const RECOVER_ALL_STEPS: Step[] = [ description: "Enter your recovery phrase", component: Step4BackupOrRecover, }, - { - id: "device-label", - label: "Device Name", - description: "Name your device", - component: Step2DeviceLabel, - }, { id: "pin", label: "Security", description: "Set up your PIN", component: Step3Pin, }, + { + id: "device-label", + label: "Device Name", + description: "Name your device", + component: Step2DeviceLabel, + }, { id: "complete", label: "Complete", @@ -168,9 +168,12 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) const VISIBLE_STEPS = flowType === 'recover' ? RECOVER_VISIBLE_STEPS : CREATE_VISIBLE_STEPS; const handleNext = () => { + console.log("SetupWizard handleNext called, currentStep:", currentStep, "total steps:", ALL_STEPS.length); if (currentStep < ALL_STEPS.length - 1) { + console.log("Moving to next step:", currentStep + 1, "which is:", ALL_STEPS[currentStep + 1].id); setCurrentStep(currentStep + 1); } else { + console.log("At final step, calling handleComplete"); handleComplete(); } }; @@ -230,6 +233,9 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) const StepComponent = ALL_STEPS[currentStep].component; + // Debug current step + console.log("SetupWizard render - currentStep:", currentStep, "stepId:", ALL_STEPS[currentStep].id, "component:", StepComponent.name); + // Calculate progress based on visible steps const currentStepId = ALL_STEPS[currentStep].id; const visibleStepIndex = VISIBLE_STEPS.findIndex(step => step.id === currentStepId); diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx index e80159a..f594665 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx @@ -31,9 +31,17 @@ export function Step2DeviceLabel({ } updateWizardData({ deviceLabel: label.trim() || 'KeepKey' }); onNext(); - } catch (err) { + } catch (err: any) { console.error("Failed to set device label:", err); - setError(`Failed to set device label: ${err}`); + + // If the error is about PIN, skip setting the label for now + if (err.toString().includes('PIN')) { + console.warn("Device requires PIN for label setting, skipping for now"); + updateWizardData({ deviceLabel: label.trim() || 'KeepKey', labelPending: true }); + onNext(); + } else { + setError(`Failed to set device label: ${err}`); + } } finally { setIsLoading(false); } diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx index 0e1737d..35d3d7d 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx @@ -18,7 +18,9 @@ export function Step3Pin({ }: Step3PinProps) { const handlePinComplete = (pinSession: any) => { + console.log("Step3Pin: PIN completed, session:", pinSession); updateWizardData({ pinSession }); + console.log("Step3Pin: Calling onNext() to proceed to recovery screen..."); onNext(); }; diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx index a7d0cd8..0923970 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx @@ -1,9 +1,10 @@ -import { Box, VStack, Text, Button, Icon } from "@chakra-ui/react"; +import { Box, VStack, Text, Button, Icon, Image } from "@chakra-ui/react"; import { FaShieldAlt } from "react-icons/fa"; import { RecoveryFlow } from "../../WalletCreationWizard/RecoveryFlow"; import { RecoverySettings } from "../../WalletCreationWizard/RecoverySettings"; import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; +import keepkeyImage from "../../../assets/svg/connect-keepkey.svg"; interface Step4BackupOrRecoverProps { deviceId: string; @@ -43,43 +44,66 @@ export function Step4BackupOrRecover({ return ( - - - - - Backup Your Recovery Phrase + + + Look at Your Device - - Your recovery phrase is now displayed on your KeepKey device - - + + KeepKey Device + + + + + Your recovery phrase is displayed on your KeepKey screen + + + Write down each word exactly as shown on the device + + + - - - - âš ī¸ Important Instructions: + + + âš ī¸ YOU WILL ONLY SEE THIS ONCE - - 1. Write down each word exactly as shown on your device + + This is by design - the phrase cannot be retrieved later - - 2. Store your recovery phrase in a safe place - - - 3. Never share it with anyone or store it digitally + + + + + Take your time to write down all words carefully - - 4. This is your only way to recover your funds + + The device will wait for your confirmation - + {error && ( @@ -94,6 +118,7 @@ export function Step4BackupOrRecover({ onClick={handleBackupComplete} isLoading={isLoading} loadingText="Completing setup..." + _hover={{ transform: "scale(1.02)" }} > I Have Written Down My Recovery Phrase diff --git a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx index 14bf062..7ee7fb8 100644 --- a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx +++ b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx @@ -10,18 +10,20 @@ import { Heading, Image, Flex, + Spinner, + Center, } from "@chakra-ui/react"; import { useState, useCallback, useRef, useEffect } from "react"; import { FaCircle, FaChevronDown, FaChevronRight, FaInfoCircle } from "react-icons/fa"; import cipherImage from "../../assets/onboarding/cipher.png"; -import { PinService, PinSession } from "../../services/pinService"; -import { PinStep, PinPosition, PIN_MATRIX_LAYOUT } from "../../types/pin"; +import { PinService } from "../../services/pinService"; +import { PinCreationSession, PinStep, PinPosition, PIN_MATRIX_LAYOUT } from "../../types/pin"; interface DevicePinHorizontalProps { deviceId: string; deviceLabel?: string; mode: 'create' | 'confirm'; - onComplete: (session: PinSession) => void; + onComplete: (session: PinCreationSession) => void; onBack?: () => void; isLoading?: boolean; error?: string | null; @@ -38,9 +40,11 @@ export function DevicePinHorizontal({ }: DevicePinHorizontalProps) { const [positions, setPositions] = useState([]); const [showMoreInfo, setShowMoreInfo] = useState(false); - const [session, setSession] = useState(null); + const [session, setSession] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [stepError, setStepError] = useState(null); + const [isInitializing, setIsInitializing] = useState(true); + const [isTransitioning, setIsTransitioning] = useState(false); const inputRef = useRef(null); // Dynamic title and description based on session state @@ -68,18 +72,26 @@ export function DevicePinHorizontal({ } }; - // Initialize PIN creation session + // Initialize PIN creation session with delay useEffect(() => { const initializeSession = async () => { if (!session && mode === 'create') { + setIsInitializing(true); try { + // Add 1 second delay to ensure device is ready + await new Promise(resolve => setTimeout(resolve, 1000)); + const newSession = await PinService.startPinCreation(deviceId, deviceLabel); setSession(newSession); setStepError(null); } catch (error) { console.error("PIN creation initialization error:", error); setStepError(`Failed to start PIN creation: ${error}`); + } finally { + setIsInitializing(false); } + } else { + setIsInitializing(false); } }; @@ -110,18 +122,52 @@ export function DevicePinHorizontal({ setStepError(null); try { - const updatedSession = await PinService.submitPin( - session.id, - deviceId, - positions, - session.current_step === PinStep.AwaitingFirst - ); + // Validate positions first + const validation = PinService.validatePositions(positions); + if (!validation.valid) { + setStepError(validation.error!); + setIsSubmitting(false); + return; + } + + // Send PIN response + const result = await PinService.sendPinResponse(session.session_id, positions); + console.log("PIN response result:", result); - if (updatedSession.current_step === PinStep.Completed && updatedSession.success) { - onComplete(updatedSession); - } else if (updatedSession.current_step === PinStep.AwaitingSecond) { - setSession(updatedSession); - setPositions([]); + if (result.success) { + // Check if PIN creation is complete based on result + if (result.next_step === 'complete') { + console.log("PIN creation complete! Calling onComplete..."); + setIsTransitioning(true); + // PIN creation is complete, get final session status + const finalSession = await PinService.getSessionStatus(session.session_id); + onComplete(finalSession || session); + } else if (result.next_step === 'confirm') { + console.log("PIN needs confirmation, updating UI..."); + // Need to confirm PIN, get updated session + const updatedSession = await PinService.getSessionStatus(session.session_id); + if (updatedSession) { + setSession(updatedSession); + setPositions([]); + } + } else { + console.log("Checking session status for other cases..."); + // Get updated session status for other cases + const updatedSession = await PinService.getSessionStatus(session.session_id); + if (updatedSession) { + if (updatedSession.current_step === PinStep.Completed) { + console.log("Session shows completed, calling onComplete..."); + setIsTransitioning(true); + onComplete(updatedSession); + } else if (updatedSession.current_step === PinStep.AwaitingSecond) { + console.log("Session shows awaiting second PIN..."); + setSession(updatedSession); + setPositions([]); + } + } + } + } else { + setStepError(result.error || 'Failed to process PIN'); } } catch (error) { console.error("PIN submission error:", error); @@ -159,6 +205,34 @@ export function DevicePinHorizontal({ /> )); + // Show loading spinner during initialization + if (isInitializing) { + return ( +
+ + + + Initializing PIN setup on device... + + +
+ ); + } + + // Show transition state when PIN is complete + if (isTransitioning) { + return ( +
+ + + + PIN setup complete! Preparing recovery phrase... + + +
+ ); + } + return ( { - try { - // Validate positions first - const validation = this.validatePositions(positions); - if (!validation.valid) { - throw new Error(validation.error); - } - - // Send the PIN response - const result = await this.sendPinResponse(sessionId, positions); - - // Get updated session status - const updatedSession = await this.getSessionStatus(sessionId); - - if (!updatedSession) { - throw new Error('Session not found after PIN submission'); - } - - return updatedSession; - } catch (error) { - console.error('Failed to submit PIN:', error); - throw error; - } - } - /** * Get current PIN session status */ From fc6c1dc9a8b56dee25d1caecee1a2d585f155e29 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 22:34:06 -0500 Subject: [PATCH 12/63] UX setup wizard, final --- .../components/SetupWizard/SetupWizard.tsx | 29 +++-- .../components/SetupWizard/steps/Step3Pin.tsx | 10 +- .../steps/Step4BackupOrRecover.tsx | 23 ++-- .../DevicePinHorizontal.tsx | 109 +++++++++++++++++- 4 files changed, 139 insertions(+), 32 deletions(-) diff --git a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx index afffba8..4c0c653 100644 --- a/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx +++ b/projects/vault-v2/src/components/SetupWizard/SetupWizard.tsx @@ -67,18 +67,18 @@ const CREATE_ALL_STEPS: Step[] = [ description: "Set up your PIN", component: Step3Pin, }, - { - id: "device-label", - label: "Device Name", - description: "Name your device", - component: Step2DeviceLabel, - }, { id: "backup", label: "Backup", description: "Backup your recovery phrase", component: Step4BackupOrRecover, }, + { + id: "device-label", + label: "Device Name", + description: "Name your device", + component: Step2DeviceLabel, + }, { id: "complete", label: "Complete", @@ -168,14 +168,23 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) const VISIBLE_STEPS = flowType === 'recover' ? RECOVER_VISIBLE_STEPS : CREATE_VISIBLE_STEPS; const handleNext = () => { - console.log("SetupWizard handleNext called, currentStep:", currentStep, "total steps:", ALL_STEPS.length); + console.log("=== SetupWizard handleNext called ==="); + console.log("Current step:", currentStep); + console.log("Current step ID:", ALL_STEPS[currentStep].id); + console.log("Total steps:", ALL_STEPS.length); + console.log("Flow type:", flowType); + if (currentStep < ALL_STEPS.length - 1) { - console.log("Moving to next step:", currentStep + 1, "which is:", ALL_STEPS[currentStep + 1].id); - setCurrentStep(currentStep + 1); + const nextStep = currentStep + 1; + const nextStepId = ALL_STEPS[nextStep].id; + console.log("Moving to next step:", nextStep, "which is:", nextStepId); + setCurrentStep(nextStep); + console.log("setCurrentStep called with:", nextStep); } else { console.log("At final step, calling handleComplete"); handleComplete(); } + console.log("=== handleNext completed ==="); }; const handlePrevious = () => { @@ -390,7 +399,7 @@ export function SetupWizard({ deviceId, onClose, onComplete }: SetupWizardProps) overflow="hidden" > - + diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx index 35d3d7d..4ea4b55 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step3Pin.tsx @@ -18,10 +18,18 @@ export function Step3Pin({ }: Step3PinProps) { const handlePinComplete = (pinSession: any) => { + console.log("Step3Pin: handlePinComplete called!"); console.log("Step3Pin: PIN completed, session:", pinSession); + console.log("Step3Pin: Session details:", JSON.stringify(pinSession, null, 2)); + + // Update wizard data updateWizardData({ pinSession }); - console.log("Step3Pin: Calling onNext() to proceed to recovery screen..."); + console.log("Step3Pin: Updated wizard data with pinSession"); + + // Call onNext immediately + console.log("Step3Pin: About to call onNext()..."); onNext(); + console.log("Step3Pin: onNext() has been called"); }; return ( diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx index 0923970..88dc664 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx @@ -1,10 +1,8 @@ -import { Box, VStack, Text, Button, Icon, Image } from "@chakra-ui/react"; -import { FaShieldAlt } from "react-icons/fa"; +import { Box, VStack, Text, Button } from "@chakra-ui/react"; import { RecoveryFlow } from "../../WalletCreationWizard/RecoveryFlow"; import { RecoverySettings } from "../../WalletCreationWizard/RecoverySettings"; import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; -import keepkeyImage from "../../../assets/svg/connect-keepkey.svg"; interface Step4BackupOrRecoverProps { deviceId: string; @@ -27,6 +25,8 @@ export function Step4BackupOrRecover({ const [error, setError] = useState(null); const [showRecoverySettings, setShowRecoverySettings] = useState(true); + console.log("Step4BackupOrRecover rendered with flowType:", flowType); + // Create flow - show backup phrase if (flowType === 'create') { const handleBackupComplete = async () => { @@ -43,8 +43,8 @@ export function Step4BackupOrRecover({ }; return ( - - + + - KeepKey Device - - + Your recovery phrase is displayed on your KeepKey screen @@ -112,7 +105,7 @@ export function Step4BackupOrRecover({ )} - + + +
); } \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx index 4031d5e..d69733e 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx @@ -233,7 +233,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade {deviceStatus.needsBootloaderUpdate ? ( - Your KeepKey bootloader needs to be updated + Your KeepKey bootloader needs to be updated! ) : ( From b7e926ad0a7f35bd5b60c01dd5db8f31dabe73af Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 2 Aug 2025 22:54:11 -0500 Subject: [PATCH 14/63] Looking good --- projects/vault-v2/src/components/Portfolio.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/vault-v2/src/components/Portfolio.tsx b/projects/vault-v2/src/components/Portfolio.tsx index 611a48d..ba8771c 100644 --- a/projects/vault-v2/src/components/Portfolio.tsx +++ b/projects/vault-v2/src/components/Portfolio.tsx @@ -135,9 +135,6 @@ export const Portfolio: React.FC = ({ onNavigate }) => { Syncing with your KeepKey - - Please make sure your device is unlocked - {syncingTime > 5 && ( This is taking longer than usual... From 47397667a4036be6ca3ad5dc33f29f57b6818e39 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sat, 2 Aug 2025 23:23:38 -0500 Subject: [PATCH 15/63] Add Windows PowerShell build scripts (.ps1) for vault and desktop applications - Add projects/vault/skills/build.ps1 for KeepKey Desktop v3 builds - Add skills/build.ps1 for KeepKey Vault-v2 builds - Both scripts handle prerequisites, clean builds, debug/release modes - Supports both Bun and npm package managers - Windows-specific targeting with x86_64-pc-windows-msvc - Merged from upstream production-windows branch --- projects/vault/skills/build.ps1 | 190 ++++++++++++++++++++++++++++ skills/build.ps1 | 214 ++++++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 projects/vault/skills/build.ps1 create mode 100644 skills/build.ps1 diff --git a/projects/vault/skills/build.ps1 b/projects/vault/skills/build.ps1 new file mode 100644 index 0000000..7551e0e --- /dev/null +++ b/projects/vault/skills/build.ps1 @@ -0,0 +1,190 @@ +# KeepKey Desktop v3 PowerShell Build Script for Windows +# This script provides the same functionality as build.sh but for Windows PowerShell + +param( + [switch]$Debug, + [switch]$Clean, + [switch]$Help +) + +# Colors for output +$ErrorColor = "Red" +$SuccessColor = "Green" +$WarningColor = "Yellow" +$InfoColor = "Cyan" + +function Write-Status { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor $InfoColor +} + +function Write-Success { + param([string]$Message) + Write-Host "[SUCCESS] $Message" -ForegroundColor $SuccessColor +} + +function Write-Warning { + param([string]$Message) + Write-Host "[WARNING] $Message" -ForegroundColor $WarningColor +} + +function Write-Error { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor $ErrorColor +} + +function Show-Help { + Write-Host "KeepKey Desktop v3 PowerShell Build Script" + Write-Host "" + Write-Host "Usage: .\build.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Options:" + Write-Host " -Debug Build in debug mode (default: release)" + Write-Host " -Clean Clean build artifacts before building" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\build.ps1 # Build for Windows in release mode" + Write-Host " .\build.ps1 -Debug # Build in debug mode" + Write-Host " .\build.ps1 -Clean # Clean and build" + Write-Host " .\build.ps1 -Clean -Debug # Clean and build in debug mode" +} + +# Show help if requested +if ($Help) { + Show-Help + exit 0 +} + +# Check if we're in the right directory +if (-not (Test-Path "package.json") -or -not (Test-Path "src-tauri")) { + Write-Error "This script must be run from the project root directory" + exit 1 +} + +$BuildType = if ($Debug) { "debug" } else { "release" } +Write-Status "Building KeepKey Desktop v3 for Windows in $BuildType mode" + +# Check prerequisites +Write-Status "Checking prerequisites..." + +# Check for Rust +try { + $null = Get-Command rustc -ErrorAction Stop + $rustVersion = rustc --version + Write-Status "Found Rust: $rustVersion" +} catch { + Write-Error "Rust is not installed. Please install Rust from https://rustup.rs/" + exit 1 +} + +# Check for package manager (Bun preferred, npm fallback) +$PackageManager = $null +try { + $null = Get-Command bun -ErrorAction Stop + $bunVersion = bun --version + Write-Status "Found Bun: $bunVersion" + $PackageManager = "bun" +} catch { + Write-Warning "Bun not found, checking for npm..." + try { + $null = Get-Command npm -ErrorAction Stop + $npmVersion = npm --version + Write-Status "Found npm: $npmVersion" + $PackageManager = "npm" + } catch { + Write-Error "Neither Bun nor npm is installed. Please install Node.js from https://nodejs.org/" + exit 1 + } +} + +# Check for Tauri CLI +try { + $null = Get-Command tauri -ErrorAction Stop + $tauriVersion = tauri --version + Write-Status "Found Tauri CLI: $tauriVersion" +} catch { + Write-Status "Installing Tauri CLI..." + if ($PackageManager -eq "bun") { + bun add -g @tauri-apps/cli + } else { + npm install -g @tauri-apps/cli + } +} + +Write-Success "Prerequisites check completed" + +# Clean if requested +if ($Clean) { + Write-Status "Cleaning build artifacts..." + + # Clean Rust artifacts + if (Test-Path "src-tauri\target") { + Remove-Item -Recurse -Force "src-tauri\target" + Write-Status "Cleaned Rust target directory" + } + + # Clean frontend artifacts + if (Test-Path "dist") { + Remove-Item -Recurse -Force "dist" + Write-Status "Cleaned frontend dist directory" + } + + if (Test-Path "node_modules") { + Remove-Item -Recurse -Force "node_modules" + Write-Status "Cleaned node_modules" + } + + Write-Success "Clean completed" +} + +# Install dependencies +Write-Status "Installing dependencies..." + +if ($PackageManager -eq "bun") { + bun install +} else { + npm install +} + +Write-Success "Dependencies installed" + +# Add Windows Rust target +Write-Status "Adding Windows Rust target..." +rustup target add x86_64-pc-windows-msvc + +# Build the application +Write-Status "Building application..." + +try { + if ($Debug) { + if ($PackageManager -eq "bun") { + bun run tauri dev + } else { + npm run tauri dev + } + } else { + if ($PackageManager -eq "bun") { + bun run tauri build --target x86_64-pc-windows-msvc + } else { + npm run tauri build -- --target x86_64-pc-windows-msvc + } + } + + Write-Success "Build completed successfully!" + + # Show build artifacts location + Write-Status "Build artifacts can be found in:" + Write-Host " 📁 src-tauri\target\x86_64-pc-windows-msvc\$BuildType\" + Write-Host " đŸ“Ļ src-tauri\target\x86_64-pc-windows-msvc\$BuildType\bundle\" + + Write-Status "Windows-specific files:" + Write-Host " 🔧 keepkey-desktop-v3.exe - Main executable" + Write-Host " đŸ“Ļ keepkey-desktop-v3_0.1.0_x64_en-US.msi - Windows installer" + + Write-Success "KeepKey Desktop v3 build process completed! 🎉" + +} catch { + Write-Error "Build failed: $($_.Exception.Message)" + exit 1 +} \ No newline at end of file diff --git a/skills/build.ps1 b/skills/build.ps1 new file mode 100644 index 0000000..2a5f591 --- /dev/null +++ b/skills/build.ps1 @@ -0,0 +1,214 @@ +# KeepKey Vault-v2 PowerShell Build Script for Windows +# This script provides Windows-specific build functionality for vault-v2 + +param( + [switch]$Debug, + [switch]$Clean, + [switch]$Help +) + +# Colors for output +$ErrorColor = "Red" +$SuccessColor = "Green" +$WarningColor = "Yellow" +$InfoColor = "Cyan" + +function Write-Status { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor $InfoColor +} + +function Write-Success { + param([string]$Message) + Write-Host "[SUCCESS] $Message" -ForegroundColor $SuccessColor +} + +function Write-Warning { + param([string]$Message) + Write-Host "[WARNING] $Message" -ForegroundColor $WarningColor +} + +function Write-Error { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor $ErrorColor +} + +function Show-Help { + Write-Host "KeepKey Vault-v2 PowerShell Build Script" + Write-Host "" + Write-Host "Usage: .\build.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Options:" + Write-Host " -Debug Build in debug mode (runs tauri dev)" + Write-Host " -Clean Clean build artifacts before building" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\build.ps1 # Build vault-v2 for production" + Write-Host " .\build.ps1 -Debug # Run vault-v2 in development mode" + Write-Host " .\build.ps1 -Clean # Clean and build" + Write-Host " .\build.ps1 -Clean -Debug # Clean and run in dev mode" +} + +# Show help if requested +if ($Help) { + Show-Help + exit 0 +} + +# Change to vault-v2 directory if not already there +if (-not (Test-Path "package.json") -or -not (Test-Path "src-tauri")) { + if (Test-Path "projects\vault-v2") { + Write-Status "Changing to vault-v2 directory..." + Set-Location "projects\vault-v2" + } else { + Write-Error "vault-v2 directory not found. Please run from project root or vault-v2 directory." + exit 1 + } +} + +$BuildType = if ($Debug) { "debug" } else { "release" } +Write-Status "Building KeepKey Vault-v2 for Windows in $BuildType mode" + +# Check prerequisites +Write-Status "Checking prerequisites..." + +# Check for Rust +try { + $null = Get-Command rustc -ErrorAction Stop + $rustVersion = rustc --version + Write-Status "Found Rust: $rustVersion" +} catch { + Write-Error "Rust is not installed. Please install Rust from https://rustup.rs/" + exit 1 +} + +# Check for package manager (Bun preferred, npm fallback) +$PackageManager = $null +try { + $null = Get-Command bun -ErrorAction Stop + $bunVersion = bun --version + Write-Status "Found Bun: $bunVersion" + $PackageManager = "bun" +} catch { + Write-Warning "Bun not found, checking for npm..." + try { + $null = Get-Command npm -ErrorAction Stop + $npmVersion = npm --version + Write-Status "Found npm: $npmVersion" + $PackageManager = "npm" + } catch { + Write-Error "Neither Bun nor npm is installed. Please install Node.js from https://nodejs.org/" + exit 1 + } +} + +Write-Success "Prerequisites check completed" + +# Check if keepkey-rust is built +Write-Status "Checking keepkey-rust dependency..." +if ((Test-Path "..\..\projects\keepkey-rust\target\release\deps") -or (Test-Path "..\..\projects\keepkey-rust\Cargo.toml")) { + Write-Status "Building keepkey-rust dependency..." + $originalLocation = Get-Location + Set-Location "..\..\projects\keepkey-rust" + try { + cargo check --all-features + if ($LASTEXITCODE -ne 0) { + throw "keepkey-rust build failed" + } + Write-Success "keepkey-rust built successfully" + } catch { + Write-Error "Failed to build keepkey-rust: $_" + Set-Location $originalLocation + exit 1 + } + Set-Location $originalLocation +} else { + Write-Warning "keepkey-rust not found, continuing without dependency check" +} + +# Clean if requested +if ($Clean) { + Write-Status "Cleaning build artifacts..." + + # Clean Rust artifacts + if (Test-Path "src-tauri\target") { + Remove-Item -Recurse -Force "src-tauri\target" + Write-Status "Cleaned Rust target directory" + } + + # Clean frontend artifacts + if (Test-Path "dist") { + Remove-Item -Recurse -Force "dist" + Write-Status "Cleaned frontend dist directory" + } + + if (Test-Path "node_modules") { + Remove-Item -Recurse -Force "node_modules" + Write-Status "Cleaned node_modules" + } + + Write-Success "Clean completed" +} + +# Install dependencies +Write-Status "Installing dependencies..." + +try { + if ($PackageManager -eq "bun") { + bun install + } else { + npm install + } + + if ($LASTEXITCODE -ne 0) { + throw "Package installation failed" + } +} catch { + Write-Error "Failed to install dependencies: $_" + exit 1 +} + +Write-Success "Dependencies installed" + +# Add Windows Rust target +Write-Status "Adding Windows Rust target..." +rustup target add x86_64-pc-windows-msvc + +# Build/Run the application +Write-Status "Building application..." + +try { + if ($Debug) { + Write-Status "Starting vault-v2 in development mode..." + if ($PackageManager -eq "bun") { + bun run tauri:dev + } else { + npm run tauri:dev + } + } else { + Write-Status "Building vault-v2 for production..." + if ($PackageManager -eq "bun") { + bun run tauri:build + } else { + npm run tauri:build + } + + Write-Success "Build completed successfully!" + + # Show build artifacts location + Write-Status "Build artifacts can be found in:" + Write-Host " 📁 src-tauri\target\x86_64-pc-windows-msvc\release\" + Write-Host " đŸ“Ļ src-tauri\target\x86_64-pc-windows-msvc\release\bundle\" + + Write-Status "Windows-specific files:" + Write-Host " 🔧 vault-v2.exe - Main executable" + Write-Host " đŸ“Ļ *.msi - Windows installer (if configured)" + } + + Write-Success "KeepKey Vault-v2 build process completed! 🎉" + +} catch { + Write-Error "Build failed: $($_.Exception.Message)" + exit 1 +} \ No newline at end of file From eb2706b513818a99dc72d1374a975b0ac88d14a8 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sat, 2 Aug 2025 23:28:29 -0500 Subject: [PATCH 16/63] Fix PowerShell build script: correct tauri commands - Change 'bun run tauri:dev' to 'bun run tauri dev' - Change 'bun run tauri:build' to 'bun run tauri build' - Package.json only has 'tauri' script, not 'tauri:dev' or 'tauri:build' --- skills/build.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/build.ps1 b/skills/build.ps1 index 2a5f591..e24be47 100644 --- a/skills/build.ps1 +++ b/skills/build.ps1 @@ -182,16 +182,16 @@ try { if ($Debug) { Write-Status "Starting vault-v2 in development mode..." if ($PackageManager -eq "bun") { - bun run tauri:dev + bun run tauri dev } else { - npm run tauri:dev + npm run tauri dev } } else { Write-Status "Building vault-v2 for production..." if ($PackageManager -eq "bun") { - bun run tauri:build + bun run tauri build } else { - npm run tauri:build + npm run tauri build } Write-Success "Build completed successfully!" From 3cf85367b786b0fe23c377b9a1b7b6db509a1d21 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 00:09:13 -0500 Subject: [PATCH 17/63] Fix Windows HID timeout issue for FirmwareErase during bootloader updates - Add FirmwareErase to LONG_TIMEOUT (5 minutes) for both read and write operations - Fix Windows HID specific timeout handling for FirmwareErase operations - Resolves HID communication timeout during bootloader erase on Windows - Allows sufficient time for v1.0.3 bootloader manual confirmation This fixes the error: 'HID read failed: hidapi error: hid_read_timeout/GetOverlappedResult: (0x0000048F) The device is not connected' during bootloader updates --- projects/keepkey-rust/messages/timeouts.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/projects/keepkey-rust/messages/timeouts.rs b/projects/keepkey-rust/messages/timeouts.rs index 45270a7..08c13ab 100644 --- a/projects/keepkey-rust/messages/timeouts.rs +++ b/projects/keepkey-rust/messages/timeouts.rs @@ -57,6 +57,7 @@ impl Message { if Message::is_hid_transport_mode() { return match self { Message::ButtonAck(_) => LONG_TIMEOUT, + Message::FirmwareErase(_) => LONG_TIMEOUT, Message::FirmwareUpload(_) => LONG_TIMEOUT, Message::Initialize(_) => WINDOWS_HID_QUICK_TIMEOUT, // 10 seconds for Windows HID Message::GetFeatures(_) => WINDOWS_HID_QUICK_TIMEOUT, // 10 seconds for Windows HID @@ -69,6 +70,7 @@ impl Message { // For Initialize messages or when in legacy device mode, use appropriate timeout match self { Message::ButtonAck(_) => LONG_TIMEOUT, + Message::FirmwareErase(_) => LONG_TIMEOUT, // Firmware erase needs time for device confirmation Message::FirmwareUpload(_) => LONG_TIMEOUT, // Bootloader updates need more time Message::Initialize(_) if Message::is_legacy_device_mode() => LEGACY_DEVICE_TIMEOUT, Message::Initialize(_) => QUICK_TIMEOUT, // Initialize is usually very fast @@ -84,6 +86,7 @@ impl Message { { if Message::is_hid_transport_mode() { return match self { + Message::FirmwareErase(_) => LONG_TIMEOUT, Message::FirmwareUpload(_) => LONG_TIMEOUT, Message::Initialize(_) => WINDOWS_HID_QUICK_TIMEOUT, // 10 seconds for Windows HID Message::GetFeatures(_) => WINDOWS_HID_QUICK_TIMEOUT, // 10 seconds for Windows HID @@ -94,6 +97,7 @@ impl Message { } match self { + Message::FirmwareErase(_) => LONG_TIMEOUT, Message::FirmwareUpload(_) => LONG_TIMEOUT, Message::Initialize(_) if Message::is_legacy_device_mode() => LEGACY_DEVICE_TIMEOUT, Message::Initialize(_) => QUICK_TIMEOUT, From a142764d751e39ace9c10b8e44ab2f9257895b4c Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 00:18:02 -0500 Subject: [PATCH 18/63] Implement comprehensive retry logic for device communication timeouts TIMEOUT RETRY FIXES: - Add 3-attempt retry with exponential backoff (500ms, 1000ms delays) to event_controller.rs - Add retry logic to both timeout locations in commands.rs - Replace single 30-second timeout calls with resilient retry loops - Maintains PIN flow protection during retries - Comprehensive logging for each attempt and final failure UI IMPROVEMENTS: - Replace 'Checking device status...' with 'Follow Directions on device...' - Add green spinner to SetupWizard steps for better user feedback - Improve user guidance during device operations This resolves the frequent timeout issues where devices would fail after single attempts instead of retrying communication failures. Fixes errors like: - 'Device operation timed out' - 'Failed to get features for [device]: Device operation timed out' --- projects/vault-v2/src-tauri/src/commands.rs | 158 +++++++++++------- .../src-tauri/src/event_controller.rs | 82 +++++---- .../steps/StepBootloaderUpdate.tsx | 7 +- .../SetupWizard/steps/StepFirmwareUpdate.tsx | 7 +- 4 files changed, 159 insertions(+), 95 deletions(-) diff --git a/projects/vault-v2/src-tauri/src/commands.rs b/projects/vault-v2/src-tauri/src/commands.rs index fe4e4de..79e1082 100644 --- a/projects/vault-v2/src-tauri/src/commands.rs +++ b/projects/vault-v2/src-tauri/src/commands.rs @@ -373,41 +373,57 @@ pub async fn get_device_status( } }; - // Fetch device features through the queue - let features = match tokio::time::timeout( - Duration::from_secs(30), // Increased from 15 to 30 seconds to match Windows HID timeout - queue_handle.get_features() - ).await { - Ok(Ok(raw_features)) => { - // Convert from raw Features message to DeviceFeatures - Some(convert_features_to_device_features(raw_features)) - } - Ok(Err(e)) => { - println!("Failed to get features for device {}: {}", device_id, e); - - // Log failed feature retrieval - let device_response_data = serde_json::json!({ - "error": format!("Failed to get features: {}", e), - "operation": "get_features_for_device" - }); + // Fetch device features through the queue with retry logic + let features = { + let mut last_error = None; + let mut success_features = None; + + for attempt in 1..=3 { + println!("🔄 Attempting to get features for device {} (attempt {}/3)", device_id, attempt); - if let Err(log_err) = log_device_response(&device_id, &request_id, false, &device_response_data, Some(&format!("Failed to get features: {}", e))).await { - eprintln!("Failed to log device features error response: {}", log_err); + match tokio::time::timeout( + Duration::from_secs(30), // 30 seconds per attempt + queue_handle.get_features() + ).await { + Ok(Ok(raw_features)) => { + println!("✅ Successfully got features for device {} on attempt {}", device_id, attempt); + // Convert from raw Features message to DeviceFeatures + success_features = Some(convert_features_to_device_features(raw_features)); + break; + } + Ok(Err(e)) => { + println!("âš ī¸ Failed to get features for device {} on attempt {}: {}", device_id, attempt, e); + last_error = Some(format!("Failed to get features: {}", e)); + } + Err(_) => { + println!("âąī¸ Timeout getting features for device {} on attempt {}", device_id, attempt); + last_error = Some("Timeout getting features".to_string()); + } } - None + // Wait before retrying (exponential backoff) + if attempt < 3 { + let delay_ms = 500 * attempt as u64; // 500ms, 1000ms + println!("âŗ Waiting {}ms before retry for device {}", delay_ms, device_id); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } } - Err(_) => { - println!("Timeout getting features for device {}", device_id); + + // Handle final result + if let Some(features) = success_features { + Some(features) + } else { + // Log the final failure + let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string()); + println!("❌ All attempts failed for device {}: {}", device_id, error_msg); - // Log timeout let device_response_data = serde_json::json!({ - "error": "Timeout getting features", + "error": error_msg, "operation": "get_features_for_device" }); - if let Err(e) = log_device_response(&device_id, &request_id, false, &device_response_data, Some("Timeout getting features")).await { - eprintln!("Failed to log device features timeout response: {}", e); + if let Err(log_err) = log_device_response(&device_id, &request_id, false, &device_response_data, Some(&error_msg)).await { + eprintln!("Failed to log device features error response: {}", log_err); } None @@ -1026,53 +1042,69 @@ pub async fn get_connected_devices_with_features( } }; - // Try to fetch features through the queue - let features = match tokio::time::timeout( - Duration::from_secs(30), // Increased from 15 to 30 seconds to match Windows HID timeout - queue_handle.get_features() - ).await { - Ok(Ok(raw_features)) => { - // Convert from raw Features message to DeviceFeatures - let device_features = convert_features_to_device_features(raw_features); - - // Log successful feature retrieval - let device_response_data = serde_json::json!({ - "features": device_features, - "operation": "get_features_for_device" - }); + // Try to fetch features through the queue with retry logic + let features = { + let mut last_error = None; + let mut success_features = None; + + for attempt in 1..=3 { + println!("🔄 Attempting to get features for device {} (attempt {}/3)", device_id, attempt); - if let Err(e) = log_device_response(&device_id, &device_request_id, true, &device_response_data, None).await { - eprintln!("Failed to log device features response: {}", e); + match tokio::time::timeout( + Duration::from_secs(30), // 30 seconds per attempt + queue_handle.get_features() + ).await { + Ok(Ok(raw_features)) => { + println!("✅ Successfully got features for device {} on attempt {}", device_id, attempt); + // Convert from raw Features message to DeviceFeatures + let device_features = convert_features_to_device_features(raw_features); + + // Log successful feature retrieval + let device_response_data = serde_json::json!({ + "features": device_features, + "operation": "get_features_for_device" + }); + + if let Err(e) = log_device_response(&device_id, &device_request_id, true, &device_response_data, None).await { + eprintln!("Failed to log device features response: {}", e); + } + + success_features = Some(device_features); + break; + } + Ok(Err(e)) => { + println!("âš ī¸ Failed to get features for device {} on attempt {}: {}", device_id, attempt, e); + last_error = Some(format!("Failed to get features: {}", e)); + } + Err(_) => { + println!("âąī¸ Timeout getting features for device {} on attempt {}", device_id, attempt); + last_error = Some("Timeout getting features".to_string()); + } } - Some(device_features) - } - Ok(Err(e)) => { - println!("Failed to get features for device {}: {}", device_id, e); - - // Log failed feature retrieval - let device_response_data = serde_json::json!({ - "error": format!("Failed to get features: {}", e), - "operation": "get_features_for_device" - }); - - if let Err(log_err) = log_device_response(&device_id, &device_request_id, false, &device_response_data, Some(&format!("Failed to get features: {}", e))).await { - eprintln!("Failed to log device features error response: {}", log_err); + // Wait before retrying (exponential backoff) + if attempt < 3 { + let delay_ms = 500 * attempt as u64; // 500ms, 1000ms + println!("âŗ Waiting {}ms before retry for device {}", delay_ms, device_id); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; } - - None } - Err(_) => { - println!("Timeout getting features for device {}", device_id); + + // Handle final result + if let Some(features) = success_features { + Some(features) + } else { + // Log the final failure + let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string()); + println!("❌ All attempts failed for device {}: {}", device_id, error_msg); - // Log timeout let device_response_data = serde_json::json!({ - "error": "Timeout getting features", + "error": error_msg, "operation": "get_features_for_device" }); - if let Err(e) = log_device_response(&device_id, &device_request_id, false, &device_response_data, Some("Timeout getting features")).await { - eprintln!("Failed to log device features timeout response: {}", e); + if let Err(log_err) = log_device_response(&device_id, &device_request_id, false, &device_response_data, Some(&error_msg)).await { + eprintln!("Failed to log device features error response: {}", log_err); } None diff --git a/projects/vault-v2/src-tauri/src/event_controller.rs b/projects/vault-v2/src-tauri/src/event_controller.rs index c873b91..cc8754c 100644 --- a/projects/vault-v2/src-tauri/src/event_controller.rs +++ b/projects/vault-v2/src-tauri/src/event_controller.rs @@ -464,42 +464,68 @@ async fn try_get_device_features(device: &FriendlyUsbDevice, app_handle: &AppHan return Err("Device entered PIN flow - aborting feature fetch".to_string()); } - // Try to get features with a timeout using the shared worker - match tokio::time::timeout(Duration::from_secs(30), queue_handle.get_features()).await { - Ok(Ok(raw_features)) => { - // Convert features to our DeviceFeatures format - let device_features = crate::commands::convert_features_to_device_features(raw_features); - Ok(device_features) + // Try to get features with retry logic for timeout resilience + let mut last_error = None; + for attempt in 1..=3 { + println!("🔄 Attempting to get features for device {} (attempt {}/3)", device.unique_id, attempt); + + // Check PIN flow status before each attempt + if crate::commands::is_device_in_pin_flow(&device.unique_id) { + return Err("Device entered PIN flow during feature fetch".to_string()); } - Ok(Err(e)) => { - let error_str = e.to_string(); - - // Check if this looks like an OOB bootloader that doesn't understand GetFeatures - if error_str.contains("Unknown message") || - error_str.contains("Failure: Unknown message") || - error_str.contains("Unexpected response") { - - println!("🔧 Device may be in OOB bootloader mode, trying Initialize message..."); + + match tokio::time::timeout(Duration::from_secs(30), queue_handle.get_features()).await { + Ok(Ok(raw_features)) => { + println!("✅ Successfully got features for device {} on attempt {}", device.unique_id, attempt); + // Convert features to our DeviceFeatures format + let device_features = crate::commands::convert_features_to_device_features(raw_features); + return Ok(device_features); + } + Ok(Err(e)) => { + let error_str = e.to_string(); - // Try the direct approach using keepkey-rust's proven method - match try_oob_bootloader_detection(device).await { - Ok(features) => { - println!("✅ Successfully detected OOB bootloader mode for device {}", device.unique_id); - Ok(features) - } - Err(oob_err) => { - println!("❌ OOB bootloader detection also failed for {}: {}", device.unique_id, oob_err); - Err(format!("Failed to get device features: {} (OOB attempt: {})", error_str, oob_err)) + // Check if this looks like an OOB bootloader that doesn't understand GetFeatures + if error_str.contains("Unknown message") || + error_str.contains("Failure: Unknown message") || + error_str.contains("Unexpected response") { + + println!("🔧 Device may be in OOB bootloader mode, trying Initialize message..."); + + // Try the direct approach using keepkey-rust's proven method + match try_oob_bootloader_detection(device).await { + Ok(features) => { + println!("✅ Successfully detected OOB bootloader mode for device {}", device.unique_id); + return Ok(features); + } + Err(oob_err) => { + println!("❌ OOB bootloader detection also failed for {}: {}", device.unique_id, oob_err); + last_error = Some(format!("Failed to get device features: {} (OOB attempt: {})", error_str, oob_err)); + } } + } else { + println!("âš ī¸ Failed to get features for device {} on attempt {}: {}", device.unique_id, attempt, error_str); + last_error = Some(format!("Failed to get device features: {}", error_str)); } - } else { - Err(format!("Failed to get device features: {}", error_str)) + } + Err(_) => { + println!("âąī¸ Timeout getting features for device {} on attempt {}", device.unique_id, attempt); + last_error = Some("Timeout while fetching device features".to_string()); } } - Err(_) => { - Err("Timeout while fetching device features".to_string()) + + // Wait before retrying (exponential backoff) + if attempt < 3 { + let delay_ms = 500 * attempt as u64; // 500ms, 1000ms + println!("âŗ Waiting {}ms before retry for device {}", delay_ms, device.unique_id); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; } } + + // All attempts failed + match last_error { + Some(err) => Err(err), + None => Err(format!("All feature fetch attempts failed for device {}", device.unique_id)) + } } else { // Fallback to the old method if queue manager is not available println!("âš ī¸ DeviceQueueManager not available, using fallback method"); diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx index d69733e..b2056a5 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx @@ -1,4 +1,4 @@ -import { VStack, HStack, Text, Button, Box, Icon, Image, Alert, Progress } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Box, Icon, Image, Alert, Progress, Spinner } from "@chakra-ui/react"; import { FaShieldAlt, FaExclamationTriangle } from "react-icons/fa"; import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; @@ -162,7 +162,10 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade if (!deviceStatus) { return ( - Checking device status... + + + Follow Directions on device... + ); } diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx index dae13ae..17c43c2 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -1,4 +1,4 @@ -import { VStack, HStack, Text, Button, Box, Icon, Progress, Badge, Alert } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Box, Icon, Progress, Badge, Alert, Spinner } from "@chakra-ui/react"; import { FaDownload, FaExclamationTriangle } from "react-icons/fa"; import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; @@ -151,7 +151,10 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd if (!deviceStatus) { return ( - Checking device status... + + + Follow Directions on device... + ); } From dbe85dabc6ba2e2e40236984d8e1837e7213f846 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 00:25:23 -0500 Subject: [PATCH 19/63] Configure GitHub Actions to build release-* branches GITHUB ACTIONS IMPROVEMENTS: - Add 'release-*' branch triggers to build.yml workflow - Add 'release-*' branch triggers to release.yml workflow - Make release-specific jobs (create-release, update-release-notes, publish-release) conditional to only run on tags, not branches - Allow build jobs (build-kkcli, build-tauri) to run on both tags and release branches - Use conditional needs dependencies to avoid job dependency issues This enables: release-2.2.4 branch builds will now trigger automatically Future release-* branches will build via GitHub Actions Actual releases still only happen on tagged versions Testing release builds on branches without creating releases Now your release-2.2.4 branch will trigger builds! --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 957abf4..fe15782 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,9 @@ name: Build KeepKey Vault v5 on: push: - branches: [ main, master ] + branches: [ main, master, 'release-*' ] pull_request: - branches: [ main, master ] + branches: [ main, master, 'release-*' ] workflow_dispatch: # Add permissions for GitHub Actions to create releases diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ad8fda..ce2e072 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,13 @@ on: push: tags: - 'v*' + branches: + - 'release-*' workflow_dispatch: jobs: create-release: + if: startsWith(github.ref, 'refs/tags/') permissions: contents: write runs-on: ubuntu-latest @@ -31,7 +34,7 @@ jobs: prerelease: false build-kkcli: - needs: create-release + needs: ${{ startsWith(github.ref, 'refs/tags/') && 'create-release' || '' }} permissions: contents: write @@ -150,7 +153,7 @@ jobs: retention-days: 7 build-tauri: - needs: create-release + needs: ${{ startsWith(github.ref, 'refs/tags/') && 'create-release' || '' }} permissions: contents: write @@ -369,6 +372,7 @@ jobs: fi update-release-notes: + if: startsWith(github.ref, 'refs/tags/') needs: [create-release, build-kkcli, build-tauri] permissions: contents: write @@ -419,6 +423,7 @@ jobs: }); publish-release: + if: startsWith(github.ref, 'refs/tags/') permissions: contents: write runs-on: ubuntu-latest From ed694738ff2e3b1eca660b8dbd8ef9d446107c06 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 00:30:17 -0500 Subject: [PATCH 20/63] Adopt superior upstream workflows with release-* branch support UPSTREAM WORKFLOW ADOPTION: - Replace our workflows with superior upstream release-2.2.3 versions - More comprehensive platform matrix (Ubuntu 22.04 & 24.04, universal macOS, multiple architectures) - Better dependency management and error handling - Proper signing credential checks and conditional usage - Direct GitHub release uploads with upload-release-asset - Cleaner job structure and better debugging RELEASE BRANCH SUPPORT: - Add 'release-*' branch triggers to both workflows - Make release-specific jobs conditional (tags only): * create-release, update-release-notes, publish-release - Allow build jobs to run on both tags and branches - Conditional releaseId and upload-release-asset for tags only This gives us the best of both worlds: Superior upstream workflow structure and features Our release-2.2.4 branch support for CI/CD testing Proper separation of build testing vs actual releases --- .github/workflows/build.yml | Bin 15735 -> 32026 bytes .github/workflows/release.yml | Bin 18262 -> 37428 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe1578290f23ba9eed7f645b3fbb2e534619bc60..738fd5c0862de9c8ec56f0574b800941f126031a 100644 GIT binary patch literal 32026 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 WrRdjk(6x9rQ=6U^HnNgI%33WoSsa= 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 Date: Sun, 3 Aug 2025 00:50:26 -0500 Subject: [PATCH 21/63] Fix TypeScript errors in SetupWizard and WalletCreation components - Fix Chakra UI prop compatibility issues (isDisabled -> disabled, isLoading -> loading) - Remove invalid thickness prop from Spinner components - Fix PinStep enum comparisons and remove unreachable code - All components now build successfully without TypeScript errors --- .../SetupWizard/steps/Step2DeviceLabel.tsx | 8 +++--- .../steps/Step4BackupOrRecover.tsx | 2 +- .../steps/StepBootloaderUpdate.tsx | 2 +- .../SetupWizard/steps/StepFirmwareUpdate.tsx | 2 +- .../DevicePinHorizontal.tsx | 27 +++++++------------ 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx index 714a1e2..a1667ec 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step2DeviceLabel.tsx @@ -82,7 +82,7 @@ export function Step2DeviceLabel({ _hover={{ borderColor: "gray.500" }} _focus={{ borderColor: "orange.500", boxShadow: "0 0 0 1px orange.500" }} color="white" - isDisabled={isLoading} + disabled={isLoading} onKeyPress={(e) => { if (e.key === 'Enter' && label.trim()) { handleSubmit(); @@ -102,9 +102,9 @@ export function Step2DeviceLabel({ size="lg" w="100%" onClick={handleSubmit} - isLoading={isLoading} + loading={isLoading} loadingText="Setting label..." - isDisabled={!label.trim()} + disabled={!label.trim()} > Set Device Name @@ -114,7 +114,7 @@ export function Step2DeviceLabel({ size="lg" w="100%" onClick={handleSkip} - isDisabled={isLoading} + disabled={isLoading} color="gray.400" _hover={{ color: "white", bg: "gray.700" }} > diff --git a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx index f4092ac..2e970ca 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/Step4BackupOrRecover.tsx @@ -111,7 +111,7 @@ export function Step4BackupOrRecover({ size="lg" w="100%" onClick={handleBackupComplete} - isLoading={isLoading} + loading={isLoading} loadingText="Completing setup..." _hover={{ transform: "scale(1.02)" }} > diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx index b2056a5..52e3c0a 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx @@ -312,7 +312,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade size="lg" w="100%" onClick={handleBootloaderUpdate} - isLoading={isUpdating} + loading={isUpdating} > Update Bootloader diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx index 17c43c2..2f7bde8 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepFirmwareUpdate.tsx @@ -383,7 +383,7 @@ export function StepFirmwareUpdate({ deviceId, onNext, onBack }: StepFirmwareUpd size="lg" w="100%" onClick={handleFirmwareUpdate} - isLoading={isUpdating} + loading={isUpdating} > Update Firmware Now diff --git a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx index 81eaddd..b842807 100644 --- a/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx +++ b/projects/vault-v2/src/components/WalletCreationWizard/DevicePinHorizontal.tsx @@ -183,16 +183,7 @@ export function DevicePinHorizontal({ } if (result.success) { - // If we're on the second PIN step, assume success and move on - if (session.current_step === PinStep.AwaitingSecond) { - console.log("Second PIN submitted! Moving to recovery phrase screen..."); - setIsTransitioning(true); - // Don't wait for backend confirmation, just move forward - setTimeout(() => { - onComplete(session); - }, 500); - return; - } + // The AwaitingSecond case is already handled above with early return // Check if PIN creation is complete based on result if (result.next_step === 'complete') { @@ -234,7 +225,7 @@ export function DevicePinHorizontal({ console.log("No next_step specified, checking session status..."); const finalSession = await PinService.getSessionStatus(session.session_id); console.log("Session status check result:", finalSession); - if (finalSession && (finalSession.current_step === PinStep.Completed || finalSession.current_step === 'Completed')) { + if (finalSession && finalSession.current_step === PinStep.Completed) { console.log("Session is completed! Triggering completion flow..."); setIsTransitioning(true); onComplete(finalSession); @@ -246,11 +237,11 @@ export function DevicePinHorizontal({ const updatedSession = await PinService.getSessionStatus(session.session_id); console.log("Updated session for other cases:", updatedSession); if (updatedSession) { - if (updatedSession.current_step === PinStep.Completed || updatedSession.current_step === 'Completed') { + if (updatedSession.current_step === PinStep.Completed) { console.log("Session shows completed, calling onComplete..."); setIsTransitioning(true); onComplete(updatedSession); - } else if (updatedSession.current_step === PinStep.AwaitingSecond || updatedSession.current_step === 'AwaitingSecond') { + } else if (updatedSession.current_step === PinStep.AwaitingSecond) { console.log("Session shows awaiting second PIN..."); setSession(updatedSession); setPositions([]); @@ -304,7 +295,7 @@ export function DevicePinHorizontal({ return (
- + Initializing PIN setup on device... @@ -318,7 +309,7 @@ export function DevicePinHorizontal({ return (
- + PIN setup complete! Preparing recovery phrase... @@ -433,7 +424,7 @@ export function DevicePinHorizontal({ colorScheme="gray" size="lg" flex={1} - isDisabled={positions.length === 0 || isLoading || isSubmitting} + disabled={positions.length === 0 || isLoading || isSubmitting} > Backspace @@ -445,8 +436,8 @@ export function DevicePinHorizontal({ colorScheme="green" size="lg" flex={1} - isLoading={isSubmitting} - isDisabled={positions.length === 0 || isLoading} + loading={isSubmitting} + disabled={positions.length === 0 || isLoading} > {session?.current_step === PinStep.AwaitingSecond ? 'Confirm PIN' : 'Set PIN'} From 0ed9660226de9e2481d1f22c60af895958dad43f Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 01:01:04 -0500 Subject: [PATCH 22/63] Fix ARM64 Linux cross-compilation in GitHub Actions - Remove ARM64 Linux target from Tauri build (glib cross-compilation too complex) - Add proper ARM64 cross-compilation setup for kkcli build - Set up cross-compilation environment variables for ARM64 targets - Enable multiarch support for ARM64 packages in Ubuntu builds This fixes the glib-sys pkg-config cross-compilation error. --- .github/workflows/build.yml | Bin 32026 -> 34180 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 738fd5c0862de9c8ec56f0574b800941f126031a..54687e3e2e98643a69d46ea5261f6e424a9dd388 100644 GIT binary patch delta 1377 zcmcIjO=}ZT6g??PBW6k`t!Zpy>!1ZKS|%-iKp{v8G!4}@O)9plI*n-z&BvG+4Z4tm z;KGHAyD6gJPY?pG+z8^zAEDsdA0Q&0_Y!PMBPgU?X6C+k-@WJFbKboC;d%PJ@9n!l z&_Nk@U|}9<+`tuFlGTN0@+}bcIjEu}r~5}GYAj21)GMz{)4v4=HRNP>Fe0A<6EbI* z%{}A0M+Kk!DZ+wTrdN2hhH3g0l;EO(BGd2lTOq7PuR<&GjK%#{BSl8Hn5*Cm$mv)C z+xoQH%X3dvDAVg`8v{5l=lX*ZjZKOh8IkvokIAnyvoh4U$N|P=&lm~i*-4iDIJ$Zk zclE#@e4((#Y8Pl-Vqy)P64UFIkA`K>>CBSKBYLaXItk3-0drg5jx+LlaXiqu_9Vmp z_L|g=i(49-v5?U-S*Vkve_&2ttW8NOWURVmuSzy7#A%xc=i<6fmUj45@R09K-EWz@ z4f=WR)Db(FBjmzA%@}!-uO}im8|-KwV;$=~MIqw~CDVzb?of{=@hLT{3Sp@eA%!I* zh!MvMma%}OUZwa8tYMLi*yN=~UhTHN_F;~KHhJ06JgJ+!jeyj+eaqa^Nx3~`B#uJS-NFARI_F*9$Ba78S?-D From 180890fcb4a17ad5bf7701916f3f26612b878765 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 01:06:26 -0500 Subject: [PATCH 23/63] Update version numbers to 2.2.4 to match release branch - Update tauri.conf.json version from 2.2.3 to 2.2.4 - Update package.json version from 2.2.3 to 2.2.4 - Fixes version mismatch causing GitHub Actions to create wrong release tag --- projects/vault-v2/package.json | 2 +- projects/vault-v2/src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/vault-v2/package.json b/projects/vault-v2/package.json index 34f5070..f6dc650 100644 --- a/projects/vault-v2/package.json +++ b/projects/vault-v2/package.json @@ -1,7 +1,7 @@ { "name": "vault-v2", "private": true, - "version": "2.2.3", + "version": "2.2.4", "type": "module", "scripts": { "dev": "vite", diff --git a/projects/vault-v2/src-tauri/tauri.conf.json b/projects/vault-v2/src-tauri/tauri.conf.json index 7286b72..0929619 100644 --- a/projects/vault-v2/src-tauri/tauri.conf.json +++ b/projects/vault-v2/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "vault-v2", - "version": "2.2.3", + "version": "2.2.4", "identifier": "com.vault-v2.app", "build": { "beforeDevCommand": "bun run dev", From dcdb4db9653bc6b944bbe2545a1328ff6f9d55d7 Mon Sep 17 00:00:00 2001 From: BitHighlander Date: Sun, 3 Aug 2025 01:15:14 -0500 Subject: [PATCH 24/63] Remove i686 Linux target from Tauri build to fix cross-compilation - Remove Ubuntu-24.04-LTS-i686 from Tauri build matrix - Fixes glib-sys pkg-config cross-compilation error for i686-unknown-linux-gnu - GUI cross-compilation for different architectures is too complex for CI - kkcli still builds for i686 in separate job --- .github/workflows/build.yml | Bin 34180 -> 33874 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54687e3e2e98643a69d46ea5261f6e424a9dd388..af49f911d3403e91e52f426b1054f77b3a36b1f7 100644 GIT binary patch delta 34 qcmZqaX1dhDv>`={nU{fUvSE?zWCu2%$saTYCO^>$*_@^$7YP8#MGLI} delta 64 zcmccA!PL^tv>`=n@&rwr$px$`lRs!GfawHH9ade2Oa`;b9|I&O3$SoZZeSOfyobeL S@}okT$qsBjn=fcJMFIfa0~Toj From c8d31828ad77ae37066499849a02f02e58e63348 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 3 Aug 2025 14:32:08 -0500 Subject: [PATCH 25/63] clean up tweeks --- projects/vault-v2/src/App.tsx | 85 ++++++++++- .../BootloaderUpdateWizard.tsx | 17 +-- .../steps/Step1UpdateInProgress.tsx | 67 ++++----- .../src/components/NoDeviceDialog.tsx | 137 ++++++++++++++++++ .../steps/StepBootloaderUpdate.tsx | 101 ++++--------- .../vault-v2/src/contexts/DialogContext.tsx | 38 +++++ .../vault-v2/src/contexts/WalletContext.tsx | 53 ++++++- .../vault-v2/src/hooks/useCommonDialogs.tsx | 15 ++ 8 files changed, 374 insertions(+), 139 deletions(-) create mode 100644 projects/vault-v2/src/components/NoDeviceDialog.tsx diff --git a/projects/vault-v2/src/App.tsx b/projects/vault-v2/src/App.tsx index fe98237..a20f631 100644 --- a/projects/vault-v2/src/App.tsx +++ b/projects/vault-v2/src/App.tsx @@ -56,7 +56,8 @@ function App() { const [deviceUpdateComplete, setDeviceUpdateComplete] = useState(false); const [onboardingActive, setOnboardingActive] = useState(false); const [setupWizardActive, setSetupWizardActive] = useState(false); - const { showOnboarding, showError } = useCommonDialogs(); + const [noDeviceDialogShown, setNoDeviceDialogShown] = useState(false); + const { showOnboarding, showError, showNoDevice } = useCommonDialogs(); const { shouldShowOnboarding, loading: onboardingLoading, clearCache } = useOnboardingState(); const { hideAll, activeDialog, getQueue, isWizardActive } = useDialog(); const { fetchedXpubs, portfolio, isSync, reinitialize } = useWallet(); @@ -198,6 +199,43 @@ function App() { } }, [shouldShowOnboarding, onboardingLoading, showOnboarding, clearCache]); + // Show "No Device" dialog after 30 seconds if no device is connected + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (!deviceConnected && !noDeviceDialogShown && !onboardingActive && !setupWizardActive) { + console.log("📱 [App] Starting 30-second timer for no device dialog"); + timeoutId = setTimeout(() => { + if (!deviceConnected) { + console.log("📱 [App] 30 seconds elapsed with no device - showing dialog"); + setNoDeviceDialogShown(true); + showNoDevice({ + onRetry: async () => { + console.log("📱 [App] User clicked retry - restarting backend"); + setNoDeviceDialogShown(false); + // Restart the backend to scan for devices again + try { + await invoke('restart_backend_startup'); + reinitialize(); + } catch (error) { + console.error("Failed to restart backend:", error); + } + } + }); + } + }, 30000); // 30 seconds + } else if (deviceConnected && noDeviceDialogShown) { + // Device connected, reset the flag + setNoDeviceDialogShown(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [deviceConnected, noDeviceDialogShown, onboardingActive, setupWizardActive, showNoDevice, reinitialize]); + useEffect(() => { let unlistenStatusUpdate: (() => void) | undefined; let unlistenDeviceReady: (() => void) | undefined; @@ -288,6 +326,27 @@ function App() { setDeviceUpdateComplete(false); }); + // Listen for "no device found" event from backend + const unlistenNoDeviceFound = await listen('device:no-device-found', (event) => { + console.log('📱 [App] No device found event received from backend:', event.payload); + if (!deviceConnected && !noDeviceDialogShown) { + setNoDeviceDialogShown(true); + showNoDevice({ + onRetry: async () => { + console.log("📱 [App] User clicked retry - restarting backend"); + setNoDeviceDialogShown(false); + // Restart the backend to scan for devices again + try { + await invoke('restart_backend_startup'); + reinitialize(); + } catch (error) { + console.error("Failed to restart backend:", error); + } + } + }); + } + }); + console.log('✅ All event listeners set up successfully'); // Return cleanup function that removes all listeners @@ -298,6 +357,7 @@ function App() { if (unlistenFeaturesUpdated) unlistenFeaturesUpdated(); if (unlistenAccessError) unlistenAccessError(); if (unlistenDeviceDisconnected) unlistenDeviceDisconnected(); + if (unlistenNoDeviceFound) unlistenNoDeviceFound(); }; } catch (error) { @@ -311,7 +371,7 @@ function App() { if (unlistenStatusUpdate) unlistenStatusUpdate(); if (unlistenDeviceReady) unlistenDeviceReady(); }; - }, []); // Empty dependency array ensures this runs once on mount and cleans up on unmount + }, [deviceConnected, noDeviceDialogShown, showNoDevice, reinitialize]); // Add dependencies for the no device listener const mcpUrl = "http://127.0.0.1:1646/mcp"; @@ -381,12 +441,21 @@ function App() { borderRadius="md" bg="rgba(0, 0, 0, 0.5)" > - - - - {loadingStatus} - - {/* âŸĩ no layout shift */} + + + + + {loadingStatus === "Scanning for devices..." && !deviceConnected + ? "Please connect your KeepKey" + : loadingStatus} + + {/* âŸĩ no layout shift */} + + {loadingStatus === "Scanning for devices..." && !deviceConnected && ( + + If your device is already connected, try unplugging and reconnecting it + + )} diff --git a/projects/vault-v2/src/components/BootloaderUpdateWizard/BootloaderUpdateWizard.tsx b/projects/vault-v2/src/components/BootloaderUpdateWizard/BootloaderUpdateWizard.tsx index f51eaad..3709acf 100644 --- a/projects/vault-v2/src/components/BootloaderUpdateWizard/BootloaderUpdateWizard.tsx +++ b/projects/vault-v2/src/components/BootloaderUpdateWizard/BootloaderUpdateWizard.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Box, Button, HStack, VStack, Text, Flex, Icon, Progress } from '@chakra-ui/react'; +import { Box, Button, HStack, VStack, Text, Flex, Icon } from '@chakra-ui/react'; import { FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa'; import { useDialog } from '../../contexts/DialogContext'; import { Step0Warning } from './steps/Step0Warning'; @@ -170,21 +170,6 @@ export function BootloaderUpdateWizard({ - {/* Progress Bar */} - - - - - - - {activeStep.id === 'in-progress' && progressInfo && ( - {progressInfo.message} - )} - {errorInfo && activeStep.id !== 'completion' && ( diff --git a/projects/vault-v2/src/components/BootloaderUpdateWizard/steps/Step1UpdateInProgress.tsx b/projects/vault-v2/src/components/BootloaderUpdateWizard/steps/Step1UpdateInProgress.tsx index 94057a0..92ee06d 100644 --- a/projects/vault-v2/src/components/BootloaderUpdateWizard/steps/Step1UpdateInProgress.tsx +++ b/projects/vault-v2/src/components/BootloaderUpdateWizard/steps/Step1UpdateInProgress.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { VStack, Text, Spinner, Box, Icon } from '@chakra-ui/react'; +import React, { useEffect } from 'react'; +import { VStack, Text, Box, Icon } from '@chakra-ui/react'; import { FaCog } from 'react-icons/fa'; import { invoke } from '@tauri-apps/api/core'; import type { StepProps } from '../BootloaderUpdateWizard'; @@ -11,61 +11,54 @@ export const Step1UpdateInProgress: React.FC = ({ onSetProgress, clearError }) => { - const [statusMessage, setStatusMessage] = useState('Initializing update...'); - useEffect(() => { clearError(); // Clear previous errors when entering this step const performUpdate = async () => { try { - if (onSetProgress) onSetProgress({ value: 10, message: 'Preparing device...' }); - setStatusMessage('Preparing device for bootloader update...'); - // TODO: Replace with actual Tauri command to start bootloader update - // This command should ideally emit progress events or return status updates. - // For now, we simulate a multi-stage process. - - await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate prep time - if (onSetProgress) onSetProgress({ value: 30, message: 'Entering bootloader mode...' }); - setStatusMessage('Device entering bootloader mode...'); - // Example: await invoke('enter_bootloader_mode', { deviceId }); - - await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate mode switch - if (onSetProgress) onSetProgress({ value: 50, message: 'Sending update payload...' }); - setStatusMessage('Sending update payload to device...'); - // Example: await invoke('send_bootloader_firmware', { deviceId }); - - await new Promise(resolve => setTimeout(resolve, 5000)); // Simulate flashing - if (onSetProgress) onSetProgress({ value: 80, message: 'Verifying update...' }); - setStatusMessage('Verifying update integrity...'); - // Example: await invoke('verify_bootloader_update', { deviceId }); + // For now, we simulate the update process. - await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate verification - if (onSetProgress) onSetProgress({ value: 100, message: 'Update successful! Rebooting...' }); - setStatusMessage('Bootloader update successful! Device is rebooting.'); - - // Example: await invoke('reboot_device_after_update', { deviceId }); - await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate reboot + // Simulate the update process + await new Promise(resolve => setTimeout(resolve, 15000)); // Simulate update time onNext(); // Move to completion step } catch (err: any) { console.error('Bootloader update failed:', err); const errorMessage = err.message || 'An unknown error occurred during the update.'; const errorAdvice = 'Please ensure your device remained connected. You may need to unplug and replug your device, then try the update again. If the problem persists, contact support.'; - if (onSetProgress) onSetProgress({ value: 100, message: `Error: ${errorMessage}`}); // Show error in progress too onError(errorMessage, errorAdvice); } }; performUpdate(); - }, [deviceId, onNext, onError, onSetProgress, clearError]); + }, [deviceId, onNext, onError, clearError]); return ( - - - Updating Bootloader... - {statusMessage} + + + Follow directions on device + + + Your KeepKey will guide you through the update process. + + + + + + Note: On the KeepKey, it will ask you to verify backup. + We will do this after updating - hold the button to skip for now to continue. + + + = ({ bg="gray.750" > - + DO NOT DISCONNECT YOUR DEVICE. diff --git a/projects/vault-v2/src/components/NoDeviceDialog.tsx b/projects/vault-v2/src/components/NoDeviceDialog.tsx new file mode 100644 index 0000000..35ba65b --- /dev/null +++ b/projects/vault-v2/src/components/NoDeviceDialog.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { + Box, + VStack, + Text, + Button, + HStack, + Icon +} from '@chakra-ui/react'; +import { FaUsb } from 'react-icons/fa'; +import { useDialog } from '../contexts/DialogContext'; + +export interface NoDeviceDialogProps { + onRetry?: () => void; +} + +export function NoDeviceDialog({ onRetry }: NoDeviceDialogProps) { + const { hide } = useDialog(); + + const handleRetry = () => { + if (onRetry) { + onRetry(); + } + hide('no-device-found'); + }; + + return ( + + + + + + No KeepKey Detected + + + + + + + + Please connect your KeepKey device to continue + + + + + + Troubleshooting tips: + + + â€ĸ Make sure your KeepKey is plugged in via USB + + + â€ĸ Try a different USB port or cable + + + â€ĸ Unplug and reconnect your device + + + â€ĸ Ensure no other apps are using the device + + + + + + + + Device still not detected? + + + Try putting your KeepKey into updater mode: + + + + 1. Disconnect your KeepKey + + + 2. Hold down the button on your KeepKey + + + 3. While holding the button, reconnect the USB cable + + + 4. Release the button when you see the KeepKey logo + + + + This will allow the device to be detected and updated if needed. + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx index 52e3c0a..7242d93 100644 --- a/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx +++ b/projects/vault-v2/src/components/SetupWizard/steps/StepBootloaderUpdate.tsx @@ -1,6 +1,6 @@ -import { VStack, HStack, Text, Button, Box, Icon, Image, Alert, Progress, Spinner } from "@chakra-ui/react"; +import { VStack, HStack, Text, Button, Box, Icon, Image, Spinner } from "@chakra-ui/react"; import { FaShieldAlt, FaExclamationTriangle } from "react-icons/fa"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import holdAndConnectSvg from '../../../assets/svg/hold-and-connect.svg'; @@ -10,20 +10,11 @@ interface StepBootloaderUpdateProps { onBack: () => void; } -// CSS animation for striped progress bar -const stripeAnimationStyle = ` - @keyframes stripeAnimation { - 0% { background-position: 0 0; } - 100% { background-position: 40px 0; } - } -`; export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloaderUpdateProps) { const [deviceStatus, setDeviceStatus] = useState(null); const [isUpdating, setIsUpdating] = useState(false); - const [updateProgress, setUpdateProgress] = useState(0); const [showBootloaderInstructions, setShowBootloaderInstructions] = useState(false); - const progressIntervalRef = useRef(null); useEffect(() => { // Only check device status if we have a deviceId @@ -99,20 +90,6 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade } setIsUpdating(true); - setUpdateProgress(0); - - // Start the 60-second progress animation - let progress = 0; - progressIntervalRef.current = setInterval(() => { - progress += (100 / 60); // 100% over 60 seconds - if (progress >= 100) { - progress = 100; - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - } - setUpdateProgress(progress); - }, 1000); // Update every second try { // Start bootloader update @@ -121,13 +98,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade targetVersion: deviceStatus.bootloaderCheck?.latestVersion || '' }); - // Clear the interval when done - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - setUpdateProgress(100); - - // Wait a moment to show completion + // Wait a moment before moving to next step setTimeout(() => { onNext(); }, 500); @@ -142,22 +113,9 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade } // Don't show any errors to the user setIsUpdating(false); - // Clear the interval on error - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } } }; - // Cleanup interval on unmount - useEffect(() => { - return () => { - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } - }; - }, []); - if (!deviceStatus) { return ( @@ -185,12 +143,12 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade - Enter Bootloader Mode + Enter Firmware Update Mode - To update the bootloader, your device must be in Bootloader Mode + To update the firmware, your device must be in Update Mode @@ -209,7 +167,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade Quick Steps: 1. Unplug your KeepKey device 2. Hold the button and plug it back in - 3. Keep holding until "BOOTLOADER MODE" appears + 3. Follow directions on device 4. Release the button @@ -232,7 +190,7 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade - Bootloader Update + Firmware Updater {deviceStatus.needsBootloaderUpdate ? ( @@ -274,34 +232,25 @@ export function StepBootloaderUpdate({ deviceId, onNext, onBack }: StepBootloade {isUpdating && ( - - - Updating bootloader... Do not disconnect your device - - - + + + + + On the KeepKey, it will ask you to verify backup. We will do this after updating, hold the button to skip this for now to continue. + + - - {Math.round(updateProgress)}% - Estimated time remaining: {Math.max(0, 60 - Math.round(updateProgress * 0.6))}s - + + + Follow directions on device + + + Your KeepKey will guide you through the update process. + + + Do not disconnect your device during the update. + + )} diff --git a/projects/vault-v2/src/contexts/DialogContext.tsx b/projects/vault-v2/src/contexts/DialogContext.tsx index a8ac163..4f787fe 100644 --- a/projects/vault-v2/src/contexts/DialogContext.tsx +++ b/projects/vault-v2/src/contexts/DialogContext.tsx @@ -618,4 +618,42 @@ export function useDeviceInvalidStateDialog() { hide: (deviceId: string) => hide(`device-invalid-state-${deviceId}`), isShowing: (deviceId: string) => isShowing(`device-invalid-state-${deviceId}`), }; +} + +// Pre-configured dialog for PIN Unlock +export function usePinUnlockDialog() { + const { show, hide, isShowing } = useDialog(); + return { + show: (props: { + deviceId: string; + onUnlocked?: () => void; + onDialogClose?: () => void; + }) => { + const dialogId = `pin-unlock-${props.deviceId}`; + console.log(`🔒 [PinUnlockDialog] show() called for device:`, props.deviceId); + + show({ + id: dialogId, + component: React.lazy(() => import('../components/PinUnlockDialog').then(m => ({ default: m.PinUnlockDialog }))), + props: { + isOpen: true, + deviceId: props.deviceId, + onUnlocked: () => { + console.log(`🔒 [PinUnlockDialog] Device unlocked successfully`); + if (props.onUnlocked) props.onUnlocked(); + hide(dialogId); + }, + onClose: () => { + console.log(`🔒 [PinUnlockDialog] Dialog closed`); + if (props.onDialogClose) props.onDialogClose(); + hide(dialogId); + } + }, + priority: 'critical', // Highest priority - PIN is needed for all operations + persistent: true, // User must enter PIN or explicitly close + }); + }, + hide: (deviceId: string) => hide(`pin-unlock-${deviceId}`), + isShowing: (deviceId: string) => isShowing(`pin-unlock-${deviceId}`), + }; } \ No newline at end of file diff --git a/projects/vault-v2/src/contexts/WalletContext.tsx b/projects/vault-v2/src/contexts/WalletContext.tsx index 4e804d4..3e20429 100644 --- a/projects/vault-v2/src/contexts/WalletContext.tsx +++ b/projects/vault-v2/src/contexts/WalletContext.tsx @@ -12,6 +12,7 @@ import { listen } from '@tauri-apps/api/event'; // Import organized types and services import { Asset, Portfolio, QueueStatus } from '../types'; import { PortfolioAPI, DeviceQueueAPI, PioneerAPI } from '../lib'; +import { usePinUnlockDialog } from './DialogContext'; const TAG = " | WalletContext | "; @@ -84,6 +85,9 @@ export const WalletProvider: React.FC = ({ children }) => { // Store fetched xpubs in memory (not database for v2) const [fetchedXpubs, setFetchedXpubs] = useState>([]); + + // PIN unlock dialog hook + const pinUnlockDialog = usePinUnlockDialog(); const refreshPortfolio = useCallback(async () => { const tag = TAG + " | refreshPortfolio | "; @@ -655,7 +659,31 @@ export const WalletProvider: React.FC = ({ children }) => { setLastReceiveAddress(addressResponse.address); // <-- context state } else { console.error(tag, `❌ Address request failed:`, addressResponse.error); - entry.reject(new Error(addressResponse.error || 'Device error')); + + // Check if the error is PIN-related + const errorMsg = (addressResponse.error || '').toLowerCase(); + if (errorMsg.includes('pin entry required') || errorMsg.includes('pin request has been triggered')) { + console.log(tag, '🔒 Device needs PIN unlock, showing PIN dialog'); + + // Get the device ID from the connected devices + const connectedDevices = await DeviceQueueAPI.getConnectedDevices(); + if (connectedDevices && connectedDevices.length > 0) { + const deviceId = getCanonicalDeviceId(connectedDevices[0]); + + // Show PIN unlock dialog + pinUnlockDialog.show({ + deviceId, + onUnlocked: () => { + console.log(tag, '🔓 Device unlocked, user should retry the address request'); + } + }); + } + + // Reject with a user-friendly error message + entry.reject(new Error('Device locked. Please enter your PIN and try again.')); + } else { + entry.reject(new Error(addressResponse.error || 'Device error')); + } } } @@ -761,6 +789,7 @@ export const WalletProvider: React.FC = ({ children }) => { useEffect(() => { let unlistenConnect: Promise<() => void>; let unlistenDisconnect: Promise<() => void>; + let unlistenPinRequest: Promise<() => void>; (async () => { // Handle device connections @@ -803,13 +832,33 @@ export const WalletProvider: React.FC = ({ children }) => { console.error(TAG, 'Failed to cleanup queue on disconnect:', e); } }); + + // Handle PIN request triggered events + unlistenPinRequest = listen('device:pin-request-triggered', async (event: any) => { + const tag = TAG + " | device:pin-request-triggered | "; + console.log(tag, '🔒 PIN request triggered event received:', event.payload); + + if (event.payload?.deviceId) { + const deviceId = event.payload.deviceId; + console.log(tag, '🔒 Showing PIN dialog for device:', deviceId); + + // Show PIN unlock dialog + pinUnlockDialog.show({ + deviceId, + onUnlocked: () => { + console.log(tag, '🔓 Device unlocked successfully'); + } + }); + } + }); })(); return () => { unlistenConnect?.then(fn => fn()); unlistenDisconnect?.then(fn => fn()); + unlistenPinRequest?.then(fn => fn()); }; - }, []); + }, [pinUnlockDialog]); // Watch fetchedXpubs and refresh portfolio when all expected xpubs are present useEffect(() => { diff --git a/projects/vault-v2/src/hooks/useCommonDialogs.tsx b/projects/vault-v2/src/hooks/useCommonDialogs.tsx index 8bea633..522099c 100644 --- a/projects/vault-v2/src/hooks/useCommonDialogs.tsx +++ b/projects/vault-v2/src/hooks/useCommonDialogs.tsx @@ -3,12 +3,14 @@ import { useCallback } from 'react'; import React from 'react'; import { OnboardingWizard } from '../components/OnboardingWizard/OnboardingWizard'; import { SetupWizard } from '../components/SetupWizard'; +import { NoDeviceDialog } from '../components/NoDeviceDialog'; // Import dialog components dynamically const dialogComponents = { onboarding: () => import('../components/OnboardingWizard/OnboardingWizard').then(m => ({ default: m.OnboardingWizard })), settings: () => import('../components/SettingsDialog').then(m => ({ default: m.SettingsDialog })), walletCreation: () => import('../components/WalletCreationWizard/WalletCreationWizard').then(m => ({ default: m.WalletCreationWizard })), + noDevice: () => import('../components/NoDeviceDialog').then(m => ({ default: m.NoDeviceDialog })), // Add more dialog imports as needed }; @@ -75,6 +77,18 @@ export function useCommonDialogs() { }, }); }, [show, hide, requestAppFocus, releaseAppFocus]); + + const showNoDevice = useCallback((props?: { + onRetry?: () => void; + }) => { + show({ + id: 'no-device-found', + component: NoDeviceDialog, + props, + priority: 'high', + persistent: false, + }); + }, [show]); // TODO: Implement these dialogs const showError = useCallback((title: string, message: string) => { @@ -91,6 +105,7 @@ export function useCommonDialogs() { showOnboarding, showSettings, showWalletCreation, + showNoDevice, showError, showConfirm, hideDialog: hide, From 9050ab593522d7c22f7efb204abca4fb67323762 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 3 Aug 2025 15:38:34 -0500 Subject: [PATCH 26/63] fix segwit inputs --- projects/vault-v2/README.md | 26 +- .../docs/bitcoin-transaction-signing-fix.md | 223 ++++++++++++++++++ projects/vault-v2/src-tauri/src/commands.rs | 40 +++- .../vault-v2/src-tauri/src/device/queue.rs | 66 +++--- projects/vault-v2/src/components/Send.tsx | 82 ++++++- projects/vault-v2/src/lib/api.ts | 39 +++ .../vault-v2/src/lib/createUnsignedUxtoTx.ts | 164 ++++++++++--- 7 files changed, 563 insertions(+), 77 deletions(-) create mode 100644 projects/vault-v2/docs/bitcoin-transaction-signing-fix.md diff --git a/projects/vault-v2/README.md b/projects/vault-v2/README.md index 987a8cb..b395be9 100644 --- a/projects/vault-v2/README.md +++ b/projects/vault-v2/README.md @@ -1,6 +1,28 @@ -# KeepKey Vault v2 +# KeepKey Vault V2 - Bitcoin Only Edition -A modern Tauri-based GUI application for KeepKey hardware wallets, built with clean architectural boundaries and efficient hardware communication. +A secure, modern desktop wallet application for KeepKey hardware wallets, focused exclusively on Bitcoin support. Built with Tauri, React, and Rust for optimal performance and security. + +## 🚀 Features + +- **Bitcoin-Only Focus**: Streamlined for Bitcoin transactions without altcoin distractions +- **Hardware Security**: Full integration with KeepKey hardware wallet +- **Modern Stack**: Built with Tauri 2.0, React, TypeScript, and Rust +- **Native Performance**: Desktop application with native OS integration +- **SegWit Support**: Full support for Legacy (P2PKH), Wrapped SegWit (P2SH-P2WPKH), and Native SegWit (P2WPKH) addresses +- **Real-time Updates**: Live portfolio tracking and transaction status +- **Secure Transaction Signing**: Device-based signing with proper UTXO validation + +## đŸŽ¯ Recent Major Fixes (January 2025) + +### Bitcoin Transaction Signing - "Invalid Prevhash 2" Resolution +Successfully resolved critical transaction signing issues that were preventing KeepKey from signing Bitcoin transactions. Key fixes included: + +- **SegWit Transaction Parsing**: Fixed parser to correctly handle SegWit transactions with witness data +- **Script Type Validation**: Only fetch previous transaction hex for legacy (P2PKH) inputs, not for SegWit inputs +- **Change Address Security**: Enforced native SegWit (BIP84) for all change addresses to prevent funds loss +- **API Migration**: Migrated from Blockstream to Mempool.space for improved reliability + +See [detailed documentation](docs/bitcoin-transaction-signing-fix.md) for complete technical details. ## đŸ—ī¸ **Architecture Overview** diff --git a/projects/vault-v2/docs/bitcoin-transaction-signing-fix.md b/projects/vault-v2/docs/bitcoin-transaction-signing-fix.md new file mode 100644 index 0000000..5114e81 --- /dev/null +++ b/projects/vault-v2/docs/bitcoin-transaction-signing-fix.md @@ -0,0 +1,223 @@ +# Bitcoin Transaction Signing: Invalid Prevhash Fix Documentation + +## Executive Summary + +This document details the resolution of the "Encountered invalid prevhash 2" error that was preventing KeepKey hardware wallets from signing Bitcoin transactions. The issue involved multiple interconnected problems across the JavaScript frontend, Rust backend, and transaction data handling. + +## The Problem + +When attempting to sign Bitcoin transactions, the KeepKey device would consistently return: +``` +Device communication error: Failure: Encountered invalid prevhash 2 +``` + +This error occurs when the device cannot properly validate the previous transaction data for security verification. + +## Root Causes Identified + +### 1. SegWit Transaction Format Mishandling + +**Issue**: The transaction hex being fetched from the blockchain was in SegWit format (containing witness data), but the parser was attempting to read it as a legacy transaction. + +**Example of problematic hex**: +``` +0100000000010192dc12975c6e4ceb7678e2972c6a300091d799ab94281a47adbc8bb673bfbf3b... +``` + +Breaking this down: +- `01000000` = version 1 +- `0001` = SegWit marker (0x00) and flag (0x01) +- The parser incorrectly interpreted `0001` as the input count + +**Solution**: Updated the transaction parser to: +- Detect SegWit marker and flag +- Properly skip witness data when present +- Correctly parse the actual input count + +### 2. Incorrect Script Type Requirements + +**Issue**: The system was attempting to fetch previous transaction hex for ALL inputs, but KeepKey only requires this for legacy (P2PKH) inputs. + +**HDWallet Behavior** (reference implementation): +- Legacy inputs (P2PKH): REQUIRE full previous transaction hex +- SegWit inputs (P2SH-P2WPKH, P2WPKH): Do NOT need hex + +**Solution**: +- Only fetch transaction hex for legacy (p2pkh) inputs +- Skip hex fetching for SegWit inputs (p2sh-p2wpkh, p2wpkh) + +### 3. Script Type Naming Inconsistency + +**Issue**: Frontend was using `p2sh` while backend expected `p2sh-p2wpkh`. + +**Solution**: Standardized script type names across the stack: +- `p2pkh` → Legacy +- `p2sh-p2wpkh` → SegWit wrapped in P2SH (not just `p2sh`) +- `p2wpkh` → Native SegWit + +### 4. Change Address Derivation Bug + +**Issue**: Change addresses were mixing derivation paths from different script types, potentially sending funds to addresses outside the gap limit. + +**Example of the bug**: +```javascript +// Wrong: Using P2SH change index with P2WPKH path +{ + "address_n_list": [2147483732, 2147483648, 2147483648, 1, 58], // Index 58 from P2SH + "script_type": "p2wpkh" // But using native SegWit type! +} +``` + +**Solution**: +- Always use native SegWit (p2wpkh) for change addresses +- Use correct BIP84 derivation path: `m/84'/0'/0'/1/x` +- Ensure change address index matches the script type + +## Implementation Details + +### Frontend Changes (TypeScript/React) + +#### 1. Transaction Building (`createUnsignedUxtoTx.ts`) + +```typescript +// Only fetch hex for legacy inputs +if (scriptType === 'p2pkh') { + // Legacy inputs REQUIRE the full previous transaction + hex = await PioneerAPI.getRawTransaction(hash); +} else { + // SegWit inputs (p2sh-p2wpkh, p2wpkh) do NOT need hex + console.log(`⚡ SegWit input detected (${scriptType}) - no hex needed`); +} +``` + +#### 2. Change Address Logic + +```typescript +// Always use native segwit (p2wpkh) for change addresses +const changeScriptType = 'p2wpkh'; +const changeXpub = relevantPubkeys.find(pk => pk.scriptType === 'p2wpkh')?.pubkey; +const path = `m/84'/0'/0'/1/${changeAddressIndex}`; // BIP84 path +``` + +### Backend Changes (Rust) + +#### 1. Transaction Parser (`commands.rs`) + +```rust +// Check for SegWit marker and flag +let mut is_segwit = false; +let input_count = { + let first_byte = read_varint(&mut cursor)?; + if first_byte == 0 { + // This might be SegWit marker (0x00) followed by flag (0x01) + let flag = read_u8(&mut cursor)?; + if flag == 1 { + is_segwit = true; + // Now read the actual input count + read_varint(&mut cursor)? + } + } else { + first_byte + } +}; + +// Skip witness data if present +if is_segwit { + for _ in 0..input_count { + let witness_count = read_varint(&mut cursor)?; + for _ in 0..witness_count { + let witness_len = read_varint(&mut cursor)? as usize; + // Skip witness bytes + let mut witness_data = vec![0u8; witness_len]; + read_exact(&mut cursor, &mut witness_data)?; + } + } +} +``` + +#### 2. Input Validation (`device/queue.rs`) + +```rust +// Only legacy (p2pkh) inputs require previous transaction hex +let needs_hex = input.script_type == "p2pkh"; + +if needs_hex && input.prev_tx_hex.is_none() { + return Err(format!("Legacy input {} missing required previous transaction hex", idx)); +} else if !needs_hex { + println!("⚡ SegWit input {} ({}): no hex required", idx, input.script_type); +} +``` + +## Testing & Verification + +### Test Scenarios + +1. **Legacy Input Transaction**: + - Input type: P2PKH + - Hex required: YES + - Result: ✅ Successfully signs + +2. **SegWit Input Transaction**: + - Input type: P2SH-P2WPKH or P2WPKH + - Hex required: NO + - Result: ✅ Successfully signs + +3. **Mixed Input Transaction**: + - Multiple input types + - Hex fetched only for legacy inputs + - Result: ✅ Successfully signs + +### Verification Steps + +1. Check that hex is only fetched for legacy inputs in browser console +2. Verify Rust backend logs show "SegWit input: no hex required" for non-legacy inputs +3. Confirm change addresses always use native SegWit (BIP84) paths +4. Validate successful transaction signing and broadcasting + +## Migration to Mempool.space API + +As part of this fix, we also migrated from Blockstream API to Mempool.space for improved reliability: + +```typescript +// Primary API +const response = await axios.get( + `https://mempool.space/api/tx/${txid}/hex` +); + +// Fallback to Blockstream if needed +if (error) { + const fallback = await axios.get( + `https://blockstream.info/api/tx/${txid}/hex` + ); +} +``` + +Benefits: +- Better uptime and reliability +- Richer transaction data +- More responsive API +- Better rate limits + +## Key Learnings + +1. **Hardware Wallet Security**: KeepKey validates previous transactions differently for legacy vs SegWit inputs as a security measure +2. **Transaction Format Evolution**: Bitcoin's SegWit upgrade added complexity that must be handled at the parsing level +3. **HDWallet Compatibility**: Following the HDWallet reference implementation patterns ensures device compatibility +4. **Script Type Consistency**: Maintaining consistent script type naming across the entire stack is crucial + +## References + +- [BIP141 - SegWit](https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki) +- [BIP84 - Native SegWit Addresses](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) +- [HDWallet Bitcoin Implementation](https://github.com/shapeshift/hdwallet/blob/master/packages/hdwallet-keepkey/src/bitcoin.ts) +- [KeepKey Protocol Documentation](https://github.com/keepkey/keepkey-firmware/wiki) + +## Conclusion + +The "invalid prevhash 2" error was caused by a combination of: +1. Incorrect SegWit transaction parsing +2. Unnecessary hex fetching for SegWit inputs +3. Script type naming inconsistencies +4. Change address derivation bugs + +All issues have been resolved, and the system now correctly handles both legacy and SegWit transactions according to KeepKey's security requirements. \ No newline at end of file diff --git a/projects/vault-v2/src-tauri/src/commands.rs b/projects/vault-v2/src-tauri/src/commands.rs index 79e1082..f7129b0 100644 --- a/projects/vault-v2/src-tauri/src/commands.rs +++ b/projects/vault-v2/src-tauri/src/commands.rs @@ -58,6 +58,7 @@ pub struct BitcoinUtxoInput { pub amount: String, // Amount in satoshis as string pub vout: u32, // Output index pub txid: String, // Transaction ID + #[serde(alias = "hex")] // Accept both "prev_tx_hex" and "hex" field names pub prev_tx_hex: Option, // Raw previous transaction hex } @@ -1433,8 +1434,24 @@ pub fn parse_transaction_from_hex(hex_data: &str) -> Result<((u32, u32, u32, u32 // Parse version (4 bytes, little-endian) let version = read_u32_le(&mut cursor)?; - // Parse input count (varint) - let input_count = read_varint(&mut cursor)?; + // Check for SegWit marker and flag + let mut is_segwit = false; + let input_count = { + let first_byte = read_varint(&mut cursor)?; + if first_byte == 0 { + // This might be SegWit marker (0x00) followed by flag (0x01) + let flag = read_u8(&mut cursor)?; + if flag == 1 { + is_segwit = true; + // Now read the actual input count + read_varint(&mut cursor)? + } else { + return Err("Invalid transaction format: unexpected marker/flag".to_string()); + } + } else { + first_byte + } + }; // Parse inputs let mut inputs = Vec::new(); @@ -1494,12 +1511,31 @@ pub fn parse_transaction_from_hex(hex_data: &str) -> Result<((u32, u32, u32, u32 }); } + // If this is a SegWit transaction, skip witness data + if is_segwit { + // Skip witness data for each input + for _ in 0..input_count { + let witness_count = read_varint(&mut cursor)?; + for _ in 0..witness_count { + let witness_len = read_varint(&mut cursor)? as usize; + let mut witness_data = vec![0u8; witness_len]; + read_exact(&mut cursor, &mut witness_data)?; + } + } + } + // Parse lock time (4 bytes, little-endian) let lock_time = read_u32_le(&mut cursor)?; Ok(((version, input_count as u32, output_count as u32, lock_time), inputs, outputs)) } +fn read_u8(cursor: &mut Cursor>) -> Result { + let mut buf = [0u8; 1]; + read_exact(cursor, &mut buf)?; + Ok(buf[0]) +} + fn read_u32_le(cursor: &mut Cursor>) -> Result { let mut buf = [0u8; 4]; read_exact(cursor, &mut buf)?; diff --git a/projects/vault-v2/src-tauri/src/device/queue.rs b/projects/vault-v2/src-tauri/src/device/queue.rs index b48e687..de91b3c 100644 --- a/projects/vault-v2/src-tauri/src/device/queue.rs +++ b/projects/vault-v2/src-tauri/src/device/queue.rs @@ -323,38 +323,48 @@ pub async fn add_to_device_queue( // Build transaction map with previous transactions and unsigned transaction let mut tx_map = std::collections::HashMap::new(); - // Cache previous transactions + // Cache previous transactions (only required for legacy inputs) for (idx, input) in inputs.iter().enumerate() { + // Only legacy (p2pkh) inputs require previous transaction hex + // SegWit inputs (p2sh, p2sh-p2wpkh, p2wpkh) do NOT need hex + let needs_hex = input.script_type == "p2pkh"; + if let Some(hex_data) = &input.prev_tx_hex { - let tx_hash = hex::decode(&input.txid).map_err(|e| format!("Invalid txid hex: {}", e))?; - let tx_hash_hex = hex::encode(&tx_hash); - - // Parse the previous transaction from hex - match parse_transaction_from_hex(hex_data) { - Ok((metadata, tx_inputs, tx_outputs)) => { - let tx = keepkey_rust::messages::TransactionType { - version: Some(metadata.0), - lock_time: Some(metadata.3), - inputs_cnt: Some(metadata.1), - outputs_cnt: Some(metadata.2), - inputs: tx_inputs, - bin_outputs: tx_outputs, - outputs: vec![], - extra_data: None, - extra_data_len: Some(0), - ..Default::default() - }; - tx_map.insert(tx_hash_hex.clone(), tx); - println!("✅ Cached previous transaction: {} (v{}, {} inputs, {} outputs)", - tx_hash_hex, metadata.0, metadata.1, metadata.2); - } - Err(e) => { - eprintln!("âš ī¸ Failed to parse previous transaction for input {}: {}", idx, e); - return Err(format!("Failed to parse previous transaction for input {}: {}", idx, e)); + if !hex_data.is_empty() { + let tx_hash = hex::decode(&input.txid).map_err(|e| format!("Invalid txid hex: {}", e))?; + let tx_hash_hex = hex::encode(&tx_hash); + + // Parse the previous transaction from hex + match parse_transaction_from_hex(hex_data) { + Ok((metadata, tx_inputs, tx_outputs)) => { + let tx = keepkey_rust::messages::TransactionType { + version: Some(metadata.0), + lock_time: Some(metadata.3), + inputs_cnt: Some(metadata.1), + outputs_cnt: Some(metadata.2), + inputs: tx_inputs, + bin_outputs: tx_outputs, + outputs: vec![], + extra_data: None, + extra_data_len: Some(0), + ..Default::default() + }; + tx_map.insert(tx_hash_hex.clone(), tx); + println!("✅ Cached previous transaction for legacy input: {} (v{}, {} inputs, {} outputs)", + tx_hash_hex, metadata.0, metadata.1, metadata.2); + } + Err(e) => { + eprintln!("âš ī¸ Failed to parse previous transaction for input {}: {}", idx, e); + return Err(format!("Failed to parse previous transaction for input {}: {}", idx, e)); + } } + } else if needs_hex { + return Err(format!("Legacy input {} missing required previous transaction hex", idx)); } + } else if needs_hex { + return Err(format!("Legacy input {} missing required previous transaction hex", idx)); } else { - return Err(format!("Input {} missing previous transaction hex", idx)); + println!("⚡ SegWit input {} ({}): no hex required", idx, input.script_type); } } @@ -363,7 +373,7 @@ pub async fn add_to_device_queue( for input in inputs { let script_type = match input.script_type.as_str() { "p2pkh" => keepkey_rust::messages::InputScriptType::Spendaddress, - "p2sh-p2wpkh" => keepkey_rust::messages::InputScriptType::Spendp2shwitness, + "p2sh" | "p2sh-p2wpkh" => keepkey_rust::messages::InputScriptType::Spendp2shwitness, "p2wpkh" => keepkey_rust::messages::InputScriptType::Spendwitness, _ => keepkey_rust::messages::InputScriptType::Spendaddress, }; diff --git a/projects/vault-v2/src/components/Send.tsx b/projects/vault-v2/src/components/Send.tsx index a4d1fad..1baea8a 100644 --- a/projects/vault-v2/src/components/Send.tsx +++ b/projects/vault-v2/src/components/Send.tsx @@ -404,21 +404,63 @@ console.debug('[Send] deviceId from device.unique_id:', deviceId); amount: input.amount, vout: input.vout, txid: input.txid, - prev_tx_hex: input.hex + hex: input.hex // Device expects 'hex' field name })); const realOutputs = unsignedTx.outputs.map((output: any) => ({ - address: output.address, + address: output.address || '', // Ensure address is always a string amount: parseInt(output.amount), address_type: output.addressType === 'change' ? 'change' : 'spend', - script_type: output.scriptType || 'p2pkh', - address_n_list: output.addressNList + is_change: output.addressType === 'change' ? true : false, + address_n_list: output.addressNList || null, + script_type: output.scriptType || 'p2pkh' })); + // Debug log the outputs being sent + console.log('📤 Outputs being sent to device:', realOutputs); + realOutputs.forEach((output: any, idx: number) => { + console.log(` Output ${idx + 1}:`, { + address: output.address, + amount: output.amount, + address_type: output.address_type, + is_change: output.is_change, + has_address_n_list: !!output.address_n_list, + script_type: output.script_type + }); + }); + // Sign the transaction using real device with properly selected UTXOs console.log('🔐 Calling signTransaction with device queue...'); console.log('🔐 Request ID will be:', `sign_tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + // Log the EXACT payload being sent to the device + console.log('📝 COMPLETE SIGN PAYLOAD TO DEVICE:'); + console.log('=========================================='); + console.log(JSON.stringify({ + coin: unsignedTx.coin, + inputs: realInputs, + outputs: realOutputs, + version: unsignedTx.version, + locktime: unsignedTx.locktime + }, null, 2)); + console.log('=========================================='); + + // Also log each input individually for detailed inspection + console.log('🔍 DETAILED INPUT INSPECTION:'); + realInputs.forEach((input: any, index: number) => { + console.log(`Input ${index + 1} Full Details:`); + console.log(' - txid:', input.txid); + console.log(' - vout:', input.vout); + console.log(' - amount:', input.amount); + console.log(' - script_type:', input.script_type); + console.log(' - address_n_list:', JSON.stringify(input.address_n_list)); + console.log(' - hex present?:', !!input.hex); + console.log(' - hex length:', input.hex ? input.hex.length : 0); + console.log(' - hex first 100 chars:', input.hex ? input.hex.substring(0, 100) : 'NO HEX'); + console.log(' - hex last 20 chars:', input.hex ? input.hex.substring(input.hex.length - 20) : 'NO HEX'); + console.log(' - All fields:', Object.keys(input)); + }); + let signedTxHex: string; try { signedTxHex = await signTransaction( @@ -719,20 +761,30 @@ console.debug('[Send] deviceId from device.unique_id:', deviceId); {(['slow', 'medium', 'fast'] as const).map((preset) => { const presetFeeRate = feeRates[preset]; + const isSelected = feeRate === preset; return ( ); })} @@ -793,11 +845,17 @@ console.debug('[Send] deviceId from device.unique_id:', deviceId); Est. Fee: - {transactionReview.fee.toFixed(8)} BTC + + {transactionReview.fee.toFixed(8)} BTC + ≈ ${convertBtcToUsd(transactionReview.fee).toFixed(2)} USD + Total: - {transactionReview.total.toFixed(8)} BTC + + {transactionReview.total.toFixed(8)} BTC + ≈ ${convertBtcToUsd(transactionReview.total).toFixed(2)} USD + @@ -929,16 +987,16 @@ console.debug('[Send] deviceId from device.unique_id:', deviceId); onClick={async () => { try { const { invoke } = await import('@tauri-apps/api/core'); - await invoke('open_url', { url: `https://blockstream.info/tx/${txid}` }); + await invoke('open_url', { url: `https://mempool.space/tx/${txid}` }); } catch (error) { console.error('Failed to open URL:', error); // Fallback to window.open if Tauri command fails - window.open(`https://blockstream.info/tx/${txid}`, '_blank'); + window.open(`https://mempool.space/tx/${txid}`, '_blank'); } }} flex="1" > - 🔗 View on Blockstream + 🔗 View on Mempool diff --git a/projects/vault-v2/src/lib/api.ts b/projects/vault-v2/src/lib/api.ts index 4805bf9..16b5868 100644 --- a/projects/vault-v2/src/lib/api.ts +++ b/projects/vault-v2/src/lib/api.ts @@ -175,6 +175,45 @@ export class PioneerAPI { } } + static async getRawTransaction(txid: string): Promise { + try { + console.log('🔍 Fetching raw transaction for txid:', txid); + + // Using mempool.space API - it's more reliable and feature-rich than blockstream + // mempool.space/api/tx/{txid}/hex returns the raw transaction hex + const response = await axios.get( + `https://mempool.space/api/tx/${txid}/hex`, + { + headers: { 'accept': 'text/plain' }, + timeout: 10000 + } + ); + + console.log('✅ Raw transaction hex retrieved from mempool.space, length:', response.data.length); + return response.data; + } catch (error) { + console.error('❌ Failed to get raw transaction from mempool.space:', error); + + // Fallback to blockstream.info if mempool.space fails + try { + console.log('🔄 Falling back to blockstream.info...'); + const fallbackResponse = await axios.get( + `https://blockstream.info/api/tx/${txid}/hex`, + { + headers: { 'accept': 'text/plain' }, + timeout: 10000 + } + ); + console.log('✅ Raw transaction hex retrieved from blockstream fallback, length:', fallbackResponse.data.length); + return fallbackResponse.data; + } catch (fallbackError) { + console.error('❌ Fallback also failed:', fallbackError); + // Return empty string if we can't get the tx - device will fail gracefully + return ''; + } + } + } + static async broadcastTransaction(networkId: string, serializedTx: string): Promise<{ txid: string }> { try { console.log('📡 Broadcasting transaction to network:', networkId); diff --git a/projects/vault-v2/src/lib/createUnsignedUxtoTx.ts b/projects/vault-v2/src/lib/createUnsignedUxtoTx.ts index ea3f95c..a2cbec9 100644 --- a/projects/vault-v2/src/lib/createUnsignedUxtoTx.ts +++ b/projects/vault-v2/src/lib/createUnsignedUxtoTx.ts @@ -3,6 +3,7 @@ import { bip32ToAddressNList } from '@pioneer-platform/pioneer-coins'; import coinSelect from 'coinselect'; import coinSelectSplit from 'coinselect/split'; +import { PioneerAPI } from './api'; export async function createUnsignedUxtoTx( caip: string, @@ -31,25 +32,6 @@ export async function createUnsignedUxtoTx( let chain = 'Bitcoin' - let changeAddressIndex = await pioneer.GetChangeAddress({ - network: chain, - xpub: relevantPubkeys[0].pubkey || relevantPubkeys[0].xpub, - }); - changeAddressIndex = changeAddressIndex.data.changeIndex; - - //todo DerivationPath is currently configued path - let DerivationPath= { - BTC: "m/84'/0'/0'/0/0", - } - const path = "m/84'/0'/0'/0/0".replace('/0/0', `/1/${changeAddressIndex}`); - - const changeAddress = { - path: path, - isChange: true, - index: changeAddressIndex, - addressNList: bip32ToAddressNList(path), - }; - const utxos: any[] = []; for (const pubkey of relevantPubkeys) { //console.log('pubkey: ',pubkey) @@ -61,6 +43,7 @@ export async function createUnsignedUxtoTx( // Assign the scriptType to each UTXO in the array for (const u of utxosResp) { u.scriptType = scriptType; + u.xpub = pubkey.pubkey; // Store the xpub with the UTXO for change address selection } utxos.push(...utxosResp); } @@ -190,26 +173,112 @@ export async function createUnsignedUxtoTx( } if (fee === undefined) throw Error('Failed to calculate transaction fee'); + + // CRITICAL FIX: Always use native segwit (p2wpkh) for change addresses + // This ensures we use the most modern and fee-efficient address type + console.log('🔐 Setting up change address with native segwit (p2wpkh)...'); + + // Always use p2wpkh for change regardless of input types + const changeScriptType = 'p2wpkh'; + + // Find the p2wpkh xpub (zpub), or fall back to the first available + const changeXpub = relevantPubkeys.find(pk => pk.scriptType === 'p2wpkh')?.pubkey || relevantPubkeys[0].pubkey; + + console.log(`🔐 Using script type for change: ${changeScriptType} (native segwit)`); + console.log(`🔐 Change xpub selected:`, changeXpub?.substring(0, 10) + '...'); + + // Now get change address with the CORRECT script type + let changeAddressIndex = await pioneer.GetChangeAddress({ + network: chain, + xpub: changeXpub, + }); + changeAddressIndex = changeAddressIndex.data.changeIndex; + + // Always use native segwit path for change address (BIP84) + // m/84'/0'/0'/1/x for mainnet native segwit change addresses + const path = `m/84'/0'/0'/1/${changeAddressIndex}`; + + console.log(`🔐 Change address path: ${path} (index: ${changeAddressIndex})`); + + const changeAddress = { + path: path, + isChange: true, + index: changeAddressIndex, + addressNList: bip32ToAddressNList(path), + scriptType: changeScriptType, // Use the correct script type + }; const uniqueInputSet = new Set(); //console.log(tag,'inputs:', inputs); //console.log(tag,'inputs:', inputs[0]); - const preparedInputs = inputs + + // First prepare inputs without hex + const inputsWithoutHex = inputs .map(transformInput) .filter(({ hash, index }) => uniqueInputSet.has(`${hash}:${index}`) ? false : uniqueInputSet.add(`${hash}:${index}`), - ) - .map(({ value, index, hash, txHex, path, scriptType }) => ({ - addressNList: bip32ToAddressNList(path), - //TODO this is PER INPUT not per asset, we need to detect what pubkeys are segwit what are not - scriptType, - amount: value.toString(), - vout: index, - txid: hash, - hex: txHex || '', - })); + ); + + // Fetch previous transaction hex for each input + console.log('🔍 Preparing inputs with script type awareness...'); + console.log(`🔍 inputsWithoutHex has ${inputsWithoutHex.length} inputs to process`); + + const preparedInputs = await Promise.all( + inputsWithoutHex.map(async ({ value, index, hash, txHex, path, scriptType }) => { + // CRITICAL: Only legacy (p2pkh) inputs need the previous transaction hex + // SegWit inputs (p2sh-p2wpkh, p2wpkh) do NOT need and should NOT have hex + let hex = ''; + + if (scriptType === 'p2pkh') { + // Legacy inputs REQUIRE the full previous transaction + try { + console.log(`🔍 Legacy input detected (${scriptType}) for ${hash}:${index} - fetching hex...`); + hex = await PioneerAPI.getRawTransaction(hash); + console.log(`✅ Got hex for legacy input ${hash}:${index}, length: ${hex.length}`); + + // Log first few bytes of hex to verify it's valid + if (hex && hex.length > 0) { + console.log(`🔍 Hex preview for ${hash}: ${hex.substring(0, 20)}...`); + } else { + console.warn(`âš ī¸ Empty or invalid hex returned for ${hash}`); + } + } catch (error) { + console.error(`❌ Failed to get hex for legacy input ${hash}:${index}`, error); + // Try to use the provided txHex as fallback + hex = txHex || ''; + console.log(`🔍 Using fallback hex for ${hash}, length: ${hex.length}`); + } + } else { + // SegWit inputs (p2sh-p2wpkh, p2wpkh) should NOT have hex + console.log(`⚡ SegWit input detected (${scriptType}) for ${hash}:${index} - no hex needed`); + } + + const preparedInput = { + addressNList: bip32ToAddressNList(path), + scriptType, + amount: value.toString(), + vout: index, + txid: hash, + hex: hex, // Will be empty string for SegWit inputs + }; + + console.log(`đŸ“Ļ Prepared input for ${hash}:${index}:`, { + scriptType: preparedInput.scriptType, + amount: preparedInput.amount, + vout: preparedInput.vout, + txid: preparedInput.txid, + hexLength: preparedInput.hex.length, + hasHex: preparedInput.hex.length > 0, + needsHex: scriptType === 'p2pkh' + }); + + return preparedInput; + }) + ); + + console.log(`✅ All ${preparedInputs.length} inputs prepared with hex data`); - const scriptType = isSegwit ? 'p2wpkh' : 'p2sh'; + // Remove the old scriptType determination - we now use changeAddress.scriptType const preparedOutputs = outputs .map(({ value, address }) => { @@ -218,7 +287,7 @@ export async function createUnsignedUxtoTx( } else if (!isMax) { return { addressNList: changeAddress.addressNList, - scriptType, + scriptType: changeAddress.scriptType, // Use the correct script type from change address isChange: true, amount: value.toString(), addressType: 'change', @@ -248,6 +317,35 @@ export async function createUnsignedUxtoTx( if (unsignedTx.memo && unsignedTx.memo !== ' ') { signPayload.opReturnData = unsignedTx.memo; } + + // Debug log the complete payload + console.log('📤 Final sign payload being sent to device:'); + console.log(' - Coin:', signPayload.coin); + console.log(' - Inputs:', signPayload.inputs.length); + signPayload.inputs.forEach((input: any, i: number) => { + console.log(` Input ${i + 1}:`, { + txid: input.txid, + vout: input.vout, + amount: input.amount, + scriptType: input.scriptType, + addressNList: input.addressNList, + hasHex: input.hex && input.hex.length > 0, + hexLength: input.hex ? input.hex.length : 0, + hexPreview: input.hex ? input.hex.substring(0, 20) + '...' : 'NO HEX' + }); + }); + console.log(' - Outputs:', signPayload.outputs.length); + signPayload.outputs.forEach((output: any, i: number) => { + console.log(` Output ${i + 1}:`, { + address: output.address || 'CHANGE', + amount: output.amount, + isChange: output.isChange || false, + addressType: output.addressType, + scriptType: output.scriptType, + addressNList: output.addressNList + }); + }); + return signPayload; } catch (error) { //console.log(tag, 'Error:', error); @@ -293,7 +391,7 @@ function getScriptTypeFromXpub(xpub: string): string { if (xpub.startsWith('xpub')) { return 'p2pkh'; // Legacy } else if (xpub.startsWith('ypub')) { - return 'p2sh'; // P2WPKH nested in P2SH + return 'p2sh-p2wpkh'; // P2WPKH nested in P2SH (compatible with Rust backend) } else if (xpub.startsWith('zpub')) { return 'p2wpkh'; // Native SegWit } else { From 0e29b79c4767956a9a39360cb74c3d843100b257 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 3 Aug 2025 16:39:30 -0500 Subject: [PATCH 27/63] USB optimized works --- projects/vault-v2/src-tauri/Cargo.toml | 4 + projects/vault-v2/src-tauri/src/commands.rs | 39 +- .../vault-v2/src-tauri/src/device/queue.rs | 77 +- .../src-tauri/src/event_controller.rs | 15 +- projects/vault-v2/src-tauri/src/logging.rs | 35 +- projects/vault-v2/src-tauri/src/server/mod.rs | 59 +- .../vault-v2/src-tauri/src/server/proxy.rs | 687 ++++++++++++++++++ 7 files changed, 869 insertions(+), 47 deletions(-) create mode 100644 projects/vault-v2/src-tauri/src/server/proxy.rs diff --git a/projects/vault-v2/src-tauri/Cargo.toml b/projects/vault-v2/src-tauri/Cargo.toml index b615056..dbb761c 100644 --- a/projects/vault-v2/src-tauri/Cargo.toml +++ b/projects/vault-v2/src-tauri/Cargo.toml @@ -48,5 +48,9 @@ utoipa-axum = "0.2.0" utoipa-swagger-ui = { version = "5", features = ["axum", "debug-embed"] } once_cell = "1.18.0" tauri-plugin-process = "2" +# Proxy dependencies +reqwest = { version = "0.11", features = ["json", "stream"] } +url = "2.4" +regex = "1.10" # Note: rusb removed - handled internally by keepkey-rust diff --git a/projects/vault-v2/src-tauri/src/commands.rs b/projects/vault-v2/src-tauri/src/commands.rs index f7129b0..2685f64 100644 --- a/projects/vault-v2/src-tauri/src/commands.rs +++ b/projects/vault-v2/src-tauri/src/commands.rs @@ -15,7 +15,8 @@ use std::path::PathBuf; use std::fs; use serde_json::Value; use log; -use std::time::Duration; +use std::time::{Duration, Instant}; +use once_cell::sync::Lazy; pub type DeviceQueueManager = Arc>>; @@ -339,6 +340,21 @@ pub async fn get_device_status( device_id: String, queue_manager: State<'_, DeviceQueueManager>, ) -> Result, String> { + // Rate limit status checks - ignore rapid duplicate requests + static LAST_STATUS_CHECK: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(|| Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()))); + + { + let mut last_checks = LAST_STATUS_CHECK.lock().await; + if let Some(last_check) = last_checks.get(&device_id) { + if last_check.elapsed() < Duration::from_millis(500) { + // Skip if checked within last 500ms + return Ok(None); + } + } + last_checks.insert(device_id.clone(), std::time::Instant::now()); + } + println!("Getting device status for: {}", device_id); let request_id = uuid::Uuid::new_v4().to_string(); @@ -383,7 +399,7 @@ pub async fn get_device_status( println!("🔄 Attempting to get features for device {} (attempt {}/3)", device_id, attempt); match tokio::time::timeout( - Duration::from_secs(30), // 30 seconds per attempt + Duration::from_secs(10), // Reduced to 10 seconds per attempt for faster retries queue_handle.get_features() ).await { Ok(Ok(raw_features)) => { @@ -393,12 +409,23 @@ pub async fn get_device_status( break; } Ok(Err(e)) => { - println!("âš ī¸ Failed to get features for device {} on attempt {}: {}", device_id, attempt, e); - last_error = Some(format!("Failed to get features: {}", e)); + let error_detail = format!("{:?}", e); + println!("âš ī¸ Failed to get features for device {} on attempt {}: {}", device_id, attempt, error_detail); + + // Check for specific error conditions + if error_detail.contains("PinRequired") || error_detail.contains("pin") { + last_error = Some("Device requires PIN unlock".to_string()); + } else if error_detail.contains("Bootloader") || error_detail.contains("bootloader") { + last_error = Some("Device is in bootloader mode".to_string()); + } else if error_detail.contains("Busy") || error_detail.contains("busy") { + last_error = Some("Device is busy, please wait".to_string()); + } else { + last_error = Some(format!("Device error: {}", e)); + } } Err(_) => { - println!("âąī¸ Timeout getting features for device {} on attempt {}", device_id, attempt); - last_error = Some("Timeout getting features".to_string()); + println!("âąī¸ Timeout getting features for device {} on attempt {} (10s timeout)", device_id, attempt); + last_error = Some("Device operation timed out".to_string()); } } diff --git a/projects/vault-v2/src-tauri/src/device/queue.rs b/projects/vault-v2/src-tauri/src/device/queue.rs index de91b3c..559a34e 100644 --- a/projects/vault-v2/src-tauri/src/device/queue.rs +++ b/projects/vault-v2/src-tauri/src/device/queue.rs @@ -77,37 +77,62 @@ pub async fn add_to_device_queue( }; // ------------------------------------------------------------------ - // -------------------------------------------------------------- + // Check if device is in PIN flow BEFORE doing anything else + // ------------------------------------------------------------------ + if crate::commands::is_device_in_pin_flow(&request.device_id) { + // Don't interrupt PIN flow with ANY device operations + match &request.request { + DeviceRequest::GetXpub { .. } | + DeviceRequest::GetAddress { .. } | + DeviceRequest::SignTransaction { .. } => { + println!("đŸšĢ Blocking request during PIN flow - device is entering PIN"); + return Err("Device is currently in PIN entry mode. Please complete PIN entry first.".to_string()); + }, + _ => { + // Allow GetFeatures and SendRaw during PIN flow as they might be needed + } + } + } + + // ------------------------------------------------------------------ // Pre-flight status check – ensure the device can service this request // ------------------------------------------------------------------ - // We fetch the current features via the queue (which opens a temporary - // transport) so that we have accurate mode/version information. - let raw_features_opt = match keepkey_rust::device_queue::DeviceQueueHandle::get_features(&queue_handle).await { - Ok(f) => { - // Successfully got features, update cache - let mut cache = DEVICE_STATE_CACHE.write().await; - cache.insert(request.device_id.clone(), DeviceStateCache { - is_oob_bootloader: false, - last_features: Some(f.clone()), - last_update: std::time::Instant::now(), - }); - Some(f) - }, - Err(e) => { - eprintln!("âš ī¸ Unable to fetch features for status check: {e}"); - - // Check if we have cached state for this device - let cache = DEVICE_STATE_CACHE.read().await; - if let Some(cached_state) = cache.get(&request.device_id) { - // If we know this is an OOB bootloader from a previous successful check - if cached_state.is_oob_bootloader { - println!("📋 Using cached OOB bootloader state for device {}", request.device_id); - cached_state.last_features.clone() + // Skip GetFeatures if device is in PIN flow to avoid interrupting the PIN screen + let raw_features_opt = if crate::commands::is_device_in_pin_flow(&request.device_id) { + println!("âš ī¸ Skipping GetFeatures check - device is in PIN flow"); + // Check cache for last known features + let cache = DEVICE_STATE_CACHE.read().await; + cache.get(&request.device_id).and_then(|state| state.last_features.clone()) + } else { + // We fetch the current features via the queue (which opens a temporary + // transport) so that we have accurate mode/version information. + match keepkey_rust::device_queue::DeviceQueueHandle::get_features(&queue_handle).await { + Ok(f) => { + // Successfully got features, update cache + let mut cache = DEVICE_STATE_CACHE.write().await; + cache.insert(request.device_id.clone(), DeviceStateCache { + is_oob_bootloader: false, + last_features: Some(f.clone()), + last_update: std::time::Instant::now(), + }); + Some(f) + }, + Err(e) => { + eprintln!("âš ī¸ Unable to fetch features for status check: {e}"); + + // Check if we have cached state for this device + let cache = DEVICE_STATE_CACHE.read().await; + if let Some(cached_state) = cache.get(&request.device_id) { + // If we know this is an OOB bootloader from a previous successful check + if cached_state.is_oob_bootloader { + println!("📋 Using cached OOB bootloader state for device {}", request.device_id); + cached_state.last_features.clone() + } else { + None + } } else { None } - } else { - None } } }; diff --git a/projects/vault-v2/src-tauri/src/event_controller.rs b/projects/vault-v2/src-tauri/src/event_controller.rs index cc8754c..b4fac11 100644 --- a/projects/vault-v2/src-tauri/src/event_controller.rs +++ b/projects/vault-v2/src-tauri/src/event_controller.rs @@ -77,6 +77,17 @@ impl EventController { // Check for newly connected devices for device in ¤t_devices { if !last_devices.iter().any(|d| d.unique_id == device.unique_id) { + // Check if this is a duplicate of an already connected device + let is_duplicate = current_devices.iter().any(|other| { + other.unique_id != device.unique_id && + crate::commands::are_devices_potentially_same(&device.unique_id, &other.unique_id) + }); + + if is_duplicate { + println!("âš ī¸ Skipping duplicate device: {} (already connected with different ID)", device.unique_id); + continue; + } + println!("🔌 Device connected: {} (VID: 0x{:04x}, PID: 0x{:04x})", device.unique_id, device.vid, device.pid); println!(" Device info: {} - {}", @@ -126,6 +137,8 @@ impl EventController { let app_for_task = app_handle.clone(); let device_for_task = device.clone(); tokio::spawn(async move { + // Give device a moment to settle after connection + tokio::time::sleep(Duration::from_millis(500)).await; println!("📡 Fetching device features for: {}", device_for_task.unique_id); // Emit getting features status @@ -474,7 +487,7 @@ async fn try_get_device_features(device: &FriendlyUsbDevice, app_handle: &AppHan return Err("Device entered PIN flow during feature fetch".to_string()); } - match tokio::time::timeout(Duration::from_secs(30), queue_handle.get_features()).await { + match tokio::time::timeout(Duration::from_secs(5), queue_handle.get_features()).await { Ok(Ok(raw_features)) => { println!("✅ Successfully got features for device {} on attempt {}", device.unique_id, attempt); // Convert features to our DeviceFeatures format diff --git a/projects/vault-v2/src-tauri/src/logging.rs b/projects/vault-v2/src-tauri/src/logging.rs index 78fd527..d21b234 100644 --- a/projects/vault-v2/src-tauri/src/logging.rs +++ b/projects/vault-v2/src-tauri/src/logging.rs @@ -138,15 +138,36 @@ impl DeviceLogger { /// Write a log entry to the current log file async fn write_log_entry(&self, log_entry: &serde_json::Value) -> Result<(), String> { - let mut file = self.get_current_log_file().await?; + let current_date = Self::get_current_date(); - // Write the log entry as a JSON line - writeln!(file, "{}", serde_json::to_string(log_entry).unwrap()) - .map_err(|e| format!("Failed to write log entry: {}", e))?; + // Hold both locks for the entire write operation to prevent interleaving + let mut current_date_lock = self.current_date.lock().await; + let mut current_log_file_lock = self.current_log_file.lock().await; - // Flush to ensure it's written immediately - file.flush() - .map_err(|e| format!("Failed to flush log file: {}", e))?; + // Check if we need to create a new log file (new day or first time) + if *current_date_lock != current_date || current_log_file_lock.is_none() { + let log_file_path = self.logs_dir.join(format!("device-communications-{}.log", current_date)); + + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path) + .map_err(|e| format!("Failed to open log file: {}", e))?; + + *current_date_lock = current_date; + *current_log_file_lock = Some(file); + } + + // Write to the file while holding the lock + if let Some(ref mut file) = *current_log_file_lock { + // Write the log entry as a JSON line + writeln!(file, "{}", serde_json::to_string(log_entry).unwrap()) + .map_err(|e| format!("Failed to write log entry: {}", e))?; + + // Flush to ensure it's written immediately + file.flush() + .map_err(|e| format!("Failed to flush log file: {}", e))?; + } Ok(()) } diff --git a/projects/vault-v2/src-tauri/src/server/mod.rs b/projects/vault-v2/src-tauri/src/server/mod.rs index 1e331a9..68d170e 100644 --- a/projects/vault-v2/src-tauri/src/server/mod.rs +++ b/projects/vault-v2/src-tauri/src/server/mod.rs @@ -1,15 +1,17 @@ pub mod routes; pub mod context; +pub mod proxy; use axum::{ Router, serve, routing::{get, post}, + response::Json, }; use tokio::net::TcpListener; use tower_http::cors::CorsLayer; -use tracing::info; +use tracing::{info, debug}; use std::sync::Arc; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -78,6 +80,11 @@ pub async fn start_server(device_queue_manager: crate::commands::DeviceQueueMana // System endpoints .route("/api/health", get(routes::health_check)) + // Add compatibility route for Pioneer SDK kkapi detection + .route("/spec/swagger.json", get(|| async move { + Json(ApiDoc::openapi()) + })) + // Context endpoints - commented out until full device interaction is implemented // .route("/api/context", get(routes::api_get_context)) // .route("/api/context", post(routes::api_set_context)) @@ -94,19 +101,57 @@ pub async fn start_server(device_queue_manager: crate::commands::DeviceQueueMana .merge(swagger_ui) // Then add state and middleware .with_state(server_state) - .layer(CorsLayer::permissive()); + .layer( + CorsLayer::new() + // Allow any origin with wildcard + .allow_origin(tower_http::cors::Any) + // Allow all methods + .allow_methods(tower_http::cors::Any) + // Allow all headers + .allow_headers(tower_http::cors::Any) + // Note: credentials cannot be used with wildcard origin + .allow_credentials(false) + ); let addr = "127.0.0.1:1646"; let listener = TcpListener::bind(addr).await?; - info!("🚀 Server started successfully:"); + // Start the proxy server on port 8080 + let proxy_addr = "127.0.0.1:8080"; + let proxy_app = proxy::create_proxy_router(); + let proxy_listener = TcpListener::bind(proxy_addr).await?; + + info!("🚀 Starting servers:"); info!(" 📋 REST API: http://{}/api", addr); + info!(" 🌍 Vault Proxy: http://{} -> vault.keepkey.com", proxy_addr); info!(" 📚 API Documentation: http://{}/docs", addr); - info!(" 🔌 Device Management: http://{}/api/devices", addr); - info!(" 🤖 MCP Endpoint: http://{}/mcp", addr); + debug!(" 🔌 Device Management: http://{}/api/devices", addr); + debug!(" 🤖 MCP Endpoint: http://{}/mcp", addr); + debug!(" 📄 Swagger JSON: http://{}/spec/swagger.json", addr); + + // Start the proxy server in a separate task + let proxy_handle = tokio::spawn(async move { + serve(proxy_listener, proxy_app).await + }); - // Spawn the server - serve(listener, app).await?; + // Small delay to let proxy server start + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + info!("✅ Both servers started successfully and are ready"); + + // Run both servers concurrently + tokio::select! { + result = serve(listener, app) => { + if let Err(e) = result { + tracing::error!("API server error: {}", e); + } + } + result = proxy_handle => { + if let Err(e) = result { + tracing::error!("Proxy server error: {}", e); + } + } + } Ok(()) } \ No newline at end of file diff --git a/projects/vault-v2/src-tauri/src/server/proxy.rs b/projects/vault-v2/src-tauri/src/server/proxy.rs new file mode 100644 index 0000000..b564177 --- /dev/null +++ b/projects/vault-v2/src-tauri/src/server/proxy.rs @@ -0,0 +1,687 @@ +use axum::{ + extract::{Host, Path as AxumPath, Query, Request}, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::Response, + routing::{any, delete, get, head, options, patch, post, put}, + Router, + body::Body, +}; +use std::collections::HashMap; +use std::str::FromStr; +use reqwest; +use serde_json; +use regex::Regex; +use url; + +/// Create the proxy router with wildcard *.keepkey.com support +pub fn create_proxy_router() -> Router { + Router::new() + .route("/", get(proxy_root_handler).post(proxy_root_post_handler)) + .route("/*path", get(proxy_handler).post(proxy_post_handler).put(proxy_put_handler).delete(proxy_delete_handler).patch(proxy_patch_handler).options(proxy_options_handler).head(proxy_head_handler)) + .fallback(proxy_fallback_handler) +} + +/// Handle GET requests to the root path +async fn proxy_root_handler( + Host(host): Host, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY ROOT GET: / -> {}", target_domain); + proxy_keepkey_request("", Method::GET, params, headers, None, &target_domain).await +} + +/// Handle POST requests to the root path +async fn proxy_root_post_handler( + Host(host): Host, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY ROOT POST: / -> {}", target_domain); + let body = extract_body(request).await; + proxy_keepkey_request("", Method::POST, params, headers, body, &target_domain).await +} + +/// Handle GET requests to any path +async fn proxy_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY GET: /{} -> {}/{}", path, target_domain, path); + proxy_keepkey_request(&path, Method::GET, params, headers, None, &target_domain).await +} + +/// Handle POST requests to any path +async fn proxy_post_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY POST: /{} -> {}/{}", path, target_domain, path); + let body = extract_body(request).await; + proxy_keepkey_request(&path, Method::POST, params, headers, body, &target_domain).await +} + +/// Handle PUT requests to any path +async fn proxy_put_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY PUT: /{} -> {}/{}", path, target_domain, path); + let body = extract_body(request).await; + proxy_keepkey_request(&path, Method::PUT, params, headers, body, &target_domain).await +} + +/// Handle DELETE requests to any path +async fn proxy_delete_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY DELETE: /{} -> {}/{}", path, target_domain, path); + let body = extract_body(request).await; + proxy_keepkey_request(&path, Method::DELETE, params, headers, body, &target_domain).await +} + +/// Handle PATCH requests to any path +async fn proxy_patch_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY PATCH: /{} -> {}/{}", path, target_domain, path); + let body = extract_body(request).await; + proxy_keepkey_request(&path, Method::PATCH, params, headers, body, &target_domain).await +} + +/// Handle OPTIONS requests to any path +async fn proxy_options_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY OPTIONS: /{} -> {}/{}", path, target_domain, path); + proxy_keepkey_request(&path, Method::OPTIONS, params, headers, None, &target_domain).await +} + +/// Handle HEAD requests to any path +async fn proxy_head_handler( + Host(host): Host, + AxumPath(path): AxumPath, + Query(params): Query>, + headers: HeaderMap, +) -> Response { + let target_domain = determine_target_domain(&host, &headers); + tracing::info!("🌐 PROXY HEAD: /{} -> {}/{}", path, target_domain, path); + proxy_keepkey_request(&path, Method::HEAD, params, headers, None, &target_domain).await +} + +/// Fallback handler for any method/path combination +async fn proxy_fallback_handler( + request: Request, +) -> Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + let path = uri.path(); + let headers = request.headers().clone(); + + // Extract host from headers if not available from extractor + let host = headers.get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8080"); + + let target_domain = determine_target_domain(host, &headers); + tracing::info!("🌐 PROXY FALLBACK: {} {} -> {}{}", method, path, target_domain, path); + + let query_params = extract_query_params(uri.query()); + let body = extract_body(request).await; + + proxy_keepkey_request(path.trim_start_matches('/'), method, query_params, headers, body, &target_domain).await +} + +/// Determine the target KeepKey domain based on routing rules with wildcard support +fn determine_target_domain(host: &str, headers: &HeaderMap) -> String { + // Check for explicit subdomain routing in headers + if let Some(target_header) = headers.get("x-keepkey-target") { + if let Ok(target) = target_header.to_str() { + if is_valid_keepkey_domain(target) { + return format!("https://{}", target); + } + } + } + + // Parse the incoming host to determine target subdomain + let host_clean = host.split(':').next().unwrap_or(host); // Remove port if present + + // Check if the request is for a specific subdomain pattern + if let Some(subdomain) = extract_keepkey_subdomain(host_clean) { + return format!("https://{}.keepkey.com", subdomain); + } + + // Check for wildcard subdomain in query params (for development) + if let Some(subdomain_header) = headers.get("x-keepkey-subdomain") { + if let Ok(subdomain) = subdomain_header.to_str() { + if is_valid_subdomain(subdomain) { + return format!("https://{}.keepkey.com", subdomain); + } + } + } + + // Default routing to real KeepKey domain + "https://keepkey.com".to_string() +} + +/// Extract subdomain from host if it follows KeepKey patterns (true wildcard support) +fn extract_keepkey_subdomain(host: &str) -> Option { + // Handle localhost with subdomain simulation for development + if host.starts_with("localhost") || host.starts_with("127.0.0.1") { + // For local development, route to vault.keepkey.com (the real live site) + return Some("vault".to_string()); + } + + // Handle actual subdomain requests (for when deployed) + // Pattern: subdomain.keepkey.local or subdomain.keepkey.dev (for development) + if host.ends_with(".keepkey.local") || host.ends_with(".keepkey.dev") { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 3 { + return Some(parts[0].to_string()); + } + } + + // Handle production patterns: any subdomain of keepkey.com + if host.ends_with(".keepkey.com") { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 3 { + // Extract the subdomain (everything before .keepkey.com) + let subdomain_parts = &parts[..parts.len()-2]; + if !subdomain_parts.is_empty() { + return Some(subdomain_parts.join(".")); + } + } + } + + None +} + +/// Validate that a domain is a legitimate KeepKey domain (wildcard support) +fn is_valid_keepkey_domain(domain: &str) -> bool { + // Use regex to match *.keepkey.com pattern + lazy_static::lazy_static! { + static ref KEEPKEY_DOMAIN_REGEX: Regex = Regex::new(r"^([a-zA-Z0-9-]+\.)*keepkey\.com$").unwrap(); + } + + // Check exact match for root domain + if domain == "keepkey.com" { + return true; + } + + // Check wildcard pattern *.keepkey.com + KEEPKEY_DOMAIN_REGEX.is_match(domain) +} + +/// Validate subdomain name +fn is_valid_subdomain(subdomain: &str) -> bool { + // Basic validation for subdomain names + lazy_static::lazy_static! { + static ref SUBDOMAIN_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9-]+$").unwrap(); + } + + !subdomain.is_empty() && + subdomain.len() <= 63 && + !subdomain.starts_with('-') && + !subdomain.ends_with('-') && + SUBDOMAIN_REGEX.is_match(subdomain) +} + +/// Extract body from request with size limit +async fn extract_body(request: Request) -> Option> { + const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; // 10MB limit + match axum::body::to_bytes(request.into_body(), MAX_BODY_SIZE).await { + Ok(bytes) => if bytes.is_empty() { None } else { Some(bytes.to_vec()) }, + Err(e) => { + tracing::warn!("Failed to extract request body (可čƒŊ exceeding size limit): {}", e); + None + } + } +} + +/// Extract query parameters from query string +fn extract_query_params(query: Option<&str>) -> HashMap { + query.map(|q| { + url::form_urlencoded::parse(q.as_bytes()) + .into_owned() + .collect() + }).unwrap_or_default() +} + +/// Core proxy function that handles all requests to *.keepkey.com domains +async fn proxy_keepkey_request( + path: &str, + method: Method, + params: HashMap, + headers: HeaderMap, + body: Option>, + target_domain: &str, +) -> Response { + // Build the target URL + let target_url = if path.is_empty() { + format!("{}/", target_domain) + } else { + format!("{}/{}", target_domain, path) + }; + + tracing::debug!("🔄 Proxying {} {} -> {}", method, path, target_url); + + // Create HTTP client with appropriate settings for connecting to vault.keepkey.com + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(false) // Use proper SSL validation for production + .timeout(std::time::Duration::from_secs(30)) // Reasonable timeout + .connect_timeout(std::time::Duration::from_secs(10)) // DNS/connect timeout + .user_agent("KeepKey-Vault-Proxy/2.0") + .tcp_keepalive(std::time::Duration::from_secs(60)) + .pool_idle_timeout(std::time::Duration::from_secs(90)) + .build() + .unwrap(); + + // Convert axum Method to reqwest Method + let reqwest_method = match method { + Method::GET => reqwest::Method::GET, + Method::POST => reqwest::Method::POST, + Method::PUT => reqwest::Method::PUT, + Method::DELETE => reqwest::Method::DELETE, + Method::PATCH => reqwest::Method::PATCH, + Method::HEAD => reqwest::Method::HEAD, + Method::OPTIONS => reqwest::Method::OPTIONS, + _ => reqwest::Method::GET, + }; + + // Build request + let mut request = client.request(reqwest_method, &target_url); + + // Add query parameters + if !params.is_empty() { + request = request.query(¶ms); + } + + // Forward appropriate headers (exclude problematic ones) + for (name, value) in headers.iter() { + let name_str = name.as_str().to_lowercase(); + if !is_hop_by_hop_header(&name_str) && !is_problematic_header(&name_str) { + if let Ok(value_str) = value.to_str() { + // Special handling for Host header - set it to target domain + if name_str == "host" { + let target_host = target_domain.trim_start_matches("https://").trim_start_matches("http://"); + request = request.header("host", target_host); + } else { + request = request.header(name.as_str(), value_str); + } + } + } + } + + // Add body for POST/PUT/PATCH requests + if let Some(body_bytes) = body { + request = request.body(body_bytes); + } + + // Make the request + match request.send().await { + Ok(response) => { + tracing::debug!("✅ Proxy response: {} {}", response.status(), target_url); + convert_response_to_axum(response, target_domain).await + } + Err(e) => { + // Provide more detailed error information for vault.keepkey.com connectivity + let error_msg = if e.is_timeout() { + tracing::warn!("⏰ Timeout connecting to {} (30s limit)", target_url); + "Request timeout - vault.keepkey.com may be slow or unreachable" + } else if e.is_connect() { + // Check if this is a DNS resolution error specifically + let error_str = e.to_string(); + if error_str.contains("dns error") || error_str.contains("failed to lookup address") { + tracing::error!("🌐 DNS resolution failed for {}: {}", target_url, e); + "DNS resolution failed - cannot resolve vault.keepkey.com. Check your internet connection and DNS settings" + } else { + tracing::error!("🔌 Connection failed to {}: {}", target_url, e); + "Failed to connect to vault.keepkey.com - the server may be down or unreachable" + } + } else if e.is_request() { + tracing::error!("📤 Request error to {}: {}", target_url, e); + "Request formatting error" + } else { + tracing::error!("❌ Unknown proxy error for {}: {}", target_url, e); + "Unknown proxy error" + }; + + create_error_response(StatusCode::BAD_GATEWAY, &format!("{}: {}", error_msg, e)) + } + } +} + +/// Convert reqwest Response to axum Response +async fn convert_response_to_axum(response: reqwest::Response, target_domain: &str) -> Response { + // Convert status code + let status_code = match response.status().as_u16() { + 200 => StatusCode::OK, + 201 => StatusCode::CREATED, + 204 => StatusCode::NO_CONTENT, + 301 => StatusCode::MOVED_PERMANENTLY, + 302 => StatusCode::FOUND, + 304 => StatusCode::NOT_MODIFIED, + 400 => StatusCode::BAD_REQUEST, + 401 => StatusCode::UNAUTHORIZED, + 403 => StatusCode::FORBIDDEN, + 404 => StatusCode::NOT_FOUND, + 405 => StatusCode::METHOD_NOT_ALLOWED, + 429 => StatusCode::TOO_MANY_REQUESTS, + 500 => StatusCode::INTERNAL_SERVER_ERROR, + 502 => StatusCode::BAD_GATEWAY, + 503 => StatusCode::SERVICE_UNAVAILABLE, + 504 => StatusCode::GATEWAY_TIMEOUT, + code => StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + }; + + let response_headers = response.headers().clone(); + let content_type = response_headers.get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + + // Check if this is a React Server Components (RSC) streaming response + let is_rsc_stream = content_type.contains("text/x-component") || + content_type.contains("text/plain") || + content_type.contains("application/x-ndjson") || + content_type.contains("text/x-server-sent-events") || + response_headers.get("transfer-encoding") + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("chunked")) + .unwrap_or(false) || + response_headers.get("x-nextjs-stream") + .is_some() || + response_headers.get("x-nextjs-page") + .is_some(); + + // For RSC streaming responses, pass through directly without buffering + if is_rsc_stream { + tracing::debug!("🔄 Streaming RSC response without buffering"); + return stream_response_directly(response, status_code, response_headers, target_domain); + } + + // For non-streaming responses, process the body (URL rewriting, etc.) + let body_bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to read response body: {}", e); + return create_error_response(StatusCode::BAD_GATEWAY, "Failed to read response body"); + } + }; + + // Process the body based on content type + let processed_body = if content_type.contains("text/html") { + // For HTML responses, rewrite URLs + let body_str = String::from_utf8_lossy(&body_bytes); + let processed = rewrite_urls(&body_str, target_domain); + processed.into_bytes() + } else { + // For other content types, return as-is + body_bytes.to_vec() + }; + + // Build response with appropriate headers + let mut resp_builder = Response::builder().status(status_code); + + // Copy headers from the original response, but skip content-length if we modified the body + let body_was_modified = content_type.contains("text/html"); + for (name, value) in response_headers.iter() { + let name_str = name.as_str().to_lowercase(); + if !is_hop_by_hop_header(&name_str) && + !(body_was_modified && name_str == "content-length") { // Skip content-length if body was modified + if let Ok(header_name) = HeaderName::from_str(name.as_str()) { + if let Ok(header_value) = HeaderValue::from_str(&value.to_str().unwrap_or_default()) { + resp_builder = resp_builder.header(header_name, header_value); + } + } + } + } + + // Set correct content-length for the processed body + resp_builder = resp_builder.header("content-length", processed_body.len().to_string()); + + // Create the response + match resp_builder.body(Body::from(processed_body)) { + Ok(response) => response, + Err(e) => { + eprintln!("❌ Failed to build response: {}", e); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Internal Server Error")) + .unwrap() + } + } +} + +/// Stream response directly without buffering (for RSC and other streaming responses) +fn stream_response_directly(response: reqwest::Response, status_code: StatusCode, response_headers: reqwest::header::HeaderMap, target_domain: &str) -> Response { + // Convert reqwest body to axum body stream + let body_stream = response.bytes_stream(); + let body = Body::from_stream(body_stream); + + // Build response with appropriate headers + let mut resp_builder = Response::builder().status(status_code); + + // Copy safe headers from the original response, preserving streaming headers + for (name, value) in response_headers.iter() { + let name_str = name.as_str().to_lowercase(); + // For streaming responses, allow transfer-encoding and don't filter content-length + if name_str == "transfer-encoding" || name_str == "content-type" { + if let Ok(value_str) = value.to_str() { + resp_builder = resp_builder.header(name.as_str(), value_str); + } + } else if !is_hop_by_hop_header(&name_str) && !is_security_header(&name_str) && name_str != "content-length" { + if let Ok(value_str) = value.to_str() { + resp_builder = resp_builder.header(name.as_str(), value_str); + } + } + } + + // Add proxy-specific headers but preserve streaming headers + resp_builder = resp_builder + .header("x-proxy-by", "keepkey-vault") + .header("x-proxy-target", target_domain) + .header("access-control-allow-origin", "*") + .header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + .header("access-control-allow-headers", "content-type, authorization, x-requested-with, x-keepkey-target, x-keepkey-subdomain"); + + // Don't override cache-control for streaming responses + resp_builder.body(body).unwrap() +} + +/// Rewrite URLs in HTML content to point to our proxy for all KeepKey domains (wildcard support with subdomain preservation) +fn rewrite_keepkey_urls(html: &str, target_domain: &str) -> String { + let mut result = html.to_string(); + let proxy_base = "http://localhost:8080"; + // Extract subdomain from target for preservation + let subdomain = target_domain.trim_start_matches("https://").split('.').next().unwrap_or(""); + let proxy_base_with_sub = if subdomain.is_empty() { proxy_base.to_string() } else { format!("{}/{}", proxy_base, subdomain) }; + // Add base tag + if let Some(head_pos) = result.find("") { + let insert_pos = head_pos + "".len(); + result.insert_str(insert_pos, &format!(r#" + + + "#, proxy_base_with_sub, target_domain)); + } + // Enhanced regex to capture and preserve subdomain + lazy_static::lazy_static! { + static ref KEEPKEY_URL_REGEX: Regex = Regex::new(r"https?://((?:[a-zA-Z0-9-]+\.)*)keepkey\.com").unwrap(); + } + result = KEEPKEY_URL_REGEX.replace_all(&result, |caps: ®ex::Captures| { + let sub = &caps[1]; + if sub.is_empty() { + proxy_base.to_string() + } else { + format!("{}/{}", proxy_base, sub.trim_end_matches('.')) + } + }).to_string(); + // Rewrite relative URLs that start with / + result = rewrite_attribute_urls(&result, "href", proxy_base); + result = rewrite_attribute_urls(&result, "src", proxy_base); + result = rewrite_attribute_urls(&result, "action", proxy_base); + + // Rewrite common API patterns for any KeepKey subdomain + result = rewrite_keepkey_api_calls(&result, proxy_base); + + tracing::debug!("🔄 Rewrote HTML URLs for KeepKey proxy compatibility (wildcard)"); + result +} + +/// Rewrite JavaScript/JSON content for KeepKey domains (wildcard support with subdomain preservation) +fn rewrite_js_urls(content: &str, target_domain: &str) -> String { + let mut result = content.to_string(); + let proxy_base = "http://localhost:8080"; + // Extract subdomain + let _subdomain = target_domain.trim_start_matches("https://").split('.').next().unwrap_or(""); + // Enhanced regex to capture subdomain + lazy_static::lazy_static! { + static ref KEEPKEY_JS_REGEX: Regex = Regex::new(r#"["']https?://((?:[a-zA-Z0-9-]+\.)*)keepkey\.com([^"']*)["']"#).unwrap(); + } + result = KEEPKEY_JS_REGEX.replace_all(&result, |caps: ®ex::Captures| { + let quote = &caps[0][0..1]; + let sub = &caps[1]; + let path = &caps[2]; + let proxy_path = if sub.is_empty() { + format!("{}{}", proxy_base, path) + } else { + format!("{}/{}{}", proxy_base, sub.trim_end_matches('.'), path) + }; + format!("{}{}{}", quote, proxy_path, quote) + }).to_string(); + tracing::debug!("🔄 Rewrote JavaScript URLs for KeepKey proxy compatibility (wildcard)"); + result +} + +/// Rewrite API calls for KeepKey domains +fn rewrite_keepkey_api_calls(html: &str, proxy_base: &str) -> String { + let mut result = html.to_string(); + + // Rewrite fetch calls + result = result.replace("fetch(\"/", &format!("fetch(\"{}/", proxy_base)); + result = result.replace("fetch('/", &format!("fetch('{}/", proxy_base)); + + // Rewrite XMLHttpRequest calls + result = result.replace(".open(\"GET\", \"/", &format!(".open(\"GET\", \"{}/", proxy_base)); + result = result.replace(".open('GET', '/", &format!(".open('GET', '{}/", proxy_base)); + result = result.replace(".open(\"POST\", \"/", &format!(".open(\"POST\", \"{}/", proxy_base)); + result = result.replace(".open('POST', '/", &format!(".open('POST', '{}/", proxy_base)); + + // Rewrite axios calls + result = result.replace("axios.get(\"/", &format!("axios.get(\"{}/", proxy_base)); + result = result.replace("axios.get('/", &format!("axios.get('{}/", proxy_base)); + result = result.replace("axios.post(\"/", &format!("axios.post(\"{}/", proxy_base)); + result = result.replace("axios.post('/", &format!("axios.post('{}/", proxy_base)); + + result +} + +/// Rewrite specific HTML attributes +fn rewrite_attribute_urls(html: &str, attribute: &str, proxy_base: &str) -> String { + let mut result = html.to_string(); + + // Handle double quotes + let pattern_double = format!("{}=\"/", attribute); + let replacement_double = format!("{}=\"{}/", attribute, proxy_base); + result = result.replace(&pattern_double, &replacement_double); + + // Handle single quotes + let pattern_single = format!("{}='/", attribute); + let replacement_single = format!("{}='{}/", attribute, proxy_base); + result = result.replace(&pattern_single, &replacement_single); + + result +} + +/// Rewrite URLs in content based on content type +fn rewrite_urls(content: &str, target_domain: &str) -> String { + // For now, just use the HTML rewriter for all content + // In the future, we could have different rewriters for different content types + rewrite_keepkey_urls(content, target_domain) +} + +/// Check if header is a hop-by-hop header that shouldn't be forwarded +fn is_hop_by_hop_header(name: &str) -> bool { + matches!(name, + "connection" | "keep-alive" | "proxy-authenticate" | + "proxy-authorization" | "te" | "trailers" | "transfer-encoding" | "upgrade" + ) +} + +/// Check if header is problematic for proxying +fn is_problematic_header(name: &str) -> bool { + matches!(name, + "content-length" | "content-encoding" | + "accept-encoding" // Let the client handle encoding + ) +} + +/// Check if header is a security header that should be filtered +fn is_security_header(name: &str) -> bool { + matches!(name, + "content-security-policy" | "x-frame-options" | + "strict-transport-security" | "x-xss-protection" | + "x-content-type-options" | "referrer-policy" + ) +} + +/// Create a standardized error response +fn create_error_response(status: StatusCode, message: &str) -> Response { + let error_body = serde_json::json!({ + "error": "KeepKey Proxy Error", + "message": message, + "status": status.as_u16(), + "proxy": "keepkey-vault", + "wildcard_support": "*.keepkey.com", + "default_target": "keepkey.com", + "examples": [ + "keepkey.com", + "vault.keepkey.com", + "app.keepkey.com", + "api.keepkey.com", + "bridge.keepkey.com", + "support.keepkey.com", + "docs.keepkey.com", + "any-subdomain.keepkey.com" + ] + }); + + Response::builder() + .status(status) + .header("content-type", "application/json") + .header("access-control-allow-origin", "*") + .header("x-proxy-error", "true") + .header("x-proxy-by", "keepkey-vault") + .header("x-wildcard-support", "*.keepkey.com") + .header("x-default-target", "keepkey.com") + .body(Body::from(error_body.to_string())) + .unwrap() +} \ No newline at end of file From ef84d60cc26ee0c7eef14cc620d00e20105ca268 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 3 Aug 2025 20:57:15 -0500 Subject: [PATCH 28/63] proxy work --- projects/vault-v2/docs/PIN_FLOW_PROTECTION.md | 205 ++++++++++++++++++ projects/vault-v2/src-tauri/src/commands.rs | 24 ++ projects/vault-v2/src-tauri/src/lib.rs | 147 +++++++++---- projects/vault-v2/src-tauri/src/server/mod.rs | 8 +- .../vault-v2/src-tauri/src/server/proxy.rs | 15 +- .../src/components/VaultInterface.tsx | 150 +++++++------ .../src/components/views/BrowserView.tsx | 85 ++++++-- 7 files changed, 506 insertions(+), 128 deletions(-) create mode 100644 projects/vault-v2/docs/PIN_FLOW_PROTECTION.md diff --git a/projects/vault-v2/docs/PIN_FLOW_PROTECTION.md b/projects/vault-v2/docs/PIN_FLOW_PROTECTION.md new file mode 100644 index 0000000..1df9968 --- /dev/null +++ b/projects/vault-v2/docs/PIN_FLOW_PROTECTION.md @@ -0,0 +1,205 @@ +# PIN Flow Protection System + +## Overview +This document describes the PIN flow protection system implemented in KeepKey Vault v2 to prevent device operations from interrupting PIN entry on the hardware device. + +## Problem Statement +When a KeepKey device displays the PIN entry screen, any USB communication can dismiss the screen, causing a poor user experience. Common culprits include: +- Background xpub fetching for portfolio display +- Status polling for device updates +- GetFeatures calls for device state checking + +## Solution Architecture + +### 1. PIN Flow State Tracking +The system maintains a global registry of devices currently in PIN entry mode: + +```rust +// In commands.rs +lazy_static! { + static ref DEVICES_IN_PIN_FLOW: Arc>> = + Arc::new(Mutex::new(HashSet::new())); +} +``` + +### 2. Two-Layer Protection + +#### Layer 1: Request Blocking at Queue Entry +**Location**: `src/device/queue.rs` lines 82-95 + +Before ANY request enters the device queue, the system checks if the device is in PIN flow: + +```rust +if crate::commands::is_device_in_pin_flow(&request.device_id) { + match &request.request { + DeviceRequest::GetXpub { .. } | + DeviceRequest::GetAddress { .. } | + DeviceRequest::SignTransaction { .. } => { + return Err("Device is currently in PIN entry mode. Please complete PIN entry first.".to_string()); + }, + _ => { + // Allow GetFeatures and SendRaw during PIN flow as they might be needed + } + } +} +``` + +**Blocked Operations During PIN Flow:** +- ❌ GetXpub - Would interrupt PIN screen +- ❌ GetAddress - Would interrupt PIN screen +- ❌ SignTransaction - Would interrupt PIN screen +- ✅ SendRaw - Needed for PIN-related messages +- ✅ GetFeatures - Handled by Layer 2 + +#### Layer 2: GetFeatures Caching +**Location**: `src/device/queue.rs` lines 101-138 + +Even allowed operations avoid unnecessary device communication: + +```rust +let raw_features_opt = if crate::commands::is_device_in_pin_flow(&request.device_id) { + // Use cached features instead of querying device + let cache = DEVICE_STATE_CACHE.read().await; + cache.get(&request.device_id).and_then(|state| state.last_features.clone()) +} else { + // Normal flow - fetch fresh features from device + match keepkey_rust::device_queue::DeviceQueueHandle::get_features(&queue_handle).await { + // ... normal GetFeatures handling + } +}; +``` + +### 3. PIN Flow Lifecycle + +#### Starting PIN Flow +```rust +// In trigger_pin_request() or start_pin_creation() +mark_device_in_pin_flow(&device_id)?; +``` + +#### During PIN Flow +- All non-essential requests are blocked +- Status checks use cached data +- PIN-related messages (SendRaw) pass through + +#### Ending PIN Flow +```rust +// After PIN entry complete or cancelled +unmark_device_in_pin_flow(&device_id)?; +``` + +## Implementation Details + +### Helper Functions + +**Check PIN Flow Status** +```rust +pub fn is_device_in_pin_flow(device_id: &str) -> bool { + if let Ok(devices) = DEVICES_IN_PIN_FLOW.lock() { + devices.contains(device_id) + } else { + false + } +} +``` + +**Mark Device in PIN Flow** +```rust +pub fn mark_device_in_pin_flow(device_id: &str) -> Result<(), String> { + let mut devices = DEVICES_IN_PIN_FLOW.lock() + .map_err(|_| "Failed to lock PIN flow registry")?; + devices.insert(device_id.to_string()); + Ok(()) +} +``` + +**Remove from PIN Flow** +```rust +pub fn unmark_device_in_pin_flow(device_id: &str) -> Result<(), String> { + let mut devices = DEVICES_IN_PIN_FLOW.lock() + .map_err(|_| "Failed to lock PIN flow registry")?; + devices.remove(device_id); + Ok(()) +} +``` + +### Error Handling + +When requests are blocked during PIN flow: +1. User-friendly error message returned +2. No device communication attempted +3. Frontend can display appropriate UI feedback + +Example error response: +```json +{ + "error": "Device is currently in PIN entry mode. Please complete PIN entry first." +} +``` + +## Benefits + +1. **Uninterrupted PIN Entry**: Users can complete PIN entry without the screen disappearing +2. **Reduced USB Traffic**: Fewer unnecessary device queries during sensitive operations +3. **Better UX**: Clear error messages explain why operations are temporarily blocked +4. **Cache Efficiency**: Features cached and reused during PIN flow + +## Testing + +### Test Scenarios + +1. **Basic PIN Flow Protection** + - Trigger PIN request + - Attempt to fetch xpub while PIN screen visible + - Verify request is blocked with appropriate error + +2. **Cache Usage During PIN** + - Get device features before PIN flow + - Enter PIN flow + - Request device status + - Verify cached features are used (no USB traffic) + +3. **Flow Cleanup** + - Enter PIN flow + - Complete or cancel PIN entry + - Verify normal operations resume + - Verify xpub requests succeed + +### Debug Logging + +Key log messages to monitor: +``` +đŸšĢ Blocking request during PIN flow - device is entering PIN +âš ī¸ Skipping GetFeatures check - device is in PIN flow +📋 Using cached features for device during PIN flow +``` + +## Related Files + +- `src/commands.rs` - PIN flow state management functions +- `src/device/queue.rs` - Request blocking and caching logic +- `src/event_controller.rs` - Device event handling + +## Future Improvements + +1. **Timeout Handling**: Auto-clear PIN flow state after timeout (e.g., 5 minutes) +2. **Multi-Device Support**: Better handling when multiple devices are in PIN flow +3. **State Persistence**: Restore PIN flow state after app restart +4. **WebSocket Notifications**: Notify frontend when device enters/exits PIN flow + +## Troubleshooting + +### Issue: PIN Screen Still Disappearing +- Check logs for any SendRaw messages during PIN flow +- Verify all background polling is using the device queue +- Ensure frontend isn't bypassing the queue system + +### Issue: Operations Blocked After PIN Entry +- Check if `unmark_device_in_pin_flow()` is called after PIN completion +- Verify PIN flow cleanup on error conditions +- Check DEVICES_IN_PIN_FLOW state in debugger + +### Issue: Features Not Cached +- Ensure device has been queried at least once before PIN flow +- Check DEVICE_STATE_CACHE has entry for device +- Verify cache insertion in GetFeatures success path \ No newline at end of file diff --git a/projects/vault-v2/src-tauri/src/commands.rs b/projects/vault-v2/src-tauri/src/commands.rs index 2685f64..d3db028 100644 --- a/projects/vault-v2/src-tauri/src/commands.rs +++ b/projects/vault-v2/src-tauri/src/commands.rs @@ -3950,4 +3950,28 @@ pub async fn check_device_pin_ready( Ok(false) } } +} + +/// Clear all device-related caches (used for backend restart) +pub async fn clear_all_device_caches() { + // Clear PIN flow devices + if let Ok(mut pin_flows) = DEVICE_PIN_FLOWS.lock() { + println!(" 📋 Clearing {} device PIN flow(s)", pin_flows.len()); + pin_flows.clear(); + } + + // Clear PIN sessions + if let Ok(mut pin_sessions) = PIN_SESSIONS.lock() { + println!(" 📋 Clearing {} PIN session(s)", pin_sessions.len()); + pin_sessions.clear(); + } + + // Clear frontend ready state and queued events + let mut state = FRONTEND_READY_STATE.write().await; + println!(" 📋 Clearing {} queued event(s)", state.queued_events.len()); + state.queued_events.clear(); + // Don't reset is_ready as frontend is still connected + drop(state); // Explicitly drop to release the lock + + println!(" ✅ All device caches cleared"); } \ No newline at end of file diff --git a/projects/vault-v2/src-tauri/src/lib.rs b/projects/vault-v2/src-tauri/src/lib.rs index 24ddfa9..daedbde 100644 --- a/projects/vault-v2/src-tauri/src/lib.rs +++ b/projects/vault-v2/src-tauri/src/lib.rs @@ -43,9 +43,14 @@ fn vault_open_support(app: tauri::AppHandle) -> Result<(), String> { "view": "browser" })).map_err(|e| format!("Failed to emit view change event: {}", e))?; - app.emit("browser:navigate", serde_json::json!({ - "url": "https://support.keepkey.com" - })).map_err(|e| format!("Failed to emit navigation event: {}", e))?; + // Add a small delay to ensure the browser view is mounted before navigation + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + + let _ = app.emit("browser:navigate", serde_json::json!({ + "url": "https://support.keepkey.com" + })); + }); Ok(()) } @@ -77,49 +82,105 @@ async fn open_url(app_handle: tauri::AppHandle, url: String) -> Result<(), Strin } #[tauri::command] -fn restart_backend_startup(app: tauri::AppHandle) -> Result<(), String> { - println!("Restarting backend startup process"); - // Emit event to indicate restart - match app.emit("application:state", serde_json::json!({ - "status": "Restarting...", +async fn restart_backend_startup(app: tauri::AppHandle) -> Result<(), String> { + println!("🔄 PERFORMING COMPREHENSIVE BACKEND RESTART"); + + // Emit restart status + let _ = app.emit("application:state", serde_json::json!({ + "status": "Restarting backend services...", "connected": false, "features": null - })) { - Ok(_) => { - // Simulate restart process - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(1000)); - let _ = app.emit("application:state", serde_json::json!({ - "status": "Device ready", - "connected": true, - "features": { - "label": "KeepKey", - "vendor": "KeepKey", - "model": "KeepKey", - "firmware_variant": "keepkey", - "device_id": "keepkey-001", - "language": "english", - "bootloader_mode": false, - "version": "7.7.0", - "firmware_hash": null, - "bootloader_hash": null, - "initialized": true, - "imported": false, - "no_backup": false, - "pin_protection": true, - "pin_cached": false, - "passphrase_protection": false, - "passphrase_cached": false, - "wipe_code_protection": false, - "auto_lock_delay_ms": null, - "policies": [] - } - })); - }); - Ok(()) - }, - Err(e) => Err(format!("Failed to emit restart event: {}", e)) + })); + + // 1. Clear all device queues + if let Some(queue_manager_state) = app.try_state::>>>() { + let mut manager = queue_manager_state.inner().lock().await; + println!(" 📋 Clearing {} device queue(s)...", manager.len()); + + // Note: Device workers will be cleaned up when dropped + for (device_id, _handle) in manager.iter() { + println!(" 🛑 Removing device worker for: {}", device_id); + // Workers will be stopped when the handle is dropped + } + + // Clear the queue manager + manager.clear(); + println!(" ✅ All device queues cleared"); + } + + // 2. Clear response tracking + if let Some(responses_state) = app.try_state::>>>() { + let mut responses = responses_state.inner().lock().await; + println!(" 📋 Clearing {} cached response(s)...", responses.len()); + responses.clear(); + println!(" ✅ Response cache cleared"); + } + + // 3. Clear any cached device states + println!(" 📋 Clearing device state caches..."); + commands::clear_all_device_caches().await; + // The function will print its own completion message + + // 4. Stop and restart the event controller (if we had a handle to it) + // Note: Current implementation doesn't store event controller handle, + // but we can emit a signal to restart device scanning + println!(" 🔄 Triggering device rescan..."); + + // 5. Small delay to let everything settle + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // 6. Emit scanning status to trigger new device discovery + let _ = app.emit("application:state", serde_json::json!({ + "status": "Scanning for devices...", + "connected": false, + "features": null + })); + + // 7. Force a device rescan by listing devices + let devices = keepkey_rust::features::list_connected_devices(); + let device_count = devices.len(); + println!(" 🔍 Found {} device(s) after restart", device_count); + + // 8. Emit device events for any found devices + for device in devices { + println!(" 📡 Re-emitting device:connected for {}", device.unique_id); + let _ = app.emit("device:connected", &device); + + // Also trigger feature fetch for each device + let app_for_device = app.clone(); + let device_for_task = device.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Try to get features for the device + println!(" 🔍 Attempting to get features for {} after restart", device_for_task.unique_id); + // Note: We'll let the event controller handle feature fetching + // Just emit the device connected event + let _ = app_for_device.emit("device:ready", serde_json::json!({ + "device": device_for_task, + "status": "reconnected_after_restart" + })); + }); } + + println!("✅ BACKEND RESTART COMPLETE"); + + // Final status update + if device_count == 0 { + let _ = app.emit("application:state", serde_json::json!({ + "status": "No devices found. Please connect your KeepKey.", + "connected": false, + "features": null + })); + } else { + let _ = app.emit("application:state", serde_json::json!({ + "status": format!("Found {} device(s)", device_count), + "connected": true, + "features": null + })); + } + + Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/projects/vault-v2/src-tauri/src/server/mod.rs b/projects/vault-v2/src-tauri/src/server/mod.rs index 68d170e..4cc385d 100644 --- a/projects/vault-v2/src-tauri/src/server/mod.rs +++ b/projects/vault-v2/src-tauri/src/server/mod.rs @@ -103,12 +103,14 @@ pub async fn start_server(device_queue_manager: crate::commands::DeviceQueueMana .with_state(server_state) .layer( CorsLayer::new() - // Allow any origin with wildcard + // Allow any origin with wildcard (includes localhost:8080 proxy) .allow_origin(tower_http::cors::Any) // Allow all methods .allow_methods(tower_http::cors::Any) - // Allow all headers + // Allow all headers including X-Requested-With for AJAX .allow_headers(tower_http::cors::Any) + // Max age for preflight caching + .max_age(std::time::Duration::from_secs(3600)) // Note: credentials cannot be used with wildcard origin .allow_credentials(false) ); @@ -123,7 +125,7 @@ pub async fn start_server(device_queue_manager: crate::commands::DeviceQueueMana info!("🚀 Starting servers:"); info!(" 📋 REST API: http://{}/api", addr); - info!(" 🌍 Vault Proxy: http://{} -> vault.keepkey.com", proxy_addr); + info!(" 🌍 Proxy: http://{} -> keepkey.com", proxy_addr); info!(" 📚 API Documentation: http://{}/docs", addr); debug!(" 🔌 Device Management: http://{}/api/devices", addr); debug!(" 🤖 MCP Endpoint: http://{}/mcp", addr); diff --git a/projects/vault-v2/src-tauri/src/server/proxy.rs b/projects/vault-v2/src-tauri/src/server/proxy.rs index b564177..1270493 100644 --- a/projects/vault-v2/src-tauri/src/server/proxy.rs +++ b/projects/vault-v2/src-tauri/src/server/proxy.rs @@ -15,10 +15,21 @@ use url; /// Create the proxy router with wildcard *.keepkey.com support pub fn create_proxy_router() -> Router { + use tower_http::cors::CorsLayer; + Router::new() .route("/", get(proxy_root_handler).post(proxy_root_post_handler)) .route("/*path", get(proxy_handler).post(proxy_post_handler).put(proxy_put_handler).delete(proxy_delete_handler).patch(proxy_patch_handler).options(proxy_options_handler).head(proxy_head_handler)) .fallback(proxy_fallback_handler) + // Add CORS layer to proxy server as well + .layer( + CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any) + .max_age(std::time::Duration::from_secs(3600)) + .allow_credentials(false) + ) } /// Handle GET requests to the root path @@ -196,8 +207,8 @@ fn determine_target_domain(host: &str, headers: &HeaderMap) -> String { fn extract_keepkey_subdomain(host: &str) -> Option { // Handle localhost with subdomain simulation for development if host.starts_with("localhost") || host.starts_with("127.0.0.1") { - // For local development, route to vault.keepkey.com (the real live site) - return Some("vault".to_string()); + // For local development, route to keepkey.com (no subdomain) + return None; } // Handle actual subdomain requests (for when deployed) diff --git a/projects/vault-v2/src/components/VaultInterface.tsx b/projects/vault-v2/src/components/VaultInterface.tsx index b23076f..4f2ea52 100644 --- a/projects/vault-v2/src/components/VaultInterface.tsx +++ b/projects/vault-v2/src/components/VaultInterface.tsx @@ -47,18 +47,21 @@ export const VaultInterface = () => { const handleSupportClick = async () => { try { + // First try to open in integrated browser await invoke('vault_open_support'); + console.log('Opening support in integrated browser'); } catch (error) { - console.error('Failed to open support via backend:', error); - // Fallback to direct open - try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('open_url', { url: 'https://support.keepkey.com' }); - } catch (error) { - console.error('Failed to open URL:', error); - // Fallback to window.open if Tauri command fails - window.open('https://support.keepkey.com', '_blank'); - } + console.error('Failed to open support via integrated browser:', error); + // Fallback to opening in external browser + try { + await invoke('open_url', { url: 'https://support.keepkey.com' }); + console.log('Opening support in external browser'); + } catch (fallbackError) { + console.error('Failed to open URL via Tauri:', fallbackError); + // Last resort: use window.open + window.open('https://support.keepkey.com', '_blank'); + console.log('Opening support via window.open'); + } } }; @@ -158,74 +161,87 @@ export const VaultInterface = () => { {/* Main Vault Interface - Hidden when settings is open */} {!isSettingsOpen && ( - {/* Top Header Bar */} + {/* Top Navigation Bar */} - - KeepKey Vault v{packageJson.version} - + + {/* Left side - Main navigation items */} + + {navItems.slice(0, 2).map((item) => ( + + ))} + + + {/* Center - Logo/Title */} + + KeepKey Vault + + + {/* Right side - Settings and Support */} + + {navItems.slice(2).map((item) => ( + + ))} + + {/* Main Content Area */} {renderCurrentView()} - - {/* Bottom Navigation */} - - - {navItems.map((item) => ( - - ))} - - )} diff --git a/projects/vault-v2/src/components/views/BrowserView.tsx b/projects/vault-v2/src/components/views/BrowserView.tsx index d0fbfca..4bbd142 100644 --- a/projects/vault-v2/src/components/views/BrowserView.tsx +++ b/projects/vault-v2/src/components/views/BrowserView.tsx @@ -14,20 +14,50 @@ import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; export const BrowserView = () => { - const [url, setUrl] = useState('https://keepkey.com'); - const [inputUrl, setInputUrl] = useState('https://keepkey.com'); + // Use proxy server for keepkey.com to avoid CORS issues + const [url, setUrl] = useState('http://localhost:8080'); + const [inputUrl, setInputUrl] = useState('keepkey.com'); const [isLoading, setIsLoading] = useState(true); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [showControls, setShowControls] = useState(false); const [hoverTimer, setHoverTimer] = useState(null); + const [apiStatus, setApiStatus] = useState<'checking' | 'connected' | 'error'>('checking'); + const [pendingNavigation, setPendingNavigation] = useState(null); + + // Check API connectivity on mount + useEffect(() => { + checkApiConnection(); + }, []); + + const checkApiConnection = async () => { + try { + const response = await fetch('http://localhost:1646/spec/swagger.json'); + if (response.ok) { + setApiStatus('connected'); + console.log('✅ API server is connected at localhost:1646'); + } else { + setApiStatus('error'); + console.error('❌ API server returned error:', response.status); + } + } catch (error) { + setApiStatus('error'); + console.error('❌ Failed to connect to API server:', error); + } + }; const handleNavigate = async () => { if (!inputUrl) return; - // Simple URL validation and formatting + // Use proxy server for KeepKey domains let formattedUrl = inputUrl; - if (!inputUrl.startsWith('http://') && !inputUrl.startsWith('https://')) { + + // Check if it's a KeepKey domain + if (inputUrl.includes('keepkey.com') || inputUrl === 'vault' || inputUrl === 'vault.keepkey.com') { + // Always use proxy for KeepKey domains + formattedUrl = 'http://localhost:8080'; + } else if (!inputUrl.startsWith('http://') && !inputUrl.startsWith('https://')) { + // For non-KeepKey domains, add https:// formattedUrl = `https://${inputUrl}`; } @@ -36,16 +66,17 @@ export const BrowserView = () => { await invoke('browser_navigate', { url: formattedUrl }); } catch (error) { console.error('Failed to notify backend of navigation:', error); - // Still navigate even if backend call fails - setUrl(formattedUrl); - setInputUrl(formattedUrl); - setIsLoading(true); } + + // Update the UI + setUrl(formattedUrl); + setIsLoading(true); }; const handleHome = () => { - const homeUrl = 'https://keepkey.com'; - setInputUrl(homeUrl); + // Use proxy for keepkey.com + const homeUrl = 'http://localhost:8080'; + setInputUrl('keepkey.com'); setUrl(homeUrl); setIsLoading(true); }; @@ -124,9 +155,26 @@ export const BrowserView = () => { unlisten = await listen('browser:navigate', (event) => { const { url: newUrl } = event.payload as { url: string }; console.log('Received backend navigation command:', newUrl); - setUrl(newUrl); - setInputUrl(newUrl); - setIsLoading(true); + + // Handle special case for support.keepkey.com + if (newUrl.includes('support.keepkey.com')) { + // Navigate directly to support URL + setUrl(newUrl); + setInputUrl('support.keepkey.com'); + setIsLoading(true); + + // Also update the iframe directly to ensure navigation + setTimeout(() => { + const iframe = document.getElementById('browser-iframe') as HTMLIFrameElement; + if (iframe && iframe.src !== newUrl) { + iframe.src = newUrl; + } + }, 50); + } else { + setUrl(newUrl); + setInputUrl(newUrl); + setIsLoading(true); + } }); } catch (error) { console.error('Failed to set up browser event listeners:', error); @@ -139,6 +187,17 @@ export const BrowserView = () => { if (unlisten) unlisten(); }; }, []); + + // Handle pending navigation once component is ready + useEffect(() => { + if (pendingNavigation) { + console.log('Processing pending navigation to:', pendingNavigation); + setUrl(pendingNavigation); + setInputUrl(pendingNavigation.includes('support.keepkey.com') ? 'support.keepkey.com' : pendingNavigation); + setIsLoading(true); + setPendingNavigation(null); + } + }, [pendingNavigation]); return ( From ab1a6def23e5ad69ccb53b6132d30ac26301fdcf Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 3 Aug 2025 21:07:43 -0500 Subject: [PATCH 29/63] disable browser --- .../src/components/VaultInterface.tsx | 20 +++-- .../src/components/views/BrowserView.tsx | 87 +++++++++++++++++-- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/projects/vault-v2/src/components/VaultInterface.tsx b/projects/vault-v2/src/components/VaultInterface.tsx index 4f2ea52..6d3dcf1 100644 --- a/projects/vault-v2/src/components/VaultInterface.tsx +++ b/projects/vault-v2/src/components/VaultInterface.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Box, Flex, Button, Text, HStack, useDisclosure } from '@chakra-ui/react'; import { FaTh, FaGlobe, FaWallet, FaCog, FaQuestionCircle } from 'react-icons/fa'; -import { listen } from '@tauri-apps/api/event'; +import { listen, emit } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import splashBg from '../assets/splash-bg.png'; import { SettingsDialog } from './SettingsDialog'; @@ -77,12 +77,18 @@ export const VaultInterface = () => { icon: , onClick: () => handleViewChange('vault'), }, - { - id: 'browser', - label: 'Browser', - icon: , - onClick: () => handleViewChange('browser'), - }, + // { + // id: 'browser', + // label: 'Browser', + // icon: , + // onClick: async () => { + // handleViewChange('browser'); + // // Always navigate to keepkey.com when browser button is clicked + // setTimeout(async () => { + // await emit('browser:navigate', { url: 'http://localhost:8080' }); + // }, 100); + // }, + // }, { id: 'settings', label: 'Settings', diff --git a/projects/vault-v2/src/components/views/BrowserView.tsx b/projects/vault-v2/src/components/views/BrowserView.tsx index 4bbd142..644a43c 100644 --- a/projects/vault-v2/src/components/views/BrowserView.tsx +++ b/projects/vault-v2/src/components/views/BrowserView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Box, Input, @@ -9,7 +9,7 @@ import { IconButton, Spinner } from '@chakra-ui/react'; -import { FaArrowLeft, FaArrowRight, FaRedo, FaHome, FaSearch } from 'react-icons/fa'; +import { FaArrowLeft, FaArrowRight, FaRedo, FaHome, FaSearch, FaExternalLinkAlt } from 'react-icons/fa'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; @@ -24,6 +24,8 @@ export const BrowserView = () => { const [hoverTimer, setHoverTimer] = useState(null); const [apiStatus, setApiStatus] = useState<'checking' | 'connected' | 'error'>('checking'); const [pendingNavigation, setPendingNavigation] = useState(null); + const [externalLinkMessage, setExternalLinkMessage] = useState(null); + const iframeRef = useRef(null); // Check API connectivity on mount useEffect(() => { @@ -84,7 +86,7 @@ export const BrowserView = () => { const handleRefresh = () => { setIsLoading(true); // Force iframe reload by changing src - const iframe = document.getElementById('browser-iframe') as HTMLIFrameElement; + const iframe = iframeRef.current; if (iframe) { iframe.src = iframe.src; } @@ -109,17 +111,57 @@ export const BrowserView = () => { const handleIframeLoad = () => { setIsLoading(false); // Try to get the actual URL from iframe (limited by CORS) - const iframe = document.getElementById('browser-iframe') as HTMLIFrameElement; + const iframe = iframeRef.current; if (iframe) { try { + // Try to inject a script to handle link clicks (may be blocked by CORS) + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (iframeDoc && iframe.contentWindow?.location.origin === window.location.origin) { + // Only works for same-origin content + const links = iframeDoc.getElementsByTagName('a'); + for (let i = 0; i < links.length; i++) { + const link = links[i]; + if (link.target === '_blank' || link.target === '_new') { + link.addEventListener('click', (e) => { + e.preventDefault(); + handleExternalLink(link.href); + }); + } + } + } setInputUrl(iframe.contentWindow?.location.href || url); } catch (e) { - // Cross-origin restrictions prevent accessing iframe URL + // Cross-origin restrictions prevent accessing iframe content + console.log('Cross-origin iframe, cannot access content'); setInputUrl(url); } } }; + const handleExternalLink = async (linkUrl: string) => { + console.log('External link clicked:', linkUrl); + + // Check if it's a documentation or external link + if (linkUrl.includes('docs') || linkUrl.includes('github') || !linkUrl.includes('keepkey.com')) { + // Open in external browser + try { + await invoke('open_url', { url: linkUrl }); + // Show a temporary message + setExternalLinkMessage(`Opening ${linkUrl} in external browser...`); + setTimeout(() => setExternalLinkMessage(null), 3000); + } catch (error) { + console.error('Failed to open external link:', error); + // Fallback: navigate in iframe + setUrl(linkUrl); + setInputUrl(linkUrl); + } + } else { + // Navigate within the iframe + setUrl(linkUrl); + setInputUrl(linkUrl); + } + }; + const handleMouseEnter = () => { // Clear any existing timer if (hoverTimer) { @@ -165,7 +207,7 @@ export const BrowserView = () => { // Also update the iframe directly to ensure navigation setTimeout(() => { - const iframe = document.getElementById('browser-iframe') as HTMLIFrameElement; + const iframe = iframeRef.current; if (iframe && iframe.src !== newUrl) { iframe.src = newUrl; } @@ -253,6 +295,16 @@ export const BrowserView = () => { > + handleExternalLink(url)} + title="Open current page in external browser" + > + + {/* Address Bar */} @@ -318,8 +370,28 @@ export const BrowserView = () => { )} + {externalLinkMessage && ( + + + {externalLinkMessage} + + )} + {/* Embedded Website */}