Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
9d3004f
fix: bootloader/firmware wizard UX — correct version detection and co…
BitHighlander Mar 2, 2026
1610751
fix: declutter firmware update UI — consolidate redundant messaging
BitHighlander Mar 2, 2026
50c5574
fix: firmware update requires bootloader mode — add entry flow
BitHighlander Mar 2, 2026
2b02a90
fix: compact firmware step layout, auto-listen, keep wizard on discon…
BitHighlander Mar 3, 2026
668f246
fix: remove RPC timeouts for device-interactive ops, refine setup UX
BitHighlander Mar 3, 2026
3a6efa0
chore: bump package.json and electrobun.config version to 1.1.0
BitHighlander Mar 3, 2026
b7aca04
fix: add window drag region to setup wizard, compact all step layouts
BitHighlander Mar 3, 2026
658eda8
fix: keep wizard open through all device state transitions
BitHighlander Mar 3, 2026
c1ba716
fix: wizard drag region preserves window resize handles
BitHighlander Mar 3, 2026
d155190
feat: cross-platform window drag/resize for Windows
Mar 3, 2026
f718c39
fix: replace timer-based wizard advancement with device-state-gated l…
BitHighlander Mar 3, 2026
f2f1bbe
fix: delay auto prompt-pin after firmware flash to prevent Invalid PI…
BitHighlander Mar 3, 2026
e99e57b
fix: clear keyring on wallet teardown to prevent "already-connected" …
BitHighlander Mar 3, 2026
4dd4ce6
fix: move window drag region to nav bar only, unblock all form inputs
BitHighlander Mar 3, 2026
6447cd1
fix: revert titleBarStyle hidden — fixes broken keyboard input in WKW…
BitHighlander Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion projects/keepkey-vault/electrobun.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion projects/keepkey-vault/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 46 additions & 26 deletions projects/keepkey-vault/src/bun/engine-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
})
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ if (!restApiEnabled) console.log('[Vault] REST API disabled (enable in Settings

// ── RPC Bridge (Electrobun UI ↔ Bun) ─────────────────────────────────
const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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 ──────────────────────────────────────
Expand Down Expand Up @@ -285,6 +285,10 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
},
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 }
Expand Down Expand Up @@ -1219,6 +1223,9 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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: {},
},
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 26 additions & 28 deletions projects/keepkey-vault/src/mainview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<NavTab>("vault")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"
Expand All @@ -410,7 +421,7 @@ function App() {
) : null

const pinOverlay = pinRequestType && !passphraseRequested ? (
<PinEntry type={pinRequestType} onSubmit={handlePinSubmit} onCancel={handlePinCancel} />
<PinEntry type={pinRequestType} onSubmit={handlePinSubmit} onCancel={handlePinCancel} onWipe={handlePinWipe} />
) : null

const charOverlay = (charRequest || recoveryError) ? (
Expand Down Expand Up @@ -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 = (
<Flex
position="fixed"
top={0}
right={0}
h="50px"
align="center"
pr="4"
zIndex={Z.nav + 1}
>
<TrafficLights />
</Flex>
)
// SplashNav provides a drag-enabled nav bar with traffic lights for
// splash / setup / claimed phases (where TopNav isn't rendered).
const splashNav = <SplashNav />

const resizeHandles = <WindowResizeHandles />

// Always-visible update banner (all phases)
const updateBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" ? (
Expand All @@ -474,8 +472,8 @@ function App() {
// Watch-only mode: render dashboard with cached data (read-only)
if (watchOnlyMode) {
return (
<>{windowControls}{updateBanner}{firmwareDropZone}
<Flex direction="column" h="100vh" bg="transparent" color="kk.textPrimary">
<>{resizeHandles}{updateBanner}{firmwareDropZone}
<Flex direction="column" h="100vh" bg="kk.bg" color="kk.textPrimary">
<TopNav
label={watchOnlyLabel || "KeepKey"}
connected={false}
Expand All @@ -496,7 +494,7 @@ function App() {

if (phase === "claimed") {
return (
<>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay}
<>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay}
<SplashScreen statusText={t("keepkeyDetected", { ns: "nav" })} variant="claimed">
<DeviceClaimedDialog error={deviceState.error || t("claimed.defaultError", { ns: "device" })} />
</SplashScreen>
Expand All @@ -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}
<SplashScreen
statusText={
needsPin ? t("unlockYourKeepKey", { ns: "nav" })
Expand All @@ -537,8 +535,8 @@ function App() {

if (phase === "setup") {
return (
<>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay}
<OobSetupWizard onComplete={() => setWizardComplete(true)} />
<>{splashNav}{resizeHandles}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay}
<OobSetupWizard onComplete={() => { setWizardComplete(true); setSetupInProgress(false) }} onSetupInProgress={setSetupInProgress} />
</>
)
}
Expand All @@ -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" && (
<SplashScreen statusText={t("loadingPortfolio", { ns: "nav" })} variant="connecting" />
)}
<Flex direction="column" h="100vh" bg="transparent" color="kk.textPrimary"
<Flex direction="column" h="100vh" bg="kk.bg" color="kk.textPrimary"
{...(!portfolioLoaded && activeTab === "vault" ? { position: "absolute", w: 0, h: 0, overflow: "hidden" } as const : {})}
>
<TopNav
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Text, Flex, VStack, Image } from "@chakra-ui/react"
import { Box, Text, Flex, VStack } from "@chakra-ui/react"
import { useTranslation } from "react-i18next"
import connectSvg from "../assets/svg/connect-keepkey.svg"
import connectSvgRaw from "../assets/svg/connect-keepkey.svg?raw"

export function DeviceClaimedDialog({ error }: { error: string }) {
const { t } = useTranslation("device")
Expand Down Expand Up @@ -39,7 +39,7 @@ export function DeviceClaimedDialog({ error }: { error: string }) {
</Box>

<Flex justify="center" py={2}>
<Image src={connectSvg} alt="Unplug and replug KeepKey" w="80px" h="80px" />
<Box w="80px" h="80px" dangerouslySetInnerHTML={{ __html: connectSvgRaw }} sx={{ '& svg': { width: '100%', height: '100%' } }} />
</Flex>

<Text fontSize="sm" color="gray.400" fontWeight="semibold">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading