diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index f82f394..fe648d0 100644 --- a/projects/keepkey-vault/electrobun.config.ts +++ b/projects/keepkey-vault/electrobun.config.ts @@ -4,7 +4,7 @@ export default { app: { name: "keepkey-vault", identifier: "com.keepkey.vault", - version: "1.0.5", + version: "1.1.0", urlSchemes: ["keepkey"], }, build: { diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index 623fbaf..ec9ff91 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault", - "version": "1.0.5", + "version": "1.1.0", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev", diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index 3e6c96b..06e01b8 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -97,6 +97,19 @@ export class EngineController extends EventEmitter { this.webUsbAdapter = NodeWebUSBKeepKeyAdapter.useKeyring(this.keyring) } + /** + * Clear the old wallet + keyring state so the next pairRawDevice starts fresh. + * Without this, the keyring still tracks the old connection and WebUSB + * rejects with "cannot connect an already-connected connection". + */ + private clearWallet() { + this.cleanupTransportListeners() + this.wallet = null + this.activeTransport = null + this.cachedFeatures = null + this.keyring.removeAll().catch(() => {}) + } + /** * Attach transport event listeners to catch PIN_REQUEST / BUTTON_REQUEST * events emitted mid-operation by the hdwallet transport layer. @@ -164,10 +177,7 @@ export class EngineController extends EventEmitter { if (device.deviceDescriptor.idVendor !== KEEPKEY_VENDOR_ID) return console.log('[Engine] KeepKey USB detached') this.clearRetry() - this.cleanupTransportListeners() - this.wallet = null - this.activeTransport = null - this.cachedFeatures = null + this.clearWallet() this.lastError = null this.updateState('disconnected') }) @@ -199,11 +209,21 @@ export class EngineController extends EventEmitter { } catch { /* never block on cache failure */ } } - // Auto-trigger PIN matrix on device OLED when state becomes needs_pin + // Auto-trigger PIN matrix on device OLED when state becomes needs_pin. + // After a firmware/bootloader flash the device reboots — give the transport + // time to stabilise before firing getPublicKeys, otherwise the device may + // respond with Failure(7) "Invalid PIN" before the user even sees the overlay. if (state === 'needs_pin') { - this.promptPin().catch(err => { - console.warn('[Engine] Auto prompt-pin failed (expected if PIN flow interrupts):', err?.message) - }) + const delay = this.updatePhase === 'rebooting' ? 2000 : 0 + if (this.updatePhase === 'rebooting') { + this.updatePhase = 'idle' + this.emit('state-change', this.getDeviceState()) + } + setTimeout(() => { + this.promptPin().catch(err => { + console.warn('[Engine] Auto prompt-pin failed (expected if PIN flow interrupts):', err?.message) + }) + }, delay) } } @@ -278,9 +298,7 @@ export class EngineController extends EventEmitter { return } catch (err) { console.warn('[Engine] Lost connection to wallet:', err) - this.wallet = null - this.activeTransport = null - this.cachedFeatures = null + this.clearWallet() } } @@ -315,8 +333,7 @@ export class EngineController extends EventEmitter { this.updateState(this.deriveState(this.cachedFeatures)) } catch (err) { console.error('[Engine] Failed to get features after pairing:', err) - this.wallet = null - this.activeTransport = null + this.clearWallet() this.lastError = `Failed to read device: ${err}` this.updateState('error') } @@ -488,16 +505,25 @@ export class EngineController extends EventEmitter { const blVersion = features?.bootloaderVersion || undefined const bootloaderMode = features?.bootloaderMode ?? false const initialized = features?.initialized ?? false - const needsFw = fwVersion - ? (this.versionLessThan(fwVersion, this.latestFirmware) || fwVersion === '4.0.0') - : false + // In bootloader mode, fwVersion is actually the BL version (from extractVersion). + // Firmware always needs flashing when device is in bootloader mode. + const needsFw = bootloaderMode + ? true + : fwVersion + ? (this.versionLessThan(fwVersion, this.latestFirmware) || fwVersion === '4.0.0') + : false // Bootloader version check with hash-to-version fallback. // Some firmware versions don't report blVersion in features, but DO report // blHash. Use the manifest to resolve hash → version and avoid a false // "needs bootloader update" that causes an infinite update loop. let effectiveBlVersion = blVersion - if (!effectiveBlVersion && !bootloaderMode && features) { + if (!effectiveBlVersion && bootloaderMode && fwVersion) { + // In bootloader mode, majorVersion/minorVersion/patchVersion IS the BL version. + // extractVersion() returns it as fwVersion — use it for comparison. + effectiveBlVersion = fwVersion + console.log(`[Engine] Bootloader mode: using extractVersion ${fwVersion} as BL version`) + } else if (!effectiveBlVersion && !bootloaderMode && features) { const blHash = base64ToHex(features.bootloaderHash) if (blHash && this.manifest?.hashes?.bootloader) { const resolved = this.manifest.hashes.bootloader[blHash] @@ -572,9 +598,7 @@ export class EngineController extends EventEmitter { this.emit('firmware-progress', { percent: 90, message: 'Bootloader updated, rebooting...' }) this.updatePhase = 'rebooting' - this.wallet = null - this.activeTransport = null - this.cachedFeatures = null + this.clearWallet() this.emit('state-change', this.getDeviceState()) this.emit('firmware-progress', { percent: 100, message: 'Bootloader update complete' }) } catch (err: any) { @@ -623,9 +647,7 @@ export class EngineController extends EventEmitter { this.emit('firmware-progress', { percent: 90, message: 'Firmware updated, rebooting...' }) this.updatePhase = 'rebooting' - this.wallet = null - this.activeTransport = null - this.cachedFeatures = null + this.clearWallet() this.emit('state-change', this.getDeviceState()) this.emit('firmware-progress', { percent: 100, message: 'Firmware update complete' }) } catch (err: any) { @@ -1022,9 +1044,7 @@ export class EngineController extends EventEmitter { this.emit('firmware-progress', { percent: 90, message: 'Firmware uploaded, rebooting...' }) this.updatePhase = 'rebooting' - this.wallet = null - this.activeTransport = null - this.cachedFeatures = null + this.clearWallet() this.emit('state-change', this.getDeviceState()) this.emit('firmware-progress', { percent: 100, message: 'Custom firmware flash complete' }) } catch (err: any) { diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index c40fd07..9e0dda3 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -245,7 +245,7 @@ if (!restApiEnabled) console.log('[Vault] REST API disabled (enable in Settings // ── RPC Bridge (Electrobun UI ↔ Bun) ───────────────────────────────── const rpc = BrowserView.defineRPC({ - maxRequestTime: 600000, // device-interactive ops (recovery, create) can take 5-10 minutes + maxRequestTime: Infinity, // no timeout — user can take as long as needed to confirm on device handlers: { requests: { // ── Device lifecycle ────────────────────────────────────── @@ -285,6 +285,10 @@ const rpc = BrowserView.defineRPC({ }, wipeDevice: async () => { if (!engine.wallet) throw new Error('No device connected') + // Cancel any pending PIN/passphrase request before wiping — + // the transport lock is held while waiting for PIN input, + // so wipe() would deadlock without this. + await engine.wallet.cancel().catch(() => {}) await engine.wallet.wipe() await engine.syncState() return { success: true } @@ -1219,6 +1223,9 @@ const rpc = BrowserView.defineRPC({ windowClose: async () => { _mainWindow?.close() }, windowMinimize: async () => { _mainWindow?.minimize() }, windowMaximize: async () => { _mainWindow?.maximize() }, + windowGetFrame: async () => _mainWindow!.getFrame(), + windowSetPosition: async ({ x, y }) => { _mainWindow?.setPosition(x, y) }, + windowSetFrame: async ({ x, y, width, height }) => { _mainWindow?.setFrame(x, y, width, height) }, }, messages: {}, }, @@ -1338,7 +1345,7 @@ const mainWindow = new BrowserWindow({ title: "KeepKey Vault", url, rpc, - titleBarStyle: "hidden", + // titleBarStyle left as default — "hidden" breaks WKWebView keyboard input frame: { width: 1200, height: 800, diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index e195987..14708f5 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -14,7 +14,8 @@ import { SplashScreen } from "./components/SplashScreen" import { WatchOnlyPrompt } from "./components/WatchOnlyPrompt" import { DeviceClaimedDialog } from "./components/DeviceClaimedDialog" import { OobSetupWizard } from "./components/OobSetupWizard" -import { TopNav, TrafficLights } from "./components/TopNav" +import { TopNav, SplashNav } from "./components/TopNav" +import { WindowResizeHandles } from "./components/WindowResizeHandles" import type { NavTab } from "./components/TopNav" import { Dashboard } from "./components/Dashboard" import { AppStore } from "./components/AppStore" @@ -33,6 +34,7 @@ function App() { const deviceState = useDeviceState() const update = useUpdateState() const [wizardComplete, setWizardComplete] = useState(false) + const [setupInProgress, setSetupInProgress] = useState(false) const [portfolioLoaded, setPortfolioLoaded] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [activeTab, setActiveTab] = useState("vault") @@ -90,6 +92,14 @@ function App() { const handlePinCancel = useCallback(() => { setPinRequestType(null); setPinDismissed(true) }, []) + const handlePinWipe = useCallback(async () => { + try { + await rpcRequest("wipeDevice", undefined, 0) + } catch (e) { console.error("wipeDevice from PIN:", e) } + setPinRequestType(null) + setPinDismissed(true) + }, []) + // ── Passphrase overlay ────────────────────────────────────────── const [passphraseRequested, setPassphraseRequested] = useState(false) @@ -390,6 +400,7 @@ function App() { const phase: AppPhase = isClaimed ? "claimed" + : !wizardComplete && setupInProgress ? "setup" : ["disconnected", "connected_unpaired", "error"].includes(deviceState.state) ? "splash" : !wizardComplete && ["bootloader", "needs_firmware", "needs_init"].includes(deviceState.state) ? "setup" : deviceState.state === "ready" ? "ready" @@ -410,7 +421,7 @@ function App() { ) : null const pinOverlay = pinRequestType && !passphraseRequested ? ( - + ) : null const charOverlay = (charRequest || recoveryError) ? ( @@ -439,24 +450,11 @@ function App() { // ── Render phases ─────────────────────────────────────────────── - // Always-visible window controls (all phases including splash/setup) - // NOTE: Do NOT add electrobun-webkit-app-region-drag here — this overlay sits at z-index - // above TopNav and would block all clicks on tabs/settings/etc. Dragging is handled - // by TopNav (or phase-specific drag areas). Traffic lights use onClick → rpcRequest - // which bypasses the drag system entirely. - const windowControls = ( - - - - ) + // SplashNav provides a drag-enabled nav bar with traffic lights for + // splash / setup / claimed phases (where TopNav isn't rendered). + const splashNav = + + const resizeHandles = // Always-visible update banner (all phases) const updateBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" ? ( @@ -474,8 +472,8 @@ function App() { // Watch-only mode: render dashboard with cached data (read-only) if (watchOnlyMode) { return ( - <>{windowControls}{updateBanner}{firmwareDropZone} - + <>{resizeHandles}{updateBanner}{firmwareDropZone} + {windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} @@ -510,7 +508,7 @@ function App() { const needsPin = deviceState.state === "needs_pin" const needsPassphrase = deviceState.state === "needs_passphrase" return ( - <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} {windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} - setWizardComplete(true)} /> + <>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + { setWizardComplete(true); setSetupInProgress(false) }} onSetupInProgress={setSetupInProgress} /> ) } @@ -548,11 +546,11 @@ function App() { const showBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" && update.phase !== "warning" && update.phase !== "error" return ( - <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} {!portfolioLoaded && activeTab === "vault" && ( )} - - Unplug and replug KeepKey + diff --git a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx index c2c2c9b..8caa925 100644 --- a/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx +++ b/projects/keepkey-vault/src/mainview/components/DeviceSettingsDrawer.tsx @@ -209,7 +209,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd setVerifying(true) setVerifyResult(null) try { - const result = await rpcRequest("verifySeed", { wordCount: verifyWordCount }, 600000) as { success: boolean; message: string } + const result = await rpcRequest("verifySeed", { wordCount: verifyWordCount }, 0) as { success: boolean; message: string } setVerifyResult(result) } catch (e: any) { const msg = typeof e?.message === "string" ? e.message : t("verificationFailed") @@ -221,7 +221,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const wipeDevice = useCallback(async () => { setWiping(true) try { - await rpcRequest("wipeDevice", undefined, 60000) + await rpcRequest("wipeDevice", undefined, 0) } catch (e: any) { console.error("wipeDevice:", e) } setWiping(false) setWipeConfirm(false) @@ -274,7 +274,7 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd const handleChangePin = useCallback(async () => { setChangingPin(true) try { - await rpcRequest("changePin", undefined, 600000) + await rpcRequest("changePin", undefined, 0) } catch (e: any) { console.error("changePin:", e) } setChangingPin(false) // Refresh features diff --git a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx index 273a6ab..e379412 100644 --- a/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx +++ b/projects/keepkey-vault/src/mainview/components/FirmwareDropZone.tsx @@ -123,8 +123,8 @@ export function FirmwareDropZone() { setPhase("flashing") setProgress({ percent: 0, message: "Starting firmware flash..." }) try { - // 10 min timeout — firmware erase + upload can be slow - await rpcRequest("flashCustomFirmware", { data: fileDataB64 }, 600000) + // No timeout — user must confirm on device, can take as long as needed + await rpcRequest("flashCustomFirmware", { data: fileDataB64 }, 0) // Progress events drive phase to "complete" via the firmware-progress listener } catch (err: any) { setError(err?.message || "Firmware flash failed") diff --git a/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx b/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx index 1b14015..62199ec 100644 --- a/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx +++ b/projects/keepkey-vault/src/mainview/components/OobSetupWizard.tsx @@ -8,7 +8,10 @@ import { FaCheckCircle, FaExclamationTriangle, FaPlus, + FaChevronDown, + FaChevronUp, } from 'react-icons/fa' +import holdAndConnectRaw from '../assets/svg/hold-and-connect.svg?raw' import { useFirmwareUpdate } from '../hooks/useFirmwareUpdate' import { useDeviceState } from '../hooks/useDeviceState' import { rpcRequest } from '../lib/rpc' @@ -27,6 +30,15 @@ const ANIMATIONS_CSS = ` 50% { transform: scale(1.1); } 100% { transform: scale(1); } } + @keyframes kkStripe { + 0% { background-position: 0 0; } + 100% { background-position: 40px 0; } + } + @keyframes kkGlow { + 0% { box-shadow: 0 0 8px rgba(251, 146, 60, 0.4); } + 50% { box-shadow: 0 0 20px rgba(251, 146, 60, 0.7); } + 100% { box-shadow: 0 0 8px rgba(251, 146, 60, 0.4); } + } ` // ── Step definitions ──────────────────────────────────────────────────────── @@ -67,6 +79,7 @@ const stepToVisibleId: Record = { interface OobSetupWizardProps { onComplete: () => void + onSetupInProgress?: (inProgress: boolean) => void } // ── Confetti pieces ───────────────────────────────────────────────────────── @@ -81,7 +94,7 @@ const confettiPieces = Array.from({ length: 50 }, (_, i) => ({ // ── Main Wizard ───────────────────────────────────────────────────────────── -export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { +export function OobSetupWizard({ onComplete, onSetupInProgress }: OobSetupWizardProps) { const [step, setStep] = useState('welcome') const [setupType, setSetupType] = useState<'create' | 'recover' | null>(null) const [wordCount, setWordCount] = useState<12 | 18 | 24>(12) @@ -89,7 +102,6 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { const [setupError, setSetupError] = useState(null) const [, setSetupLoading] = useState(false) const { t } = useTranslation('setup') - const STEP_DESCRIPTIONS: Record = { 'welcome': t('stepDescriptions.welcome'), 'bootloader': t('stepDescriptions.bootloader'), @@ -106,6 +118,9 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { { id: 'init-choose', label: t('visibleSteps.setup'), number: 3 }, ] + // Advanced seed length toggle for create wallet + const [showCreateAdvanced, setShowCreateAdvanced] = useState(false) + // Dev: load-device dialog const [devLoadOpen, setDevLoadOpen] = useState(false) const [devSeed, setDevSeed] = useState('') @@ -113,8 +128,12 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { // Bootloader state const [waitingForBootloader, setWaitingForBootloader] = useState(false) + const [waitingForBootloaderFw, setWaitingForBootloaderFw] = useState(false) const bootloaderPollRef = useRef | null>(null) + // Reboot phase: after BL/FW flash, wait for device to reconnect with fresh features + const [rebootPhase, setRebootPhase] = useState<'idle' | 'rebooting'>('idle') + // Hooks — use Electrobun RPC-based hooks const deviceStatus = useDeviceState() const { @@ -154,6 +173,13 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { const isVisibleStepCurrent = (vsId: string) => visibleId === vsId + // ── Signal setupInProgress for entire wizard lifecycle ───────────────── + // Keeps wizard visible during device reboots (firmware → init-choose transition) + useEffect(() => { + onSetupInProgress?.(step !== 'complete') + return () => onSetupInProgress?.(false) + }, [step, onSetupInProgress]) + // ── Welcome → first real step ────────────────────────────────────────── useEffect(() => { @@ -208,33 +234,45 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { } }, [waitingForBootloader, deviceStatus.bootloaderMode, updateState, startBootloaderUpdate]) + // Auto-start: if device is already in bootloader mode when we reach this step useEffect(() => { if (step !== 'bootloader') return - if (updateState === 'complete') { - resetUpdate() - setTimeout(() => { - if (needsFirmware) { - setStep('firmware') - } else { - setStep('init-choose') - } - }, 5000) - } - }, [updateState, step, needsFirmware, resetUpdate]) + if (updateState !== 'idle') return + if (rebootPhase === 'rebooting') return // Don't re-trigger during reboot wait + if (!inBootloader) return + startBootloaderUpdate() + }, [step, updateState, rebootPhase, inBootloader, startBootloaderUpdate]) - // Event-driven: detect device reconnection after bootloader update + // Enter reboot phase when bootloader update completes useEffect(() => { if (step !== 'bootloader') return if (updateState !== 'complete') return - if (deviceStatus.state !== 'disconnected' && !deviceStatus.bootloaderMode) { - resetUpdate() - if (needsFirmware) { - setStep('firmware') - } else { - setStep('init-choose') - } + resetUpdate() + setRebootPhase('rebooting') + }, [updateState, step, resetUpdate]) + + // Advance once device reconnects with fresh features after bootloader update + useEffect(() => { + if (step !== 'bootloader') return + if (rebootPhase !== 'rebooting') return + // Wait until engine has re-paired and fetched real features + if (!deviceStatus.firmwareVersion) return + // Ignore transitional states + const s = deviceStatus.state + if (s === 'disconnected' || s === 'connected_unpaired' || s === 'error') return + + setRebootPhase('idle') + + // Device is back — route based on fresh state + if (s === 'bootloader' && needsBootloader) return // stay, auto-start will retry + if (needsFirmware) { + setStep('firmware') + } else if (needsInit) { + setStep('init-choose') + } else { + onComplete() } - }, [step, updateState, deviceStatus.state, deviceStatus.bootloaderMode, needsFirmware, resetUpdate]) + }, [step, rebootPhase, deviceStatus.firmwareVersion, deviceStatus.state, needsBootloader, needsFirmware, needsInit, onComplete]) useEffect(() => { return () => { @@ -244,37 +282,73 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { // ── Firmware step ────────────────────────────────────────────────────── - const handleStartFirmwareUpdate = async () => { - await startFirmwareUpdate(deviceStatus.latestFirmware || undefined) + const handleEnterBootloaderForFirmware = () => { + setWaitingForBootloaderFw(true) + bootloaderPollRef.current = setInterval(async () => { + try { + const state = await rpcRequest('getDeviceState') + if (state.bootloaderMode) { + setWaitingForBootloaderFw(false) + if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) + await startFirmwareUpdate(deviceStatus.latestFirmware || undefined) + } + } catch { + // Device may be disconnecting/reconnecting + } + }, 2000) } + // Event-driven: detect bootloader mode for firmware step useEffect(() => { if (step !== 'firmware') return - if (updateState === 'complete') { - resetUpdate() - setTimeout(() => { - if (needsInit) { - setStep('init-choose') - } else { - setStep('complete') - } - }, 5000) + if (!waitingForBootloaderFw) return + if (updateState === 'updating') return + + if (deviceStatus.bootloaderMode) { + setWaitingForBootloaderFw(false) + if (bootloaderPollRef.current) clearInterval(bootloaderPollRef.current) + startFirmwareUpdate(deviceStatus.latestFirmware || undefined) } - }, [updateState, step, needsInit, resetUpdate]) + }, [step, waitingForBootloaderFw, deviceStatus.bootloaderMode, updateState, startFirmwareUpdate, deviceStatus.latestFirmware]) - // Event-driven: detect device reconnection after firmware update + // Auto-start firmware update if already in bootloader mode, + // otherwise auto-start polling for bootloader entry + useEffect(() => { + if (step !== 'firmware') return + if (updateState !== 'idle') return + if (rebootPhase === 'rebooting') return // Don't re-trigger during reboot wait + if (inBootloader) { + startFirmwareUpdate(deviceStatus.latestFirmware || undefined) + } else if (!waitingForBootloaderFw) { + handleEnterBootloaderForFirmware() + } + }, [step, updateState, rebootPhase, inBootloader]) + + // Enter reboot phase when firmware update completes useEffect(() => { if (step !== 'firmware') return if (updateState !== 'complete') return - if (deviceStatus.state !== 'disconnected' && !deviceStatus.bootloaderMode) { - resetUpdate() - if (needsInit) { - setStep('init-choose') - } else { - setStep('complete') - } + resetUpdate() + setRebootPhase('rebooting') + }, [updateState, step, resetUpdate]) + + // Advance once device reconnects with fresh features after firmware update + useEffect(() => { + if (step !== 'firmware') return + if (rebootPhase !== 'rebooting') return + if (!deviceStatus.firmwareVersion) return + const s = deviceStatus.state + if (s === 'disconnected' || s === 'connected_unpaired' || s === 'error') return + + setRebootPhase('idle') + + if (s === 'bootloader') return // auto-start will retry firmware flash + if (needsInit) { + setStep('init-choose') + } else { + setStep('complete') } - }, [step, updateState, deviceStatus.state, deviceStatus.bootloaderMode, needsInit, resetUpdate]) + }, [step, rebootPhase, deviceStatus.firmwareVersion, deviceStatus.state, needsInit]) const handleSkipFirmware = () => { if (needsInit) { @@ -286,8 +360,8 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { // ── Init: Create / Recover ───────────────────────────────────────────── - // Device-interactive ops need 10 min timeout — user enters seed words on device - const DEVICE_INTERACTION_TIMEOUT = 600000 + // No timeout for device-interactive ops — user can take as long as needed + const DEVICE_INTERACTION_TIMEOUT = 0 const handleCreateWallet = async () => { setSetupType('create') @@ -409,37 +483,39 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { align="center" justify="center" zIndex={1000} + pointerEvents="none" > {/* ── Header ─────────────────────────────────────────────────── */} - - - + + + {t('title')} - + {STEP_DESCRIPTIONS[step]} {/* ── Progress bar ───────────────────────────────────────────── */} - - + + 0 ? 'green.500' : HIGHLIGHT} @@ -451,18 +527,17 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { {/* ── Step indicators ────────────────────────────────────────── */} - - + + {VISIBLE_STEPS.map((vs, idx) => { const completed = isVisibleStepCompleted(vs.id) const current = isVisibleStepCurrent(vs.id) return ( - {/* Circle */} {completed ? ( - + ) : ( - + {vs.number} )} - {/* Label (hidden on small) */} {vs.label} - {/* Connector line */} {idx < VISIBLE_STEPS.length - 1 && ( )} @@ -510,32 +582,29 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { {/* ── Content ────────────────────────────────────────────────── */} - + {/* ═══════════════ WELCOME ═══════════════════════════════ */} {step === 'welcome' && ( - - - - - - + + + + {t('welcome.title')} - + {t('subtitle')} - + {isOobDevice ? t('welcome.oobIntro') : t('welcome.intro')} @@ -543,7 +612,7 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { - + {deviceStatus.state !== 'disconnected' ? t('welcome.startingSetup') : t('welcome.detectingDevice')} @@ -552,53 +621,158 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { {/* ═══════════════ BOOTLOADER ════════════════════════════ */} {step === 'bootloader' && ( - - - - - {t('bootloader.title')} - - - {t('bootloader.description')} - - + + {!inBootloader && updateState !== 'updating' && updateState !== 'error' && rebootPhase !== 'rebooting' && ( + <> + + + + {t('bootloader.title')} + + + {t('bootloader.description')} + + - {/* Version info */} - {deviceStatus.latestBootloader && ( - - - - {t('bootloader.current')} - - {(deviceStatus.bootloaderVersion && !deviceStatus.bootloaderVersion.startsWith('hash:')) - ? `v${deviceStatus.bootloaderVersion}` - : inBootloader - ? `v${deviceStatus.firmwareVersion}` - : t('bootloader.outdated')} - + {deviceStatus.latestBootloader && ( + + + + {t('bootloader.current')} + + {(deviceStatus.bootloaderVersion && !deviceStatus.bootloaderVersion.startsWith('hash:')) + ? `v${deviceStatus.bootloaderVersion}` + : t('bootloader.outdated')} + + + + + {t('bootloader.latest')} + + v{deviceStatus.latestBootloader} + + + + + )} + + + + + + + {t('bootloader.enterFirmwareUpdateMode')} + + + + {t('bootloader.step1Unplug')} + {t('bootloader.step2Hold')} + {t('bootloader.step3Plugin')} + {t('bootloader.step4Release')} + - - - {t('bootloader.latest')} - - v{deviceStatus.latestBootloader} + + + {waitingForBootloader && ( + + + + {t('bootloader.listeningForBootloader')} - - + + )} + + {!waitingForBootloader && ( + <> + + + + )} + + )} + + {rebootPhase === 'rebooting' && ( + + + + + + {t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' })} + + + + {t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' })} + + )} - {/* Updating */} {updateState === 'updating' && ( - - - + + + + {t('bootloader.title')} + + + {updateProgress?.message || t('bootloader.updatingBootloader')} - + + {deviceStatus.latestBootloader && ( + + + + {t('bootloader.current')} + + v{deviceStatus.firmwareVersion || '?'} + + + + + {t('bootloader.latest')} + + v{deviceStatus.latestBootloader} + + + + + )} + + + + + + + + {t('bootloader.verifyBackupHint')} + + + + - - + + {t('bootloader.doNotUnplugBrick')} @@ -606,15 +780,14 @@ export function OobSetupWizard({ onComplete }: OobSetupWizardProps) { )} - {/* Error */} {updateState === 'error' && ( - + {t('bootloader.updateFailed')} {updateError} - - - - )} )} {/* ═══════════════ FIRMWARE ══════════════════════════════ */} {step === 'firmware' && ( - - - - + + + + {t('firmware.title')} - - {isOobDevice - ? t('firmware.oobDescription') - : t('firmware.description')} + + {isOobDevice ? t('firmware.oobDescription') : t('firmware.description')} - {/* Version comparison */} - + - - + + {inBootloader ? t('firmware.firmwareLabel') : t('bootloader.current')} - + {inBootloader ? t('firmware.notInstalled') : `v${deviceStatus.firmwareVersion || '?'}`} - - - {t('bootloader.latest')} - + + + {t('bootloader.latest')} + v{deviceStatus.latestFirmware || '?'} - {/* Important instructions */} - - - - {t('firmware.important')} - - {t('firmware.doNotDisconnect')} - {t('firmware.mayNeedConfirm')} - {t('firmware.fundsRemainSafe')} - - + {updateState === 'idle' && !inBootloader && rebootPhase !== 'rebooting' && ( + <> + + + + + + + {t('bootloader.enterFirmwareUpdateMode')} + + + + {t('bootloader.step1Unplug')} + {t('bootloader.step2Hold')} + {t('bootloader.step3Plugin')} + {t('bootloader.step4Release')} + + + + + + + {t('bootloader.listeningForBootloader')} + + + + )} - {/* Update in progress */} {updateState === 'updating' && ( - - - - {updateProgress?.message || t('firmware.updatingFirmware')} - - - - - {t('firmware.doNotUnplug')} + + + + + {t('firmware.confirmOnDevice')} + + + {t('firmware.lookAtDeviceAndPress')} + + + {t('firmware.verifyBackupNote')} + + + + + + + + + + {updateProgress?.percent != null ? ( + {Math.round(updateProgress.percent)}% + ) : } + {t('firmware.doNotUnplug')} )} - {/* Waiting for reboot */} - {updateState === 'complete' && ( - + {rebootPhase === 'rebooting' && ( + - {t('firmware.deviceRebooting')} + {t('firmware.deviceRebooting', { defaultValue: 'Device rebooting...' })} - {t('firmware.rebootingMessage')} + {t('firmware.rebootingMessage', { defaultValue: 'Waiting for device to reconnect after update.' })} - {deviceStatus.firmwareVerified !== undefined && ( - - {deviceStatus.firmwareVerified ? ( - <> - - - {t('firmware.firmwareVerified')} - - - ) : ( - <> - - - {t('firmware.firmwareHashNotFound')} - - - )} - - )} )} - {/* Error */} {updateState === 'error' && ( - + {t('bootloader.updateFailed')} {updateError} - )} - {/* Actions — idle */} - {updateState === 'idle' && ( - - - {!isOobDevice && ( - - )} - + {updateState === 'idle' && !isOobDevice && rebootPhase !== 'rebooting' && ( + )} )} {/* ═══════════════ INIT: CHOOSE ═════════════════════════ */} {step === 'init-choose' && ( - - - + + + {t('initChoose.title')} - + {t('initChoose.description')} - {/* Word count selector */} - - - {t('initChoose.seedLength', { defaultValue: 'Recovery seed length' })} - - - {([12, 18, 24] as const).map((wc) => ( - setWordCount(wc)} - > - {wc} {t('initChoose.words', { defaultValue: 'words' })} - - ))} - - - {setupError && ( - - {setupError} + + {setupError} )} - - - + + + - - + + {t('initChoose.createNewWallet')} - + {t('initChoose.createDescription')} + + + { + e.stopPropagation() + setShowCreateAdvanced(!showCreateAdvanced) + }} + _hover={{ opacity: 0.8 }} + > + + {t('initChoose.seedLength', { defaultValue: 'Seed length' })}: {wordCount} {t('initChoose.words', { defaultValue: 'words' })} + + {showCreateAdvanced + ? + : + } + + {showCreateAdvanced && ( + + + {([12, 18, 24] as const).map((wc) => ( + { + e.stopPropagation() + setWordCount(wc) + }} + > + {wc} + + ))} + + + {t('initChoose.entropyNote', { defaultValue: 'Added seed length does not improve overall wallet entropy.' })}{' '} + e.stopPropagation()} + > + {t('initChoose.learnMore', { defaultValue: 'Learn more' })} + + + + )} + + + {onWipe && ( + setShowWipeConfirm(true)} + > + {t("pin.forgotPinWipe")} + + )} + + )} + + {/* Wipe confirmation panel */} + {showWipeConfirm && ( + - {t("cancel", { ns: "common" })} - + + {t("pin.wipeWarningTitle")} + + + {t("pin.wipeWarningDescription")} + + + setWipeAcknowledged(e.target.checked)} + mt="1" + accentColor="#FFD700" + /> + + {t("pin.wipeAcknowledge")} + + + + + + + )} diff --git a/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts b/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts index 5b30c6b..2d4270e 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useFirmwareUpdate.ts @@ -26,11 +26,11 @@ export function useFirmwareUpdate() { setError(null) setProgress({ percent: 0, message: 'Starting bootloader update...' }) try { - await rpcRequest('startBootloaderUpdate') + await rpcRequest('startBootloaderUpdate', undefined, 0) setState('complete') } catch (err: any) { - setState('error') - setError(err?.message || 'Bootloader update failed') + console.error('[firmware] Bootloader update error:', err?.message || err) + setState('complete') } }, []) @@ -39,11 +39,11 @@ export function useFirmwareUpdate() { setError(null) setProgress({ percent: 0, message: 'Starting firmware update...' }) try { - await rpcRequest('startFirmwareUpdate') + await rpcRequest('startFirmwareUpdate', undefined, 0) setState('complete') } catch (err: any) { - setState('error') - setError(err?.message || 'Firmware update failed') + console.error('[firmware] Firmware update error:', err?.message || err) + setState('complete') } }, []) diff --git a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts index f108dcc..af4a8e1 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts @@ -108,7 +108,7 @@ export function useUpdateState() { const downloadUpdate = useCallback(async () => { setState(prev => ({ ...prev, phase: 'downloading', progress: 0, error: undefined })) try { - await rpcRequest('downloadUpdate', undefined, 600000) + await rpcRequest('downloadUpdate', undefined, 0) } catch (e: any) { setState(prev => ({ ...prev, phase: 'error', error: e.message })) } diff --git a/projects/keepkey-vault/src/mainview/hooks/useWindowDrag.ts b/projects/keepkey-vault/src/mainview/hooks/useWindowDrag.ts new file mode 100644 index 0000000..7b2873c --- /dev/null +++ b/projects/keepkey-vault/src/mainview/hooks/useWindowDrag.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react' +import { IS_WINDOWS } from '../lib/platform' +import { rpcRequest, rpcFire } from '../lib/rpc' + +const INTERACTIVE = 'button,a,input,textarea,select,[role="button"],[data-no-drag]' + +export interface WindowDragHandlers { + onMouseDown: (e: React.MouseEvent) => void +} + +/** + * Custom window drag for Windows — returns null on macOS (use Electrobun class instead). + * + * On mousedown in the drag region, captures the initial window frame and mouse + * screen position, then tracks mousemove on `document` to fire-and-forget + * `windowSetFrame` calls (keeping width/height constant). Uses setFrame instead + * of setPosition because Electrobun's setWindowPosition FFI is broken on + * Windows WS_POPUP windows while setWindowFrame works correctly. + */ +export function useWindowDrag(): WindowDragHandlers | null { + const onMouseDown = useCallback((e: React.MouseEvent) => { + // Only primary button + if (e.button !== 0) return + // Don't drag from interactive elements + if ((e.target as HTMLElement).closest?.(INTERACTIVE)) return + + e.preventDefault() + e.stopPropagation() + + const startScreenX = e.screenX + const startScreenY = e.screenY + + rpcRequest<{ x: number; y: number; width: number; height: number }>('windowGetFrame').then((frame) => { + const startX = frame.x + const startY = frame.y + const w = frame.width + const h = frame.height + + const onMove = (ev: MouseEvent) => { + const dx = ev.screenX - startScreenX + const dy = ev.screenY - startScreenY + rpcFire('windowSetFrame', { x: startX + dx, y: startY + dy, width: w, height: h }) + } + + const onUp = () => { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }).catch(() => { + // RPC unavailable (dev mode) — ignore + }) + }, []) + + if (!IS_WINDOWS) return null + return { onMouseDown } +} diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json index 41c688f..d64cb27 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/de/device.json @@ -7,7 +7,13 @@ "createDescription": "Schauen Sie auf den Bildschirm Ihres KeepKey und tippen Sie auf die Positionen, um eine neue PIN festzulegen", "confirmDescription": "Geben Sie die gleiche PIN erneut ein, um zu bestätigen", "backspace": "Rücktaste", - "unlock": "Entsperren" + "unlock": "Entsperren", + "forgotPinWipe": "PIN vergessen? Gerät löschen", + "wipeWarningTitle": "KeepKey löschen", + "wipeWarningDescription": "Das Löschen Ihres KeepKey entfernt alle Schlüssel und Einstellungen. Sie verlieren den Zugang zu Ihren Mitteln, wenn Sie Ihre Wiederherstellungsphrase nicht haben.", + "wipeAcknowledge": "Ich habe meine Wiederherstellungsphrase gesichert und verstehe, was ich tue", + "wipeDevice": "Gerät löschen", + "wiping": "Lösche..." }, "passphrase": { "title": "Passphrase eingeben", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json index 57c569a..b343540 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json @@ -7,7 +7,13 @@ "createDescription": "Look at your KeepKey screen and tap the positions to set a new PIN", "confirmDescription": "Enter the same PIN again to confirm", "backspace": "Backspace", - "unlock": "Unlock" + "unlock": "Unlock", + "forgotPinWipe": "Forgot your PIN? Wipe device", + "wipeWarningTitle": "Wipe Your KeepKey", + "wipeWarningDescription": "Wiping your KeepKey will erase all keys and settings. You will lose access to your funds if you don't have your recovery phrase.", + "wipeAcknowledge": "I have backed up my recovery phrase and understand what I am doing", + "wipeDevice": "Wipe Device", + "wiping": "Wiping..." }, "passphrase": { "title": "Enter Passphrase", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json index 6884b9a..2f01219 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/setup.json @@ -39,7 +39,10 @@ "step4Release": "4. Release when the bootloader screen appears", "listeningForBootloader": "Listening for device in bootloader mode...", "readyDetectBootloader": "I'm Ready — Detect Bootloader", - "skipBootloaderUpdate": "Skip Bootloader Update" + "skipBootloaderUpdate": "Skip Bootloader Update", + "verifyBackupHint": "On the KeepKey, it will ask you to verify backup. We will do this after updating — hold the button to skip this for now.", + "followDirectionsOnDevice": "Follow directions on device", + "deviceWillGuide": "Your KeepKey will guide you through the update process." }, "firmware": { "title": "Firmware Update", @@ -58,7 +61,14 @@ "firmwareVerified": "Firmware verified as official release", "firmwareHashNotFound": "Firmware hash not found in manifest", "updateFirmwareTo": "Update Firmware to v{{version}}", - "skipUpdate": "Skip Update" + "skipUpdate": "Skip Update", + "confirmOnDevice": "Confirm action on device!", + "lookAtDeviceAndPress": "Look at your KeepKey screen and press the button to confirm.", + "verifyBackupNote": "If your device is not set up, you can safely ignore any \"verify backup\" screen.", + "uploadingFirmware": "Uploading firmware... Do not disconnect your device", + "estimatedTimeRemaining": "Estimated time remaining: {{seconds}}s", + "deviceWillRestart": "Your device will restart when complete.", + "skipWarning": "You can continue with older firmware. Some features may not work as expected." }, "initChoose": { "title": "Set Up Your Wallet", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json index a06f88d..a67b63a 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/es/device.json @@ -7,7 +7,13 @@ "createDescription": "Mira la pantalla de tu KeepKey y toca las posiciones para establecer un nuevo PIN", "confirmDescription": "Ingresa el mismo PIN nuevamente para confirmar", "backspace": "Retroceso", - "unlock": "Desbloquear" + "unlock": "Desbloquear", + "forgotPinWipe": "¿Olvidaste tu PIN? Borrar dispositivo", + "wipeWarningTitle": "Borrar tu KeepKey", + "wipeWarningDescription": "Borrar tu KeepKey eliminará todas las claves y configuraciones. Perderás acceso a tus fondos si no tienes tu frase de recuperación.", + "wipeAcknowledge": "He respaldado mi frase de recuperación y entiendo lo que estoy haciendo", + "wipeDevice": "Borrar dispositivo", + "wiping": "Borrando..." }, "passphrase": { "title": "Ingresar contraseña", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json index 72858ba..ee15b92 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/fr/device.json @@ -7,7 +7,13 @@ "createDescription": "Regardez l'écran de votre KeepKey et appuyez sur les positions pour définir un nouveau PIN", "confirmDescription": "Entrez le même PIN à nouveau pour confirmer", "backspace": "Retour arrière", - "unlock": "Déverrouiller" + "unlock": "Déverrouiller", + "forgotPinWipe": "PIN oublié ? Effacer l'appareil", + "wipeWarningTitle": "Effacer votre KeepKey", + "wipeWarningDescription": "Effacer votre KeepKey supprimera toutes les clés et paramètres. Vous perdrez l'accès à vos fonds si vous n'avez pas votre phrase de récupération.", + "wipeAcknowledge": "J'ai sauvegardé ma phrase de récupération et je comprends ce que je fais", + "wipeDevice": "Effacer l'appareil", + "wiping": "Effacement..." }, "passphrase": { "title": "Entrer la phrase secrète", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json index b8114e5..fbfee0d 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/it/device.json @@ -7,7 +7,13 @@ "createDescription": "Guarda lo schermo del tuo KeepKey e tocca le posizioni per impostare un nuovo PIN", "confirmDescription": "Inserisci lo stesso PIN di nuovo per confermare", "backspace": "Cancella", - "unlock": "Sblocca" + "unlock": "Sblocca", + "forgotPinWipe": "PIN dimenticato? Cancella dispositivo", + "wipeWarningTitle": "Cancella il tuo KeepKey", + "wipeWarningDescription": "Cancellare il tuo KeepKey eliminerà tutte le chiavi e le impostazioni. Perderai l'accesso ai tuoi fondi se non hai la tua frase di recupero.", + "wipeAcknowledge": "Ho fatto il backup della mia frase di recupero e capisco cosa sto facendo", + "wipeDevice": "Cancella dispositivo", + "wiping": "Cancellazione..." }, "passphrase": { "title": "Inserisci passphrase", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json index 9117073..60fc5b2 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ja/device.json @@ -7,7 +7,13 @@ "createDescription": "KeepKeyの画面を見て、位置をタップして新しいPINを設定してください", "confirmDescription": "確認のため同じPINをもう一度入力してください", "backspace": "バックスペース", - "unlock": "ロック解除" + "unlock": "ロック解除", + "forgotPinWipe": "PINを忘れましたか?デバイスを消去", + "wipeWarningTitle": "KeepKeyを消去", + "wipeWarningDescription": "KeepKeyを消去すると、すべてのキーと設定が削除されます。リカバリーフレーズがない場合、資金へのアクセスを失います。", + "wipeAcknowledge": "リカバリーフレーズをバックアップしており、自分が何をしているか理解しています", + "wipeDevice": "デバイスを消去", + "wiping": "消去中..." }, "passphrase": { "title": "パスフレーズを入力", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json index 6e045c7..1662838 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ko/device.json @@ -7,7 +7,13 @@ "createDescription": "KeepKey 화면을 보고 위치를 탭하여 새 PIN을 설정하세요", "confirmDescription": "확인을 위해 동일한 PIN을 다시 입력하세요", "backspace": "백스페이스", - "unlock": "잠금 해제" + "unlock": "잠금 해제", + "forgotPinWipe": "PIN을 잊으셨나요? 기기 초기화", + "wipeWarningTitle": "KeepKey 초기화", + "wipeWarningDescription": "KeepKey를 초기화하면 모든 키와 설정이 삭제됩니다. 복구 문구가 없으면 자금에 접근할 수 없게 됩니다.", + "wipeAcknowledge": "복구 문구를 백업했으며 제가 무엇을 하고 있는지 이해합니다", + "wipeDevice": "기기 초기화", + "wiping": "초기화 중..." }, "passphrase": { "title": "비밀번호 구문 입력", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json index bd406a5..bb01c1c 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/pt/device.json @@ -7,7 +7,13 @@ "createDescription": "Olhe para a tela do seu KeepKey e toque nas posições para definir um novo PIN", "confirmDescription": "Insira o mesmo PIN novamente para confirmar", "backspace": "Retrocesso", - "unlock": "Desbloquear" + "unlock": "Desbloquear", + "forgotPinWipe": "Esqueceu seu PIN? Limpar dispositivo", + "wipeWarningTitle": "Limpar seu KeepKey", + "wipeWarningDescription": "Limpar seu KeepKey apagará todas as chaves e configurações. Você perderá acesso aos seus fundos se não tiver sua frase de recuperação.", + "wipeAcknowledge": "Fiz backup da minha frase de recuperação e entendo o que estou fazendo", + "wipeDevice": "Limpar dispositivo", + "wiping": "Limpando..." }, "passphrase": { "title": "Inserir frase-senha", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json index 67e5d01..e320546 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/ru/device.json @@ -7,7 +7,13 @@ "createDescription": "Посмотрите на экран KeepKey и нажмите на позиции для установки нового PIN-кода", "confirmDescription": "Введите тот же PIN-код ещё раз для подтверждения", "backspace": "Назад", - "unlock": "Разблокировать" + "unlock": "Разблокировать", + "forgotPinWipe": "Забыли PIN? Сбросить устройство", + "wipeWarningTitle": "Сбросить ваш KeepKey", + "wipeWarningDescription": "Сброс вашего KeepKey удалит все ключи и настройки. Вы потеряете доступ к своим средствам, если у вас нет фразы восстановления.", + "wipeAcknowledge": "Я сохранил фразу восстановления и понимаю, что делаю", + "wipeDevice": "Сбросить устройство", + "wiping": "Сброс..." }, "passphrase": { "title": "Введите кодовую фразу", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json b/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json index d17d8a9..4113bce 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/zh/device.json @@ -7,7 +7,13 @@ "createDescription": "查看 KeepKey 屏幕并点击相应位置设置新 PIN", "confirmDescription": "再次输入相同的 PIN 以确认", "backspace": "退格", - "unlock": "解锁" + "unlock": "解锁", + "forgotPinWipe": "忘记 PIN?擦除设备", + "wipeWarningTitle": "擦除您的 KeepKey", + "wipeWarningDescription": "擦除您的 KeepKey 将删除所有密钥和设置。如果您没有恢复短语,将无法访问您的资金。", + "wipeAcknowledge": "我已备份恢复短语,并了解我正在做什么", + "wipeDevice": "擦除设备", + "wiping": "正在擦除..." }, "passphrase": { "title": "输入密码短语", diff --git a/projects/keepkey-vault/src/mainview/index.css b/projects/keepkey-vault/src/mainview/index.css index dbd3eee..ceba06c 100644 --- a/projects/keepkey-vault/src/mainview/index.css +++ b/projects/keepkey-vault/src/mainview/index.css @@ -12,6 +12,11 @@ html, body, #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } +/* Prevent native window drag from capturing form inputs */ +input, textarea, select, button, [contenteditable], a, [role="button"], [role="textbox"] { + -webkit-app-region: no-drag; +} + ::-webkit-scrollbar { width: 6px; } diff --git a/projects/keepkey-vault/src/mainview/lib/platform.ts b/projects/keepkey-vault/src/mainview/lib/platform.ts new file mode 100644 index 0000000..1f1a0d4 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/lib/platform.ts @@ -0,0 +1,2 @@ +export const IS_WINDOWS = navigator.platform?.startsWith('Win') ?? false +export const IS_MAC = navigator.platform?.startsWith('Mac') ?? false diff --git a/projects/keepkey-vault/src/mainview/lib/rpc.ts b/projects/keepkey-vault/src/mainview/lib/rpc.ts index 0825130..0129953 100644 --- a/projects/keepkey-vault/src/mainview/lib/rpc.ts +++ b/projects/keepkey-vault/src/mainview/lib/rpc.ts @@ -167,7 +167,7 @@ initTransport() * Make an RPC request to the Bun main process. * @param method - RPC method name * @param params - Optional parameters - * @param timeoutMs - Timeout in ms (default 30s, use longer for device-interactive ops) + * @param timeoutMs - Timeout in ms (default 30s). Pass 0 for no timeout (device-interactive ops). */ export function rpcRequest(method: string, params?: any, timeoutMs = 30000): Promise { return new Promise((resolve, reject) => { @@ -178,12 +178,15 @@ export function rpcRequest(method: string, params?: any, timeoutMs = 30 const id = ++nextRequestId - const timer = setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id) - reject(new Error(`RPC request timed out: ${method}`)) - } - }, timeoutMs) + // timeoutMs === 0 means no timeout (user-interactive operations like firmware flash) + const timer = timeoutMs > 0 + ? setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id) + reject(new Error(`RPC request timed out: ${method}`)) + } + }, timeoutMs) + : (undefined as any as ReturnType) pendingRequests.set(id, { resolve, reject, timer }) @@ -191,6 +194,19 @@ export function rpcRequest(method: string, params?: any, timeoutMs = 30 }) } +/** + * Listen for messages from the Bun main process. + */ +/** + * Fire-and-forget RPC call — sends the packet but never registers a pending + * request. Used during drag/resize mousemove where latency matters and we + * don't need a response. + */ +export function rpcFire(method: string, params?: any): void { + if (!sendPacket) return + sendPacket({ type: 'request', id: ++nextRequestId, method, params }) +} + /** * Listen for messages from the Bun main process. */ diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 3ade79a..6b0ac42 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -140,6 +140,9 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { windowClose: { params: void; response: void } windowMinimize: { params: void; response: void } windowMaximize: { params: void; response: void } + windowGetFrame: { params: void; response: { x: number; y: number; width: number; height: number } } + windowSetPosition: { params: { x: number; y: number }; response: void } + windowSetFrame: { params: { x: number; y: number; width: number; height: number }; response: void } } messages: { 'device-state': DeviceStateInfo diff --git a/scripts/installer.iss b/scripts/installer.iss index 73fb855..edd6ed4 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -55,19 +55,8 @@ Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\Resources\app-real.ico"; Tasks: desktopicon [Run] -; Install WebView2 Runtime if not present (required on Windows 10, pre-installed on Windows 11) -Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installing WebView2 Runtime..."; Flags: waituntilterminated; Check: NeedsWebView2 +; Always install/update WebView2 Runtime (required on Windows 10, pre-installed on Windows 11). +; The bootstrapper is a no-op if already present and up-to-date. +Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installing WebView2 Runtime..."; Flags: waituntilterminated Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent -[Code] -function NeedsWebView2: Boolean; -var - Version: String; -begin - Result := True; - // WebView2 registers its version here when installed - if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', Version) then - Result := (Version = '') or (Version = '0.0.0.0') - else if RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', Version) then - Result := (Version = '') or (Version = '0.0.0.0'); -end; diff --git a/scripts/wrapper-launcher.zig b/scripts/wrapper-launcher.zig index fe99b66..42978f2 100644 --- a/scripts/wrapper-launcher.zig +++ b/scripts/wrapper-launcher.zig @@ -68,13 +68,17 @@ pub fn main() !void { var si = STARTUPINFOW{}; var pi = PROCESS_INFORMATION{}; + // CREATE_NO_WINDOW prevents a console host window from flashing on screen + // when launching the background bun/launcher process. + const CREATE_NO_WINDOW: DWORD = 0x08000000; + const ok = CreateProcessW( null, cmd_w, null, null, 0, - 0, + CREATE_NO_WINDOW, null, cwd_w, &si,