diff --git a/Makefile b/Makefile index 93355e0..a7f3237 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ dev: install cd $(PROJECT_DIR) && bun run dev dev-hmr: install - -lsof -ti :5173 | xargs kill -9 2>/dev/null || true + -lsof -ti :5177 | xargs kill -9 2>/dev/null || true -pkill -f "electrobun dev" 2>/dev/null || true cd $(PROJECT_DIR) && bun run dev:hmr diff --git a/README.md b/README.md index ef08d79..32987bd 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,144 @@ # KeepKey Vault v11 -Desktop hardware wallet management UI built with **Electrobun** + **React 18** + **Chakra UI 3.0**. +Desktop hardware wallet application built with **Electrobun** (Bun main process + system WebView) + **React 18** + **Chakra UI 3.0**. ## Architecture ``` Electrobun Desktop App -├── Main Process (Bun) ──── HTTP REST ──── keepkey-desktop (port 1646) ──── KeepKey Device -└── WebView (React + Chakra UI + Vite) +├── Main Process (Bun) ──── USB (HID + WebUSB) ──── KeepKey Device +│ ├── Engine Controller (device lifecycle, USB events) +│ ├── SQLite persistence (bun:sqlite) +│ ├── Pioneer API integration (balance/portfolio) +│ └── REST API on port 1646 (opt-in, KEEPKEY_REST_API=true) +└── WebView (React 18 + Chakra UI 3.0 + Vite) + └── Tab-based state machine (dashboard | addresses | settings) ``` -- **Main process** (`src/bun/index.ts`): Bun runtime, window management, RPC bridge -- **WebView** (`src/mainview/`): React 18 + Chakra UI 3.0, client-side routing -- **API layer**: Direct REST client to keepkey-desktop on port 1646 (no Pioneer SDK) -- **Theme**: Black/gold (#000/#111/#FFD700) matching KeepKey branding +- The vault talks directly to the KeepKey device via USB -- no external desktop app dependency. +- All device operations go through Electrobun RPC (no REST required for normal use). +- The REST API on port 1646 is opt-in (`KEEPKEY_REST_API=true`), compatible with the `kkapi://` protocol. +- No React Router -- the UI uses a simple tab-based state machine. +- Theme: black/gold (#000/#111/#FFD700) matching KeepKey branding. ## Prerequisites -- [Bun](https://bun.sh) >= 1.0 -- [keepkey-desktop](https://github.com/keepkey/keepkey-desktop) running on port 1646 +- [Bun](https://bun.sh) >= 1.3.5 +- [Yarn](https://yarnpkg.com) (for hdwallet monorepo build) - KeepKey hardware wallet connected via USB +- For signing/notarization: Apple Developer ID certificate + Xcode CLI tools ## Quick Start ```bash -make install # Install dependencies -make dev # Build and launch app -make dev-hmr # Dev mode with Vite hot reload +make vault # Build modules + install deps + run dev mode +make dev # Build and run in dev mode +make dev-hmr # Dev mode with Vite HMR ``` ## Make Targets | Target | Description | |--------|-------------| -| `make install` | Install dependencies | +| `make vault` | Build modules + install deps + run dev mode | | `make dev` | Build and run in dev mode | -| `make dev-hmr` | Dev mode with Vite HMR on port 5173 | -| `make build` | Production build | -| `make build-prod` | Production build (prod channel) | -| `make clean` | Remove build artifacts and node_modules | +| `make dev-hmr` | Dev mode with Vite HMR | +| `make build` | Development build (no signing) | +| `make build-signed` | Full pipeline: build, prune, DMG, sign, notarize | +| `make prune-bundle` | Prune app bundle (version-aware dedup + re-sign) | +| `make dmg` | Create DMG from existing build | +| `make upload-dmg` | Upload signed DMG to CI draft release | +| `make release` | Build, sign, and create new GitHub release | +| `make modules-build` | Build hdwallet + proto-tx-builder from source | +| `make audit` | Generate dependency manifest + SBOM | +| `make clean` | Remove all build artifacts | ## Project Structure ``` keepkey-vault-v11/ -├── Makefile # Top-level make targets -├── hdwallet/ # Git submodule: keepkey/hdwallet +├── Makefile +├── modules/ +│ ├── hdwallet/ # Git submodule: keepkey/hdwallet (yarn+lerna) +│ ├── proto-tx-builder/ # Git submodule: @keepkey/proto-tx-builder +│ ├── keepkey-firmware/ # Git submodule: device firmware (C, CMake) +│ └── device-protocol/ # Git submodule: protobuf definitions +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── COIN-ADDITION-GUIDE.md +│ ├── coins/ +│ └── firmware/README.md +├── firmware/ # Firmware manifest + binaries └── projects/ - └── keepkey-vault/ # Electrobun app - ├── electrobun.config.ts # App identity & build config - ├── vite.config.ts # Vite build config - ├── package.json + └── keepkey-vault/ # Electrobun app + ├── electrobun.config.ts + ├── vite.config.ts + ├── entitlements.plist + ├── scripts/ # Build scripts (collect-externals, prune, etc.) + ├── docs/ # BUILD.md, API.md, ELECTROBUN.md └── src/ - ├── bun/index.ts # Main process (window, RPC) - ├── shared/types.ts # RPC type definitions - └── mainview/ - ├── main.tsx # React entry + Chakra Provider - ├── App.tsx # Router + layout shell - ├── theme.ts # Chakra 3.0 black/gold theme + ├── bun/ # Main process + │ ├── index.ts # Electrobun RPC + engine controller + REST + │ ├── engine-controller.ts # USB event-driven device lifecycle + │ ├── rest-api.ts # Bun.serve() REST API (opt-in) + │ ├── evm-rpc.ts # EVM chain RPC calls + │ └── ... # DB, Pioneer, TX builder modules + ├── shared/ + │ ├── rpc-schema.ts # Electrobun RPC type definitions + │ ├── types.ts # DeviceStateInfo, FirmwareProgress, etc. + │ └── chains.ts # Chain definitions + └── mainview/ # React frontend + ├── main.tsx # React entry + ChakraProvider + ├── App.tsx # Tab-based state machine (no router) ├── components/ - │ ├── layout/ # Header, Sidebar, StatusBar - │ ├── dashboard/ # Dashboard overview - │ ├── device/ # DeviceStatus, PinEntry, Settings - │ ├── addresses/ # Multi-chain address derivation - │ └── signing/ # Transaction signing - ├── hooks/ # useKeepKey, useApi - ├── services/ # keepkey-api.ts (REST client) - └── types/ # Frontend type definitions + │ ├── Dashboard.tsx + │ ├── Addresses.tsx + │ ├── TopNav.tsx + │ ├── SplashScreen.tsx + │ ├── OobSetupWizard.tsx + │ ├── DeviceSettings.tsx + │ └── device/ # PinEntry, PassphraseEntry, RecoveryWordEntry + ├── hooks/ # useDeviceState, useFirmwareUpdate, etc. + └── lib/ # rpc.ts (browser-side RPC transport) ``` -## keepkey-desktop API +## Supported Chains -The app communicates with keepkey-desktop's REST API on port 1646: +- **Bitcoin**: Multi-account, SegWit (p2wpkh, p2sh-p2wpkh, p2pkh) +- **Ethereum + 6 EVM L2s**: Polygon, Arbitrum, Optimism, Avalanche, BSC, Base +- **Cosmos ecosystem**: Cosmos, THORChain, Osmosis, Mayachain +- **Other**: Ripple (XRP), Binance (BNB), Solana +- **Custom EVM chains**: User-defined via Add Chain dialog -- **Auth**: `POST /auth/pair` (get Bearer token) -- **Addresses**: `/addresses/eth`, `/addresses/utxo`, `/addresses/cosmos`, etc. -- **Signing**: `/eth/sign-transaction`, `/utxo/sign-transaction`, `/cosmos/sign-amino`, etc. -- **System**: `/system/info/get-features`, `/system/apply-settings`, `/system/wipe-device`, etc. +## Tech Stack -See [docs/API.md](docs/API.md) for the full endpoint reference. +- **Runtime**: [Electrobun](https://electrobun.dev) (Bun + system WebView) +- **UI**: React 18 + Chakra UI 3.0 +- **Build**: Vite 6 +- **Device communication**: @keepkey/hdwallet-* (HID + WebUSB dual transport with automatic fallback) +- **Persistence**: SQLite (bun:sqlite) +- **Signing**: Apple codesign + notarize + staple -## Supported Chains +## Documentation -Bitcoin, Ethereum, Cosmos, THORChain, Osmosis, Litecoin, Dogecoin, Bitcoin Cash, Dash, Ripple, Mayachain, Binance +- [Build and Signing Guide](projects/keepkey-vault/docs/BUILD.md) +- [REST API Reference](projects/keepkey-vault/docs/API.md) +- [Electrobun Integration](projects/keepkey-vault/docs/ELECTROBUN.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Coin Addition Guide](docs/COIN-ADDITION-GUIDE.md) -## Tech Stack +## Submodules -- **Runtime**: [Electrobun](https://electrobun.dev) (Bun + system WebView, ~14MB bundle) -- **UI**: React 18 + Chakra UI 3.0 + Emotion -- **Build**: Vite 6 with HMR support -- **Routing**: React Router 7 -- **API**: Direct fetch to keepkey-desktop REST API +After cloning, initialize the git submodules: -## hdwallet Submodule +```bash +git submodule update --init --recursive +``` -The `hdwallet/` directory is a git submodule pointing to [keepkey/hdwallet](https://github.com/keepkey/hdwallet). To initialize after cloning: +The `modules/hdwallet/` submodule must be on the `master` branch (which has lodash/rxjs removed from source). Build with: ```bash -git submodule update --init --recursive +make modules-build ``` + +This builds proto-tx-builder (bun + tsc) then hdwallet (yarn install + yarn build). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 51b59d5..7678f37 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,7 +10,7 @@ │ │ Main Process (Bun) │◄──────────►│ WebView (React) │ │ │ │ │ │ Chakra UI 3.0 │ │ │ │ EngineController │ │ Vite 6 + HMR │ │ -│ │ REST API Server │ │ React Router 7 │ │ +│ │ REST API Server │ │ Tab state machine │ │ │ │ SQLite Cache │ └────────────────────┘ │ │ │ Auth Store │ │ │ └───────────┬───────────┘ │ @@ -38,24 +38,26 @@ |------|---------| | `index.ts` | App entry: window creation, RPC bridge, event wiring | | `engine-controller.ts` | Device lifecycle: connect, pair, PIN/passphrase, firmware update | -| `rest-api.ts` | HTTP REST API (port 1646, opt-in, keepkey-desktop compatible) | +| `rest-api.ts` | HTTP REST API (port 1646, opt-in, kkapi:// compatible) | | `auth.ts` | Bearer token auth, pairing requests, signing approval | -| `db.ts` | SQLite persistence (balances cache, pubkeys, settings, custom tokens) | +| `db.ts` | SQLite persistence (balances, pubkeys, settings, custom tokens/chains) | | `pioneer.ts` | Pioneer API client (portfolio balances, tx building, market data) | -| `txbuilder.ts` | Transaction construction (UTXO, EVM, Cosmos) | -| `schemas.ts` | Zod schemas for REST API request/response validation | +| `txbuilder/` | Transaction construction (UTXO, EVM, Cosmos) | +| `schemas.ts` | Zod schemas for REST API validation | +| `evm-rpc.ts` | Direct EVM chain RPC calls (balance, nonce, gas, broadcast) | +| `evm-addresses.ts` | EVM multi-address manager | +| `btc-accounts.ts` | BTC multi-account manager | +| `camera.ts` | QR code scanning via system camera | ### Frontend (`src/mainview/`) -| Directory | Purpose | -|-----------|---------| -| `components/layout/` | Header, Sidebar, StatusBar | -| `components/dashboard/` | Portfolio overview, chain balances | -| `components/device/` | DeviceStatus, PinEntry, Settings | -| `components/addresses/` | Multi-chain address derivation | -| `components/signing/` | Transaction review & signing | -| `hooks/` | useKeepKey (RPC wrapper), useApi (REST client) | -| `services/` | keepkey-api.ts REST client | +| Directory/File | Purpose | +|----------------|---------| +| `App.tsx` | Tab-based state machine (dashboard, addresses, settings) | +| `components/` | TopNav, Dashboard, SendForm, OobSetupWizard, SplashScreen, etc. | +| `components/device/` | PinEntry, PassphraseEntry, RecoveryWordEntry | +| `hooks/` | useDeviceState, useBtcAccounts, useEvmAddresses, useFirmwareUpdate | +| `lib/rpc.ts` | Browser-side Electrobun RPC transport | ### CLI (`projects/keepkey-cli/`) @@ -74,12 +76,11 @@ Standalone Bun/TypeScript CLI. Same `@keepkey/hdwallet-*` packages as vault, but | `keepkey-firmware` | C (CMake) | Device firmware — protobuf handlers, crypto, OLED UI | | `device-protocol` | protobuf | `.proto` message definitions shared by firmware + hdwallet | -> **Note on hdwallet lodash/rxjs dependencies**: `hdwallet-core` imports `lodash` and `rxjs`; -> `hdwallet-keepkey` imports `lodash`. These are declared dependencies in each package's -> `package.json` and are required at compile time. They are **stripped at bundle time** by -> `collect-externals.ts` (pruning step) so they do not ship in the final app. A future cleanup -> should inline the ~6 usages (`isObject`, `cloneDeep`, `omit`, `takeFirstOfManyEvents`) and -> remove the deps entirely from source. See `keepkey/hdwallet` for details. +> **Note on hdwallet lodash/rxjs dependencies**: The lodash/rxjs imports have been removed from +> `hdwallet-core` and `hdwallet-keepkey` source code on the `master` branch (commit `179c5668`). +> `isObject` was inlined, `cloneDeep` replaced with `structuredClone`, `omit` replaced with a +> local helper, and `takeFirstOfManyEvents` (dead code) was removed. The build scripts +> (`collect-externals.ts`) still strip lodash/rxjs as a safety measure. ## Transport Layer diff --git a/firmware/.gitignore b/firmware/.gitignore index 4a7f6a6..5bfb15e 100644 --- a/firmware/.gitignore +++ b/firmware/.gitignore @@ -1,5 +1,4 @@ # Firmware binaries — download via `make firmware-download` signed/*.bin -unsigned/*.bin # Keep directory structure !.gitkeep diff --git a/firmware/manifest.json b/firmware/manifest.json index 3e655ae..6c45918 100644 --- a/firmware/manifest.json +++ b/firmware/manifest.json @@ -39,7 +39,17 @@ "e6685ab14844d0a381d658d77e13d6145fe7ae80469e5a5360210ae9c3447a77": "2.1.3", "fe98454e7ebd4aef4a6db5bd4c60f52cf3f58b974283a7c1e1fcc5fea02cf3eb": "2.1.4" }, + "unsigned_firmware": { + "solana": { + "version": "7.10.0-solana", + "filename": "firmware.keepkey.solana-8acfd898.bin", + "sha256_payload": "8ad2ecd35ad0d9714a3592edfaa6343c0c7c63dc33677907cd8eb9ffb4f8bea7", + "sha256_full": "0906d9343c1a971b069715d84b78b4fce4ff4ba095e2b63ca7de18e6dd60a686", + "note": "Unsigned firmware with Solana (Ed25519) support — requires bootloader policy override" + } + }, "firmware_hashes": { + "8ad2ecd35ad0d9714a3592edfaa6343c0c7c63dc33677907cd8eb9ffb4f8bea7": "7.10.0-solana", "958764cf3baa53eec0002eab9c54e02ce6f5fdab71e7efbbe723f958e26ff419": "7.10.0", "24cca93ef5e7907dc6d8405b8ab9800d4e072dd9259138cf7679107985b88137": "7.9.3", "9e691874bb6966aa0616d36b60489b82fab166d96e5166518eaa3e11468bf6a8": "7.9.1", diff --git a/firmware/unsigned/firmware.keepkey.solana-8acfd898.bin b/firmware/unsigned/firmware.keepkey.solana-8acfd898.bin new file mode 100755 index 0000000..6968d1e Binary files /dev/null and b/firmware/unsigned/firmware.keepkey.solana-8acfd898.bin differ diff --git a/modules/hdwallet b/modules/hdwallet index 8f12b16..fd141c7 160000 --- a/modules/hdwallet +++ b/modules/hdwallet @@ -1 +1 @@ -Subproject commit 8f12b16c49735519ec97384507c69c6d09df22ea +Subproject commit fd141c7f6e346117c07ccad68e3d8b2892b33de9 diff --git a/projects/keepkey-sdk/lib/index.d.ts b/projects/keepkey-sdk/lib/index.d.ts index 163206f..e8ef801 100644 --- a/projects/keepkey-sdk/lib/index.d.ts +++ b/projects/keepkey-sdk/lib/index.d.ts @@ -128,6 +128,7 @@ export declare class KeepKeySdk { eth: { ethSignTransaction: (params: EthSignTxParams) => Promise; ethSignMessage: (params: EthSignMessageParams) => Promise; + ethSign: (params: EthSignMessageParams) => Promise; ethSignTypedData: (params: EthSignTypedDataParams) => Promise; ethVerifyMessage: (params: EthVerifyMessageParams) => Promise; }; diff --git a/projects/keepkey-sdk/lib/index.d.ts.map b/projects/keepkey-sdk/lib/index.d.ts.map index 8480fa6..4096b2f 100644 --- a/projects/keepkey-sdk/lib/index.d.ts.map +++ b/projects/keepkey-sdk/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/projects/keepkey-sdk/lib/index.js b/projects/keepkey-sdk/lib/index.js index 20ba74c..ae4c08a 100644 --- a/projects/keepkey-sdk/lib/index.js +++ b/projects/keepkey-sdk/lib/index.js @@ -74,6 +74,8 @@ class KeepKeySdk { this.eth = { ethSignTransaction: (params) => this.client.post('/eth/sign-transaction', params), ethSignMessage: (params) => this.client.post('/eth/sign', params), + // v1 SDK compat alias (old clients call ethSign instead of ethSignMessage) + ethSign: (params) => this.client.post('/eth/sign', params), ethSignTypedData: (params) => this.client.post('/eth/sign-typed-data', params), ethVerifyMessage: (params) => this.client.post('/eth/verify', params), }; diff --git a/projects/keepkey-sdk/lib/index.js.map b/projects/keepkey-sdk/lib/index.js.map index 3218828..9befb03 100644 --- a/projects/keepkey-sdk/lib/index.js.map +++ b/projects/keepkey-sdk/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/projects/keepkey-sdk/package-lock.json b/projects/keepkey-sdk/package-lock.json new file mode 100644 index 0000000..4abc4b1 --- /dev/null +++ b/projects/keepkey-sdk/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "keepkey-vault-sdk", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keepkey-vault-sdk", + "version": "2.0.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/projects/keepkey-sdk/package.json b/projects/keepkey-sdk/package.json index af5dc81..148571e 100644 --- a/projects/keepkey-sdk/package.json +++ b/projects/keepkey-sdk/package.json @@ -1,6 +1,6 @@ { "name": "keepkey-vault-sdk", - "version": "2.0.1", + "version": "2.0.2", "description": "TypeScript SDK for KeepKey Vault REST API — zero dependencies, native fetch", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/projects/keepkey-sdk/src/index.ts b/projects/keepkey-sdk/src/index.ts index 6e5445e..390027c 100644 --- a/projects/keepkey-sdk/src/index.ts +++ b/projects/keepkey-sdk/src/index.ts @@ -247,6 +247,10 @@ export class KeepKeySdk { ethSignMessage: (params: EthSignMessageParams): Promise => this.client.post('/eth/sign', params), + // v1 SDK compat alias (old clients call ethSign instead of ethSignMessage) + ethSign: (params: EthSignMessageParams): Promise => + this.client.post('/eth/sign', params), + ethSignTypedData: (params: EthSignTypedDataParams): Promise => this.client.post('/eth/sign-typed-data', params), diff --git a/projects/keepkey-vault/docs/API.md b/projects/keepkey-vault/docs/API.md index 991f416..432e5c0 100644 --- a/projects/keepkey-vault/docs/API.md +++ b/projects/keepkey-vault/docs/API.md @@ -1,139 +1,428 @@ -# keepkey-desktop REST API Reference +# KeepKey Vault v11 API Reference + +KeepKey Vault v11 exposes two API layers for communicating with the KeepKey hardware wallet. + +## API Layers + +### Electrobun RPC (primary) + +The built-in frontend communicates with the Bun main process over Electrobun's WebSocket-based RPC. This is the primary interface and is always active. The schema is defined in `src/shared/rpc-schema.ts`. + +RPC calls use request/response semantics (`rpcRequest('method', params)`) and one-way push messages (`rpc.send['message-name'](payload)`). + +### REST API (opt-in, port 1646) + +An HTTP API for external applications (dApps, SDKs, CLI tools). Disabled by default. Enable it by setting `KEEPKEY_REST_API=true` in app settings or via the Settings UI. + +- Base URL: `http://localhost:1646` +- Compatible with the `kkapi://` protocol (maps to `localhost:1646`) +- All endpoints except health, ping, and pairing require `Authorization: Bearer ` header +- Signing endpoints require explicit user approval via the Electrobun UI before execution +- CORS is enabled for all origins (bearer-token auth model, not cookie-based) +- Swagger UI available at `http://localhost:1646/docs` +- OpenAPI spec at `http://localhost:1646/spec/swagger.json` + +--- + +## RPC Methods + +### Requests (WebView calls Bun) + +#### Device Lifecycle + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getDeviceState` | void | `DeviceStateInfo` | Current device connection state | +| `startBootloaderUpdate` | void | void | Enter bootloader and flash update | +| `startFirmwareUpdate` | void | void | Flash latest firmware | +| `flashFirmware` | void | void | Flash firmware (when in bootloader) | +| `analyzeFirmware` | `{ data: string }` | `FirmwareAnalysis` | Analyze a custom firmware binary | +| `flashCustomFirmware` | `{ data: string }` | void | Flash user-supplied firmware | +| `resetDevice` | `{ wordCount, pin, passphrase }` | void | Generate new seed on device | +| `recoverDevice` | `{ wordCount, pin, passphrase }` | void | Recover device via cipher recovery | +| `loadDevice` | `{ mnemonic, pin?, passphrase?, label? }` | void | Load a known mnemonic | +| `verifySeed` | `{ wordCount }` | `{ success, message }` | Verify seed backup | +| `applySettings` | `{ label?, usePassphrase?, autoLockDelayMs? }` | void | Change device settings | +| `changePin` | void | void | Start PIN change flow | +| `removePin` | void | void | Remove PIN protection | +| `sendPin` | `{ pin: string }` | void | Respond to PIN prompt | +| `sendPassphrase` | `{ passphrase: string }` | void | Respond to passphrase prompt | +| `sendCharacter` | `{ character: string }` | void | Send character during cipher recovery | +| `sendCharacterDelete` | void | void | Delete last character during recovery | +| `sendCharacterDone` | void | void | Confirm final word during recovery | + +#### Wallet Operations + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getFeatures` | void | any | Get device features (model, firmware, policies) | +| `ping` | `{ msg?: string }` | any | Ping the device | +| `wipeDevice` | void | any | Factory reset | +| `getPublicKeys` | paths array | any | Derive public keys / xpubs | + +#### Address Derivation + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `btcGetAddress` | `{ addressNList, coin?, scriptType?, showDisplay? }` | any | Bitcoin / UTXO address | +| `ethGetAddress` | `{ addressNList, showDisplay? }` | any | Ethereum / EVM address | +| `cosmosGetAddress` | `{ addressNList, showDisplay? }` | any | Cosmos address | +| `thorchainGetAddress` | `{ addressNList, showDisplay? }` | any | THORChain address | +| `mayachainGetAddress` | `{ addressNList, showDisplay? }` | any | Mayachain address | +| `osmosisGetAddress` | `{ addressNList, showDisplay? }` | any | Osmosis address | +| `xrpGetAddress` | `{ addressNList, showDisplay? }` | any | XRP address | +| `solanaGetAddress` | `{ addressNList, showDisplay? }` | any | Solana address | + +#### Transaction Signing + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `btcSignTx` | `{ coin, inputs, outputs, version?, locktime? }` | any | Sign UTXO transaction | +| `ethSignTx` | `{ addressNList, to, value, nonce, gasLimit, chainId, ... }` | any | Sign EVM transaction (EIP-155 / EIP-1559) | +| `ethSignMessage` | `{ addressNList, message }` | any | Sign ETH message | +| `ethSignTypedData` | `{ addressNList, typedData }` | any | Sign EIP-712 typed data | +| `ethVerifyMessage` | `{ address, message, signature }` | any | Verify ETH signature | +| `cosmosSignTx` | amino tx object | any | Sign Cosmos transaction | +| `thorchainSignTx` | amino tx object | any | Sign THORChain transaction | +| `mayachainSignTx` | amino tx object | any | Sign Mayachain transaction | +| `osmosisSignTx` | amino tx object | any | Sign Osmosis transaction | +| `xrpSignTx` | XRP tx object | any | Sign XRP transaction | +| `solanaSignTx` | `{ addressNList, rawTx }` | any | Sign Solana transaction | + +#### Pioneer Integration + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getBalances` | void | `ChainBalance[]` | Fetch all chain balances via Pioneer | +| `getBalance` | `{ chainId }` | `ChainBalance` | Fetch single chain balance | +| `buildTx` | `BuildTxParams` | `BuildTxResult` | Build unsigned transaction | +| `broadcastTx` | `{ chainId, signedTx }` | `BroadcastResult` | Broadcast signed transaction | +| `getMarketData` | `{ caips: string[] }` | any | Get market prices for assets | +| `getFees` | `{ chainId }` | any | Get fee estimates for a chain | + +#### Bitcoin Multi-Account + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getBtcAccounts` | void | `BtcAccountSet` | List BTC accounts (all script types) | +| `addBtcAccount` | void | `BtcAccountSet` | Add next account index | +| `setBtcSelectedXpub` | `{ accountIndex, scriptType }` | void | Set active BTC account | +| `getBtcAddressIndices` | `{ xpub }` | `{ receiveIndex, changeIndex }` | Get current address indices | + +#### EVM Multi-Address + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getEvmAddresses` | void | `EvmAddressSet` | List tracked EVM address indices | +| `addEvmAddressIndex` | `{ index? }` | `EvmAddressSet` | Add an EVM address index | +| `removeEvmAddressIndex` | `{ index }` | `EvmAddressSet` | Remove an EVM address index | +| `setEvmSelectedIndex` | `{ index }` | void | Set active EVM address index | + +#### Chain Discovery + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `browseChains` | `{ query?, page?, pageSize? }` | `{ chains, total, page, pageSize }` | Search Pioneer chain catalog | + +#### Custom Tokens and Chains + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `addCustomToken` | `{ chainId, contractAddress }` | `CustomToken` | Add custom ERC-20 token | +| `removeCustomToken` | `{ chainId, contractAddress }` | void | Remove custom token | +| `getCustomTokens` | void | `CustomToken[]` | List custom tokens | +| `addCustomChain` | `CustomChain` | void | Add custom EVM chain | +| `removeCustomChain` | `{ chainId }` | void | Remove custom chain | +| `getCustomChains` | void | `CustomChain[]` | List custom chains | + +#### Token Visibility (Spam Filter) + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `setTokenVisibility` | `{ caip, status }` | void | Mark token visible or hidden | +| `removeTokenVisibility` | `{ caip }` | void | Remove visibility override | +| `getTokenVisibilityMap` | void | `Record` | Get all visibility overrides | + +#### Camera / QR Scanning + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `startQrScan` | void | void | Start camera for QR scanning | +| `stopQrScan` | void | void | Stop camera | + +#### Pairing and Signing Approval + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `approvePairing` | void | `{ apiKey }` | Approve pending REST API pairing request | +| `rejectPairing` | void | void | Reject pending pairing request | +| `approveSigningRequest` | `{ id }` | void | Approve a signing request | +| `rejectSigningRequest` | `{ id }` | void | Reject a signing request | +| `listPairedApps` | void | `PairedAppInfo[]` | List all paired applications | +| `revokePairing` | `{ apiKey }` | void | Revoke an app's API key | + +#### API Audit Log + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getApiLogs` | `{ limit?, offset? }` | `ApiLogEntry[]` | Get REST API audit log entries | +| `clearApiLogs` | void | void | Clear the audit log | + +#### App Settings + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getAppSettings` | void | `AppSettings` | Get current settings | +| `setRestApiEnabled` | `{ enabled }` | `AppSettings` | Enable or disable REST API | +| `setPioneerApiBase` | `{ url }` | `AppSettings` | Set Pioneer API base URL | + +#### Balance Cache / Watch-Only + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `getCachedBalances` | void | `ChainBalance[] | null` | Get locally cached balances | +| `checkWatchOnlyCache` | void | `{ available, deviceLabel?, lastSynced? }` | Check if watch-only data exists | +| `getWatchOnlyBalances` | void | `ChainBalance[] | null` | Get balances without device connected | +| `getWatchOnlyPubkeys` | void | pubkey array | Get cached public keys | + +#### App Updates + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `checkForUpdate` | void | `UpdateInfo` | Check for new app version | +| `downloadUpdate` | void | void | Download available update | +| `applyUpdate` | void | void | Apply downloaded update | +| `getUpdateInfo` | void | `UpdateInfo | null` | Get current update state | +| `getAppVersion` | void | `{ version, channel }` | Get running app version | + +#### Utility + +| Method | Params | Response | Description | +|--------|--------|----------|-------------| +| `openUrl` | `{ url }` | void | Open URL in system browser | + +### Messages (Bun pushes to WebView) + +| Message | Payload | Description | +|---------|---------|-------------| +| `device-state` | `DeviceStateInfo` | Device state changed | +| `firmware-progress` | `FirmwareProgress` | Firmware flash progress | +| `pin-request` | `PinRequest` | Device is requesting PIN entry | +| `character-request` | `CharacterRequest` | Device is requesting character (cipher recovery) | +| `passphrase-request` | `{}` | Device is requesting passphrase | +| `recovery-error` | `{ message, errorType }` | Recovery or PIN change failed | +| `btc-accounts-update` | `BtcAccountSet` | BTC accounts changed | +| `evm-addresses-update` | `EvmAddressSet` | EVM tracked addresses changed | +| `camera-frame` | string (base64) | Camera frame for QR scanning | +| `camera-error` | string | Camera error message | +| `update-status` | `UpdateStatus` | App update download/install progress | +| `pair-request` | `PairingRequestInfo` | External app requesting to pair | +| `signing-request` | `SigningRequestInfo` | External app requesting to sign | +| `signing-dismissed` | `{ id }` | Signing request was dismissed | +| `api-log` | `ApiLogEntry` | New REST API log entry | +| `walletconnect-uri` | string | WalletConnect URI received | + +--- + +## REST API Endpoints Base URL: `http://localhost:1646` -All endpoints except `POST /auth/pair` require `Authorization: Bearer ` header. +### Public (no auth required) -## Authentication +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/health` | Health check with device status, supported chains, uptime | +| GET | `/api/v1/health` | Alias for `/api/health` | +| GET | `/api/v1/health/fast` | Minimal health check (status + uptime only) | +| GET | `/info/ping` | Returns `{ message: "pong" }` -- SDK detection | +| POST | `/system/info/ping` | Returns `{ message: "pong" }` -- SDK detection | +| GET | `/admin/info` | Version, connection status, uptime | +| GET | `/spec/swagger.json` | OpenAPI specification | +| GET | `/docs` | Swagger UI (interactive API docs) | +| GET | `/api/cache/status` | Cache status (pubkey + address cache counts) | +| GET | `/api/portfolio` | Portfolio stub (returns device state, no balance aggregation) | +| GET | `/auth/paired-apps` | List paired apps (keys stripped) | + +### Authentication | Method | Path | Description | |--------|------|-------------| -| POST | `/auth/pair` | Pair new client, returns `{ apiKey }` | -| GET | `/auth/pair` | Verify existing API key | - -### Pairing Request Body -```json -{ - "name": "KeepKey Vault", - "url": "electrobun://keepkey-vault", - "imageUrl": "https://keepkey.com/favicon.ico" -} -``` - -## Address Derivation - -All POST, all require API key. - -| Endpoint | Chains | Body | -|----------|--------|------| -| `/addresses/eth` | Ethereum | `{ address_n, show_display? }` | -| `/addresses/utxo` | BTC, LTC, DOGE, BCH, DASH | `{ address_n, coin, script_type?, show_display? }` | -| `/addresses/cosmos` | Cosmos | `{ address_n, show_display? }` | -| `/addresses/osmosis` | Osmosis | `{ address_n, show_display? }` | -| `/addresses/thorchain` | THORChain | `{ address_n, show_display? }` | -| `/addresses/mayachain` | Mayachain | `{ address_n, show_display? }` | -| `/addresses/xrp` | Ripple | `{ address_n, show_display? }` | -| `/addresses/bnb` | Binance | `{ address_n, show_display? }` | -| `/addresses/tendermint` | Tendermint | `{ address_n, show_display? }` | - -## Signing - -### Ethereum +| POST | `/auth/pair` | Pair a new app. Body: `{ name, url?, imageUrl? }`. Returns `{ apiKey }`. Requires user approval via UI. | +| GET | `/auth/pair` | Verify existing API key. Returns `{ paired: true/false }`. | + +### Address Derivation (auth required) + +All POST. Body: `{ address_n: number[], coin?: string, script_type?: string, show_display?: boolean }`. + +| Path | Chain(s) | +|------|----------| +| `/addresses/utxo` | Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Dash, DigiByte | +| `/addresses/eth` | Ethereum and all EVM L2s | +| `/addresses/cosmos` | Cosmos | +| `/addresses/osmosis` | Osmosis | +| `/addresses/thorchain` | THORChain | +| `/addresses/mayachain` | Mayachain | +| `/addresses/tendermint` | Tendermint (generic cosmos) | +| `/addresses/xrp` | XRP | +| `/addresses/solana` | Solana | + +### Signing (auth required, user approval required) + +#### Ethereum / EVM + | Method | Path | Description | |--------|------|-------------| -| POST | `/eth/sign-transaction` | Sign ETH transaction | -| POST | `/eth/sign-typed-data` | Sign EIP-712 typed data | -| POST | `/eth/sign` | Sign message (hex) | -| POST | `/eth/verify` | Verify signature | +| POST | `/eth/sign-transaction` | Sign EVM transaction (EIP-155 and EIP-1559). Supports `from` address auto-lookup across first 5 account indices. | +| POST | `/eth/sign-typed-data` | Sign EIP-712 typed data. Body: `{ address, typedData }` | +| POST | `/eth/sign` | Sign hex message. Body: `{ address, message }` | +| POST | `/eth/verify` | Verify signature. Body: `{ address, message, signature }` | + +#### UTXO -### UTXO | Method | Path | Description | |--------|------|-------------| -| POST | `/utxo/sign-transaction` | Sign BTC-like transaction | +| POST | `/utxo/sign-transaction` | Sign UTXO transaction. Body: `{ coin?, inputs, outputs, version?, locktime? }`. Auto-prefixes BCH addresses. | + +#### Cosmos -### Cosmos | Method | Path | Description | |--------|------|-------------| -| POST | `/cosmos/sign-amino` | Sign amino message | +| POST | `/cosmos/sign-amino` | Sign generic amino message | | POST | `/cosmos/sign-amino-delegate` | Sign delegation | | POST | `/cosmos/sign-amino-undelegate` | Sign undelegation | | POST | `/cosmos/sign-amino-redelegate` | Sign redelegation | -| POST | `/cosmos/sign-amino-withdraw-delegator-rewards-all` | Sign reward withdrawal | +| POST | `/cosmos/sign-amino-withdraw-delegator-rewards-all` | Claim all staking rewards | | POST | `/cosmos/sign-amino-ibc-transfer` | Sign IBC transfer | -### Osmosis -Same pattern as Cosmos, plus: +#### Osmosis + +All Cosmos amino endpoints plus: + | Method | Path | Description | |--------|------|-------------| -| POST | `/osmosis/sign-amino-lp-add` | Sign LP addition | -| POST | `/osmosis/sign-amino-lp-remove` | Sign LP removal | -| POST | `/osmosis/sign-amino-swap` | Sign swap | +| POST | `/osmosis/sign-amino` | Sign generic amino message | +| POST | `/osmosis/sign-amino-delegate` | Sign delegation | +| POST | `/osmosis/sign-amino-undelegate` | Sign undelegation | +| POST | `/osmosis/sign-amino-redelegate` | Sign redelegation | +| POST | `/osmosis/sign-amino-withdraw-delegator-rewards-all` | Claim all staking rewards | +| POST | `/osmosis/sign-amino-ibc-transfer` | Sign IBC transfer | +| POST | `/osmosis/sign-amino-lp-add` | Add liquidity | +| POST | `/osmosis/sign-amino-lp-remove` | Remove liquidity | +| POST | `/osmosis/sign-amino-swap` | Swap | + +#### THORChain -### THORChain | Method | Path | Description | |--------|------|-------------| | POST | `/thorchain/sign-amino-transfer` | Sign transfer | -| POST | `/thorchain/sign-amino-desposit` | Sign deposit | +| POST | `/thorchain/sign-amino-deposit` | Sign deposit (e.g. LP add, swap) | + +#### Mayachain -### Mayachain | Method | Path | Description | |--------|------|-------------| | POST | `/mayachain/sign-amino-transfer` | Sign transfer | -| POST | `/mayachain/sign-amino-desposit` | Sign deposit | +| POST | `/mayachain/sign-amino-deposit` | Sign deposit | + +#### XRP -### Binance | Method | Path | Description | |--------|------|-------------| -| POST | `/bnb/sign-transaction` | Sign BNB transaction | +| POST | `/xrp/sign-transaction` | Sign XRP transaction | + +#### Solana -### XRP | Method | Path | Description | |--------|------|-------------| -| POST | `/xrp/sign-transaction` | Sign XRP transaction | +| POST | `/solana/sign-transaction` | Sign Solana transaction. Body: `{ raw_tx, addressNList? }` | -## System +### Device Info (auth required) -### Info | Method | Path | Description | |--------|------|-------------| -| POST | `/system/info/get-features` | Get device features/capabilities | -| POST | `/system/info/get-entropy` | Get device entropy | -| POST | `/system/info/get-public-key` | Get public key | -| POST | `/system/info/list-coins` | List supported coins | -| POST | `/system/info/ping` | Ping device | +| POST | `/system/info/get-features` | Device features (snake_case format, 10s cache) | +| POST | `/system/info/get-public-key` | Get xpub. Body: `{ address_n, ecdsa_curve_name?, show_display?, coin_name?, script_type? }` | +| POST | `/system/info/list-coins` | List supported coins (from built-in chain config) | + +### Device Management (auth required) -### Configuration | Method | Path | Description | |--------|------|-------------| -| POST | `/system/apply-settings` | Set label, language, auto-lock, passphrase | -| POST | `/system/apply-policies` | Enable/disable policies | -| POST | `/system/change-pin` | Change or remove PIN | +| POST | `/system/apply-settings` | Set label, passphrase, auto-lock delay | +| POST | `/system/apply-policies` | Enable/disable device policies | +| POST | `/system/change-pin` | Change or remove PIN. Body: `{ remove?: boolean }` | | POST | `/system/clear-session` | Clear device session | -| POST | `/system/wipe-device` | Factory reset | -| POST | `/system/firmware-update` | Update firmware | +| POST | `/system/wipe-device` | Factory reset the device | +| POST | `/system/initialize/reset-device` | Generate new seed. Body: `{ word_count?, label?, pin_protection?, passphrase_protection? }` | +| POST | `/system/initialize/recover-device` | Recover from seed. Body: `{ word_count?, label?, pin_protection?, passphrase_protection? }` | +| POST | `/system/initialize/load-device` | Load seed directly | +| POST | `/system/recovery/pin` | Send PIN during recovery. Body: `{ pin }` | + +### SDK / Multi-Device (auth required) -### Initialization | Method | Path | Description | |--------|------|-------------| -| POST | `/system/initialize/load-device` | Load seed | -| POST | `/system/initialize/recover-device` | Recover from backup | -| POST | `/system/initialize/reset-device` | Reset device | +| GET | `/api/v2/devices` | List connected devices (single-device mode) | +| GET | `/api/v2/devices/active` | Get active device | +| GET | `/api/v2/devices/paired` | Get paired device info | +| POST | `/api/v2/devices/select` | Select device (no-op in single-device mode) | +| GET | `/api/v2/devices/:id` | Get device by ID | +| GET | `/api/portfolio/:device_id` | Device portfolio stub | +| POST | `/api/pubkeys/batch` | Batch derive public keys and addresses. Supports `type: 'address'` for non-UTXO chains and `type: 'xpub'` for UTXO chains. | + +### WalletConnect Reverse Proxy + +Requests to `/wc/*` are reverse-proxied to the WalletConnect dApp origin. This allows the WC panel to load as same-origin content, avoiding mixed-content blocks in WKWebView. GET only, no auth required. + +--- -## BIP44 Paths +## BIP44 Derivation Paths + +Default paths used by KeepKey Vault: + +### UTXO Chains + +| Chain | Path | Coin Type | Script Type | +|-------|------|-----------|-------------| +| Bitcoin | `m/44'/0'/0'/0/0` | 0 | p2pkh (legacy) | +| Bitcoin (SegWit) | `m/49'/0'/0'/0/0` | 0 | p2sh-p2wpkh | +| Bitcoin (Native SegWit) | `m/84'/0'/0'/0/0` | 0 | p2wpkh | +| Litecoin | `m/44'/2'/0'/0/0` | 2 | p2wpkh | +| Dogecoin | `m/44'/3'/0'/0/0` | 3 | p2pkh | +| Dash | `m/44'/5'/0'/0/0` | 5 | p2pkh | +| DigiByte | `m/44'/20'/0'/0/0` | 20 | p2pkh | +| Bitcoin Cash | `m/44'/145'/0'/0/0` | 145 | p2pkh | + +### EVM Chains (all share coin type 60) + +| Chain | Path | Chain ID | +|-------|------|----------| +| Ethereum | `m/44'/60'/0'/0/0` | 1 | +| Polygon | `m/44'/60'/0'/0/0` | 137 | +| Arbitrum | `m/44'/60'/0'/0/0` | 42161 | +| Optimism | `m/44'/60'/0'/0/0` | 10 | +| Avalanche C-Chain | `m/44'/60'/0'/0/0` | 43114 | +| BNB Smart Chain | `m/44'/60'/0'/0/0` | 56 | +| Base | `m/44'/60'/0'/0/0` | 8453 | +| Monad | `m/44'/60'/0'/0/0` | 143 | +| Hyperliquid | `m/44'/60'/0'/0/0` | 2868 | +| Custom EVM chains | `m/44'/60'/0'/0/0` | user-defined | + +All EVM chains derive the same address at a given account index. The firmware receives `coin: 'Ethereum'` for all EVM chains. + +### Cosmos-Family Chains + +| Chain | Path | Coin Type | +|-------|------|-----------| +| Cosmos (ATOM) | `m/44'/118'/0'/0/0` | 118 | +| Osmosis (OSMO) | `m/44'/118'/0'/0/0` | 118 | +| THORChain (RUNE) | `m/44'/931'/0'/0/0` | 931 | +| Mayachain (CACAO) | `m/44'/931'/0'/0/0` | 931 | -Default derivation paths used by KeepKey Vault: +### Other Chains | Chain | Path | Coin Type | |-------|------|-----------| -| Bitcoin | `m/44'/0'/0'/0/0` | 0 | -| Ethereum | `m/44'/60'/0'/0/0` | 60 | -| Cosmos | `m/44'/118'/0'/0/0` | 118 | -| THORChain | `m/44'/931'/0'/0/0` | 931 | -| Osmosis | `m/44'/118'/0'/0/0` | 118 | -| Litecoin | `m/44'/2'/0'/0/0` | 2 | -| Dogecoin | `m/44'/3'/0'/0/0` | 3 | -| Bitcoin Cash | `m/44'/145'/0'/0/0` | 145 | -| Dash | `m/44'/5'/0'/0/0` | 5 | -| Ripple | `m/44'/144'/0'/0/0` | 144 | -| Mayachain | `m/44'/931'/0'/0/0` | 931 | -| Binance | `m/44'/714'/0'/0/0` | 714 | +| Ripple (XRP) | `m/44'/144'/0'/0/0` | 144 | +| Solana (SOL) | `m/44'/501'/0'/0'` | 501 | diff --git a/projects/keepkey-vault/docs/BUILD.md b/projects/keepkey-vault/docs/BUILD.md index c02922d..f99bb00 100644 --- a/projects/keepkey-vault/docs/BUILD.md +++ b/projects/keepkey-vault/docs/BUILD.md @@ -1,10 +1,10 @@ -# KeepKey Vault v11 - Build & Distribution Guide +# KeepKey Vault — Build & Distribution Guide ## Overview KeepKey Vault is built with [Electrobun](https://electrobun.dev) — a desktop framework using Bun as the main process and the system WebView for the UI. The build pipeline produces a signed, notarized macOS DMG. -**Architecture**: Vite (frontend) + Bun.build (backend) + Electrobun (packaging) + Apple codesign/notarize +**Architecture**: Vite (frontend) → Bun.build (backend) → collect-externals (native deps) → Electrobun (packaging) → prune-app-bundle (post-build dedup) → DMG (distribution) ## Quick Start @@ -12,7 +12,7 @@ KeepKey Vault is built with [Electrobun](https://electrobun.dev) — a desktop f # Development (from monorepo root) make dev -# Full signed production build +# Full signed production build → DMG make build-signed ``` @@ -22,9 +22,9 @@ make build-signed - **Yarn** (for hdwallet monorepo) - **Xcode Command Line Tools** (`xcode-select --install`) - **Apple Developer ID** certificate in Keychain (for signing) -- **zstd** (`brew install zstd`) — for DMG extraction +- **zstd** (`brew install zstd`) — for tar.zst extraction -### Environment Variables (Signing Only) +### Environment Variables (Signing) Create a `.env` file in the monorepo root: @@ -39,20 +39,22 @@ ELECTROBUN_APPLEIDPASS="app-specific-password" ## Build Pipeline -The full `make build-signed` pipeline executes these stages in order: +The full `make build-signed` pipeline runs: `build-stable` → `prune-bundle` → `dmg`. ### Stage 1: Module Builds (`make modules-build`) Builds local submodule dependencies from source: -1. **proto-tx-builder** (`modules/proto-tx-builder/`): `bun install && bun run build` (TypeScript → dist/) +1. **proto-tx-builder** (`modules/proto-tx-builder/`): `bun install && bun run build` 2. **hdwallet** (`modules/hdwallet/`): `yarn install && yarn build` (lerna monorepo, ~6s) These are referenced as `file:` dependencies in `package.json`. +> **CRITICAL**: hdwallet must be on `master` (or `vault-v1` branch) which has the lodash/rxjs removal commit (`179c5668`). Without this, the build scripts correctly strip lodash/rxjs but the code still imports them → runtime crash. + ### Stage 2: Vault Install (`bun install`) -Installs all dependencies. The `postinstall` hook runs `scripts/patch-electrobun.sh` to patch Electrobun's zip handling (see [ENOBUFS Workaround](#enobufs-workaround) below). +Installs all dependencies. The `postinstall` hook runs `scripts/patch-electrobun.sh` to patch Electrobun's zip handling (see [ENOBUFS Workaround](#enobufs-workaround)). ### Stage 3: Vite Build (`vite build`) @@ -60,30 +62,32 @@ Builds the React frontend (Chakra UI 3.0) from `src/mainview/` into `dist/`. **Critical**: `base: './'` in `vite.config.ts` generates relative asset paths. Without this, assets fail to load under Electrobun's `views://` protocol (absolute paths like `/assets/...` resolve to the wrong origin). -Output: -- `dist/index.html` — entry point -- `dist/assets/` — JS/CSS chunks - ### Stage 4: Collect Externals (`bun scripts/collect-externals.ts`) -Native addons and protobuf packages can't be bundled by Bun — they're marked `external` in `electrobun.config.ts`. This script collects them and all transitive dependencies into `build/_ext_modules/` for inclusion in the app bundle. +Native addons and protobuf packages can't be bundled by Bun — they're marked `external` in `electrobun.config.ts`. This script collects them into `build/_ext_modules/`. **What it does**: -1. Walks the dependency tree of all EXTERNALS (~274 packages) -2. Copies each package from `node_modules/` to `build/_ext_modules/` -3. Strips ALL nested `node_modules/` (forces flat resolution, removes devDep bloat) -4. Prunes docs, tests, source maps, `.d.ts`, TypeScript source, C/C++ build artifacts -5. Removes non-macOS prebuilds (linux, win32) -6. Strips large directories (protobufjs/cli, rxjs/dist/bundles, ethers/dist, etc.) -7. Code-signs all `.node`, `.dylib`, `.so` native binaries (if `ELECTROBUN_DEVELOPER_ID` is set) -**Bundle size**: ~54MB on disk (~274 packages, ~5700 files) +1. **Dependency tree walk**: Collects all transitive deps from the EXTERNALS list, filtering out ~100 dev-time packages via DEV_BLOCKLIST (jest, babel, istanbul, ts-proto, etc.) +2. **Copy**: Each package from `node_modules/` → `build/_ext_modules/` +3. **Version-aware nested dedup**: Discovers nested `node_modules/` in each package: + - **Same version as top-level** → skip (duplicate) + - **Different version** → copy to `pkg/node_modules/nested-pkg` (required by parent) + - Also collects the nested package's own deps at top-level +4. **@keepkey/* cleanup**: Strips `node_modules/` from `@keepkey/*` packages (lerna monorepo artifacts from `file:` resolution — all their deps are already at top-level) +5. **Prune**: Removes docs, tests, `.d.ts`, source maps, TypeScript source, C/C++ build artifacts, non-macOS prebuilds +6. **Directory strip**: Removes known-large unnecessary directories (protobufjs/cli, ethers/dist, etc.) +7. **Banned package removal**: Recursively removes `node-notifier`, `growly`, `is-wsl` (contain unsigned macOS binaries that break notarization) +8. **Code signing**: Signs all `.node`, `.dylib`, `.so` binaries AND extensionless Mach-O binaries with Apple Developer ID -> **Why strip nested `node_modules`?** Bun copies `file:` deps with their own `node_modules/`, creating 64MB+ of duplicated packages including dev tools (`jest`, `node-notifier`) with unsigned Mach-O binaries that break Apple notarization. +**Bundle size**: ~38MB on disk (~237 packages) + +> See [collect-externals-guide.md](collect-externals-guide.md) for safety rules and debugging. +> See [retro-noble-hashes-breakage.md](retro-noble-hashes-breakage.md) for why blind nested stripping is dangerous. ### Stage 5: Electrobun Build (`bun scripts/build-signed.ts stable`) -Runs `electrobun build --env=stable` with a custom `scripts/zip` shim on PATH (see [ENOBUFS Workaround](#enobufs-workaround)). +Runs `electrobun build --env=stable` with a custom `scripts/zip` shim on PATH. Electrobun: 1. Bundles the Bun backend (`src/bun/index.ts` → single JS file) @@ -93,22 +97,78 @@ Electrobun: - `build/_ext_modules` → `node_modules` 3. Code-signs all Mach-O binaries in the `.app` bundle 4. Notarizes the `.app` with Apple -5. Produces a `.app.tar.zst` artifact +5. Produces a `.app.tar.zst` artifact in `artifacts/` + +### Stage 6: Prune App Bundle (`make prune-bundle`) + +`scripts/prune-app-bundle.ts` operates on the final `.app.tar.zst` — a second pass that catches anything collect-externals missed: + +1. Extracts the tar.zst → temporary `.app` directory +2. **Version-aware nested dedup** (same logic as collect-externals): only removes nested deps whose version matches top-level +3. Prunes `.d.ts`, `.ts`, `.map`, `README`, `CHANGELOG`, `LICENSE` files +4. Strips known large directories (protobufjs, ethers, libsodium ESM, etc.) +5. Re-signs all native `.node` binaries +6. **Re-signs the entire `.app` with `entitlements.plist`** — JIT, unsigned executable memory, dyld env vars, and library validation bypass (all required for Bun runtime) +7. Repackages into tar.zst + +> **CRITICAL**: The `entitlements.plist` step was added in v1.0.1. Without it, macOS Sequoia kills the Bun process on launch (hardened runtime blocks JIT which Bun requires). -### Stage 6: DMG Creation (`make dmg`) +### Stage 7: DMG Creation (`make dmg`) -Creates a distributable DMG from the notarized app: +Creates a distributable DMG from the pruned app: 1. Extracts `.app` from the `.app.tar.zst` archive 2. Verifies codesign on extracted app 3. Creates a DMG with `hdiutil` (UDZO compression) -4. Signs the DMG itself +4. Signs the DMG 5. Notarizes the DMG with Apple -6. Staples the notarization ticket to the DMG +6. Staples the notarization ticket + +**Output**: `artifacts/KeepKey-Vault-{VERSION}-{ARCH}.dmg` (~52MB) + +> **Why not use Electrobun's built-in DMG?** Electrobun's zig-zstd self-extractor has a bug where binary files aren't extracted properly on macOS Sequoia. The custom DMG pipeline works around this. + +## Signing Deep Dive + +### What Gets Signed and When + +The build has **three signing passes** to satisfy Apple notarization: + +| Pass | Script | What | Why | +|------|--------|------|-----| +| 1 | `collect-externals.ts` | All `.node`, `.dylib`, `.so`, extensionless Mach-O binaries in `build/_ext_modules/` | Native addons must be signed before Electrobun packages them | +| 2 | `prune-app-bundle.ts` | All native binaries in the extracted `.app` | Re-sign after pruning modifies the bundle | +| 3 | `prune-app-bundle.ts` | The entire `.app` bundle with `--entitlements entitlements.plist` | Hardened runtime + JIT entitlements for Bun | -**Output**: `artifacts/KeepKey-Vault-{VERSION}-{ARCH}.dmg` (~100MB) +### Entitlements (`entitlements.plist`) -> **Why not use Electrobun's built-in DMG?** Electrobun's zig-zstd self-extractor has a bug where binary files aren't extracted properly. The custom DMG pipeline works around this by extracting from the tar.zst and creating a standard macOS DMG. +```xml +com.apple.security.cs.allow-jit +com.apple.security.cs.allow-unsigned-executable-memory +com.apple.security.cs.disable-library-validation +com.apple.security.cs.allow-dyld-environment-variables +``` + +All four are required. Missing `allow-jit` causes the app to be killed immediately on macOS Sequoia. + +### Extensionless Mach-O Detection + +Some packages ship binary executables without file extensions. `collect-externals.ts` detects these by reading the first 4 bytes and checking for Mach-O magic numbers: + +- `0xFEEDFACE` / `0xFEEDFACF` — 32/64-bit Mach-O +- `0xCEFAEDFE` / `0xCFFAEDFE` — Reverse byte order +- `0xCAFEBABE` — Universal binary + +Unsigned Mach-O binaries cause notarization to fail with a cryptic error. + +### Common Signing Failures + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Notarization rejects with "unsigned binary" | Nested `node_modules/` contains unsigned Mach-O | Check banned packages list, run `find` for unsigned binaries (see Troubleshooting) | +| App killed on launch (no error) | Missing JIT entitlement | Verify `entitlements.plist` is applied in `prune-app-bundle.ts` | +| `codesign: resource fork, Finder information, or similar detritus not allowed` | `.DS_Store` or extended attributes in bundle | Add `xattr -cr` step before signing | +| Stapling fails after notarization succeeds | Network issue or Apple CDN delay | Retry `xcrun stapler staple` after a few minutes | ## Makefile Targets @@ -119,8 +179,11 @@ Creates a distributable DMG from the notarized app: | `make dev-hmr` | Dev with Vite HMR (hot module reload) | | `make build` | Development build (no signing) | | `make build-stable` | Production build with signing + notarization | -| `make build-signed` | Full pipeline: build → DMG → sign → notarize → staple | +| `make build-signed` | Full pipeline: build → prune → DMG → sign → notarize → staple | +| `make prune-bundle` | Prune app bundle (version-aware dedup, strip bloat, re-sign with entitlements) | | `make dmg` | Create DMG from existing build artifacts | +| `make upload-dmg` | Upload signed DMG to existing CI-created draft release | +| `make release` | Full release: build-signed + create new GitHub release | | `make modules-build` | Build hdwallet + proto-tx-builder from source | | `make modules-clean` | Clean module build artifacts | | `make audit` | Generate dependency manifest + SBOM | @@ -136,48 +199,55 @@ Creates a distributable DMG from the notarized app: | `--env=canary` | Pre-release testing | Signed + notarized | | `--env=stable` | Production release | Signed + notarized | +## CI / Release Workflow + +### Linux (CI — GitHub Actions) + +CI builds Linux x64 only (`.github/workflows/build.yml`). macOS and Windows are built locally because: +- macOS requires Apple Developer ID in Keychain (can't run in CI without self-hosted runner) +- Windows Electrobun support is experimental (D:\ drive path bug) + +### macOS Release (Local) + +```bash +# Option A: Full release (build + create GitHub release) +make release + +# Option B: Upload to existing CI draft release +make build-signed # Build locally +make upload-dmg # Upload DMG to CI's draft release +``` + +`make upload-dmg` also uploads `stable-*-update.json` and `stable-*.app.tar.zst` if present (for Electrobun auto-updates). + ## Known Workarounds ### ENOBUFS Workaround -Electrobun's compiled Zig CLI invokes `zip` via Bun's `execSync` with a 1MB `maxBuffer`. With ~5700+ files in the app bundle, the zip output exceeds this buffer causing `ENOBUFS`. +Electrobun's compiled Zig CLI invokes `zip` via Bun's `execSync` with a 1MB `maxBuffer`. With ~5700+ files, zip output exceeds this buffer → `ENOBUFS`. **Two-layer fix**: -1. **`scripts/zip`** — A shim that intercepts zip calls and adds `-q` (quiet flag) to suppress per-file output +1. **`scripts/zip`** — Shim that adds `-q` (quiet) to suppress per-file output 2. **`scripts/build-signed.ts`** — Prepends `scripts/` to PATH so the shim is found before `/usr/bin/zip` 3. **`scripts/patch-electrobun.sh`** — Patches Electrobun's CLI source to add `-q` and increase `maxBuffer` to 50MB (runs as `postinstall`) ### Vite `base: './'` -Electrobun serves WebView content via the `views://` protocol from `Contents/Resources/app/views/mainview/`. Vite's default `base: '/'` generates absolute paths (`/assets/index.js`) which resolve to `views://assets/` instead of `views://mainview/assets/`. Setting `base: './'` generates relative paths (`./assets/index.js`) that resolve correctly. - -### Nested `node_modules` Stripping - -Bun's `file:` dependency resolution copies the entire local package directory including its own `node_modules/`. These nested copies contain: -- Duplicated packages (hdwallet-core 5.4MB x4, rxjs, cosmjs, etc. = ~64MB) -- Dev dependencies (jest, babel, node-notifier with unsigned Mach-O binaries) - -The `collect-externals.ts` script strips ALL nested `node_modules/` and relies on the flat top-level resolution instead. +Electrobun serves WebView content via `views://` protocol. Vite's default `base: '/'` generates absolute paths (`/assets/index.js`) which resolve to `views://assets/` instead of `views://mainview/assets/`. Setting `base: './'` fixes this. -### `src/` Not Pruned +### Version-Aware Nested Dedup (v1.0.1 Fix) -Many packages (e.g., `bip32`) set `"main": "./src/index.js"` — their published artifact IS the `src/` directory. The prune step explicitly preserves `src/` directories. +**Problem (v1.0.0)**: Blind stripping of ALL nested `node_modules/` killed `@noble/hashes@1.4.0` (required by `ethereum-cryptography@2.2.1`), while top-level had `@noble/hashes@1.8.0` with incompatible API changes. App crashed silently inside Electrobun's Worker before any window opened. -## Native Binary Signing +**Fix**: Both `collect-externals.ts` and `prune-app-bundle.ts` now compare `package.json` versions: +- Same version as top-level → safe to remove (duplicate) +- Different version → MUST keep (parent needs this specific version) -Apple notarization requires ALL Mach-O binaries to be signed with a Developer ID certificate and hardened runtime. The `collect-externals.ts` script signs: -- `.node` files (native addons: node-hid, usb, tiny-secp256k1, keccak, etc.) -- `.dylib` files -- `.so` files +See [retro-noble-hashes-breakage.md](retro-noble-hashes-breakage.md) for the full incident report. -Approximately 17 native binaries are signed during the collect step. +### DEV_BLOCKLIST -## Security & Audit - -Run `make audit` to generate: -- `artifacts/deps.runtime.json` — All runtime dependencies with versions and sizes -- `artifacts/deps.install-scripts.txt` — Packages with install scripts (security review) -- `artifacts/sbom.cdx.json` — CycloneDX Software Bill of Materials +`collect-externals.ts` maintains a blocklist of ~100 dev-time packages (jest, babel, istanbul, ts-proto, etc.) that get pulled into the dependency tree via `file:` resolution of hdwallet/proto-tx-builder. Without the blocklist these add ~50MB of test infrastructure to the production bundle. ## Troubleshooting @@ -189,14 +259,20 @@ cat scripts/zip # Should contain: exec /usr/bin/zip -q "$@" ``` ### Notarization fails with "unsigned binary" -A nested `node_modules/` contains an unsigned Mach-O binary. Run: +A nested `node_modules/` or banned package contains an unsigned Mach-O binary: ```bash -# Find unsigned binaries in the build find build/_ext_modules -type f | while read f; do file "$f" | grep -q "Mach-O" && ! codesign -v "$f" 2>/dev/null && echo "UNSIGNED: $f" done ``` -Fix: ensure `collect-externals.ts` strips nested `node_modules/` and signs all native binaries. +Fix: add the package to `BANNED_PACKAGES` in `collect-externals.ts` or ensure the signing loop catches it. + +### App killed immediately on macOS Sequoia (no error) +Missing JIT entitlement. Verify: +```bash +codesign -d --entitlements :- /path/to/KeepKey\ Vault.app +``` +Should show `com.apple.security.cs.allow-jit`. If missing, check that `prune-app-bundle.ts` applies `entitlements.plist`. ### UI doesn't render (blank window) Check `vite.config.ts` has `base: './'`. Verify in `dist/index.html` that asset paths are relative (`./assets/...`), not absolute (`/assets/...`). @@ -209,23 +285,43 @@ Run from terminal to see the error: ```bash /path/to/KeepKey\ Vault.app/Contents/MacOS/launcher ``` +Or bypass Electrobun's Worker isolation entirely: +```bash +cd build/dev-macos-arm64/keepkey-vault-dev.app/Contents/MacOS +timeout 10 ./bun ../Resources/app/bun/index.js 2>&1 +``` + +### Bundle too large +Run `du -sh build/_ext_modules/` (expected ~38MB). Check collect-externals output for "Keeping nested" lines. Consider `package.json` `overrides` to align versions. + +## Security & Audit + +Run `make audit` to generate: +- `artifacts/deps.runtime.json` — All runtime dependencies with versions and sizes +- `artifacts/deps.install-scripts.txt` — Packages with install scripts (security review) +- `artifacts/sbom.cdx.json` — CycloneDX Software Bill of Materials ## File Reference ``` projects/keepkey-vault/ - electrobun.config.ts # Electrobun app config (externals, copy rules, signing) - vite.config.ts # Vite frontend build config (base: './') - package.json # Dependencies and build scripts + electrobun.config.ts # Electrobun app config (externals, copy rules, signing) + vite.config.ts # Vite frontend build config (base: './') + package.json # Dependencies and build scripts + entitlements.plist # macOS entitlements (JIT, unsigned memory, dyld, library validation) scripts/ - collect-externals.ts # Native module collector + pruner + signer - build-signed.ts # Electrobun build wrapper with zip shim - patch-electrobun.sh # Patches Electrobun CLI for large bundles - zip # Quiet zip shim (prevents ENOBUFS) - audit-deps.ts # Dependency audit + SBOM generator - artifacts/ # Build output (gitignored) - KeepKey-Vault-*.dmg # Signed, notarized DMG - *.app.tar.zst # Electrobun compressed app - deps.runtime.json # Dependency manifest - sbom.cdx.json # CycloneDX SBOM + collect-externals.ts # Native module collector + pruner + signer (DEV_BLOCKLIST, version-aware dedup) + prune-app-bundle.ts # Post-build pruner on .app.tar.zst (version-aware dedup + entitlements re-sign) + build-signed.ts # Electrobun build wrapper with zip shim + patch-electrobun.sh # Patches Electrobun CLI for large bundles + zip # Quiet zip shim (prevents ENOBUFS) + audit-deps.ts # Dependency audit + SBOM generator + artifacts/ # Build output (gitignored) + KeepKey-Vault-*.dmg # Signed, notarized DMG + *.app.tar.zst # Electrobun compressed app (post-prune) + deps.runtime.json # Dependency manifest + sbom.cdx.json # CycloneDX SBOM + docs/ + collect-externals-guide.md # Safety rules for collect-externals changes + retro-noble-hashes-breakage.md # v1.0.0 crash incident report ``` diff --git a/projects/keepkey-vault/docs/ELECTROBUN.md b/projects/keepkey-vault/docs/ELECTROBUN.md index 20f55b3..6bd9b6a 100644 --- a/projects/keepkey-vault/docs/ELECTROBUN.md +++ b/projects/keepkey-vault/docs/ELECTROBUN.md @@ -32,12 +32,15 @@ Communication between processes uses Electrobun's typed RPC system (`electrobun/ ## Build Pipeline ``` -src/mainview/ ──[vite build]──> dist/ ──[electrobun build]──> build/dev-macos-arm64/keepkey-vault-dev.app +src/mainview/ ──[vite build]──> dist/ + └──[collect-externals]──> build/_ext_modules/ + └──[electrobun build]──> .app ``` ### Production ```bash -make build-prod # Creates production-signed app bundle +make build-stable # Production build with signing + notarization +make build-signed # Full pipeline: build → prune → DMG → sign → notarize ``` ## Config Files @@ -58,12 +61,18 @@ make build-prod # Creates production-signed app bundle | Config | electron-builder/forge | `electrobun.config.ts` | | Asset loading | `file://` protocol | `views://` protocol | +## Entitlements (macOS Production) + +Production builds require `entitlements.plist` with JIT, unsigned executable memory, library validation bypass, and dyld env vars. These are needed because Bun uses JIT compilation. Without them, macOS Sequoia kills the process immediately. + +The `prune-app-bundle.ts` script applies entitlements during the re-signing step. + ## Troubleshooting ### ENOENT launcher error The `electrobun build` step must run before `electrobun dev`. The dev script handles this: ```json -"dev": "vite build && electrobun build && electrobun dev" +"dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev" ``` ### HMR not working @@ -73,3 +82,4 @@ Ensure Vite dev server is running on port 5173. Use `make dev-hmr` which starts Check browser console in the WebView. Common causes: - Missing `dist/` output (run `vite build` first) - Build copy paths wrong in `electrobun.config.ts` +- Most common cause: `vite.config.ts` missing `base: './'`. Absolute paths like `/assets/...` break under the `views://` protocol. diff --git a/projects/keepkey-vault/docs/collect-externals-guide.md b/projects/keepkey-vault/docs/collect-externals-guide.md index 7f0d1c8..43bd979 100644 --- a/projects/keepkey-vault/docs/collect-externals-guide.md +++ b/projects/keepkey-vault/docs/collect-externals-guide.md @@ -6,13 +6,15 @@ ## How It Works -1. **Dependency collection**: Walks `package.json` `dependencies` recursively from the EXTERNALS list +1. **Dependency collection**: Walks `package.json` `dependencies` recursively from the EXTERNALS list, filtering out ~100 dev packages via DEV_BLOCKLIST (jest, babel, istanbul, ts-proto, etc.) 2. **Copy**: Copies each package from `node_modules/` to `build/_ext_modules/` -3. **Prune**: Removes docs, tests, source maps, TypeScript declarations -4. **Native cleanup**: Removes non-macOS prebuilds, C/C++ source, build artifacts -5. **Nested dedup**: Strips nested `node_modules/` that duplicate top-level versions (keeps version-differing deps) -6. **Directory strip**: Removes known-large unnecessary directories (protobufjs/cli, ethers/dist, etc.) -7. **Code signing**: Signs all `.node` binaries with Apple Developer ID (for notarization) +3. **@keepkey/* cleanup**: Strips `node_modules/` from `@keepkey/*` packages (lerna monorepo artifacts from `file:` resolution) +4. **Version-aware nested dedup**: Copies nested `node_modules/` where versions differ from top-level; skips same-version duplicates +5. **Prune**: Removes docs, tests, source maps, TypeScript declarations +6. **Native cleanup**: Removes non-macOS prebuilds, C/C++ source, build artifacts +7. **Directory strip**: Removes known-large unnecessary directories (protobufjs/cli, ethers/dist, etc.) +8. **Banned package removal**: Recursively removes `node-notifier`, `growly`, `is-wsl` (contain unsigned macOS binaries that break notarization) +9. **Code signing**: Signs all `.node`, `.dylib`, `.so` binaries AND extensionless Mach-O binaries (detected by reading first 4 bytes for magic numbers) with Apple Developer ID ## Critical Safety Rules @@ -74,11 +76,11 @@ grep -r "require.*PACKAGE_NAME" build/_ext_modules/ | grep -v node_modules/PACKA ### Symptom: Notarization fails on `.node` binaries -`collect-externals.ts` signs native binaries. Ensure `ELECTROBUN_DEVELOPER_ID` and `ELECTROBUN_TEAMID` env vars are set. Check that nested `node_modules/` don't contain unsigned binaries from devDependencies. +`collect-externals.ts` signs native binaries. Ensure `ELECTROBUN_DEVELOPER_ID` and `ELECTROBUN_TEAMID` env vars are set. Check that nested `node_modules/` don't contain unsigned binaries from devDependencies. Also check that `BANNED_PACKAGES` list in the script covers packages with unsigned Mach-O binaries (e.g., `node-notifier` ships `terminal-notifier.app`). ### Symptom: Bundle too large -Run `du -sh build/_ext_modules/` and compare to expected (~60MB). Check the collect-externals output for "Keeping nested" lines — version-differing deps may contain duplicated large packages. Consider adding overrides in `package.json` to align versions. +Run `du -sh build/_ext_modules/` and compare to expected (~38MB). Check the collect-externals output for "Keeping nested" lines — version-differing deps may contain duplicated large packages. Consider adding overrides in `package.json` to align versions. ## Version Conflict Audit diff --git a/projects/keepkey-vault/electrobun.config.ts b/projects/keepkey-vault/electrobun.config.ts index 1609ab7..196ba1d 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.1", + version: "1.0.4", urlSchemes: ["keepkey"], }, build: { @@ -56,6 +56,6 @@ export default { }, }, release: { - baseUrl: "https://github.com/BitHighlander/keepkey-vault-v11/releases/latest/download", + baseUrl: "https://github.com/keepkey/keepkey-vault/releases/latest/download", }, } satisfies ElectrobunConfig; diff --git a/projects/keepkey-vault/package.json b/projects/keepkey-vault/package.json index d6f3510..d24ea45 100644 --- a/projects/keepkey-vault/package.json +++ b/projects/keepkey-vault/package.json @@ -1,11 +1,12 @@ { "name": "keepkey-vault", - "version": "1.0.1", + "version": "1.0.4", "description": "KeepKey Vault - Desktop hardware wallet management powered by Electrobun", "scripts": { "dev": "vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev", "dev:hmr": "bun run hmr & vite build && bun scripts/collect-externals.ts && electrobun build && electrobun dev", - "hmr": "vite --port 5173", + "dev:hmr:win": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev-hmr-windows.ps1", + "hmr": "vite --port 5177", "build": "vite build && bun scripts/collect-externals.ts && electrobun build", "build:stable": "vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts stable", "build:canary": "vite build && bun scripts/collect-externals.ts && bun scripts/build-signed.ts canary", diff --git a/projects/keepkey-vault/scripts/collect-externals.ts b/projects/keepkey-vault/scripts/collect-externals.ts index 515f98b..c6a1cfe 100644 --- a/projects/keepkey-vault/scripts/collect-externals.ts +++ b/projects/keepkey-vault/scripts/collect-externals.ts @@ -6,7 +6,7 @@ * Usage: bun scripts/collect-externals.ts */ import { existsSync, mkdirSync, cpSync, readFileSync, rmSync, readdirSync, statSync } from 'node:fs' -import { join, dirname } from 'node:path' +import { join, dirname, resolve } from 'node:path' const EXTERNALS = [ '@keepkey/hdwallet-core', @@ -25,6 +25,29 @@ const projectRoot = join(import.meta.dir, '..') const nmSource = join(projectRoot, 'node_modules') const nmDest = join(projectRoot, 'build', '_ext_modules') +// Resolve file: linked packages to their actual source directories. +// Bun's file: resolution can leave broken stubs in node_modules (empty dir with only node_modules/). +// We read package.json's dependencies to find the real path for file: references. +const fileLinkedPaths = new Map() +try { + const rootPj = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) + for (const [name, spec] of Object.entries({ ...rootPj.dependencies, ...rootPj.overrides } as Record)) { + if (spec.startsWith('file:')) { + const relPath = spec.slice(5) + const absPath = resolve(projectRoot, relPath) + if (existsSync(join(absPath, 'package.json'))) { + fileLinkedPaths.set(name, absPath) + } + } + } + if (fileLinkedPaths.size > 0) { + console.log(`[collect-externals] Resolved ${fileLinkedPaths.size} file: linked packages:`) + for (const [name, path] of fileLinkedPaths) console.log(` ${name} → ${path}`) + } +} catch (e) { + console.warn(`[collect-externals] WARN: Could not resolve file: links: ${e}`) +} + // Recursively collect all transitive dependencies const allDeps = new Set(EXTERNALS) // Track nested packages that need their own node_modules copied @@ -96,6 +119,8 @@ const DEV_BLOCKLIST = new Set([ 'test-exclude', 'throat', 'p-each-series', 'growly', 'is-wsl', 'node-notifier', 'node-int64', 'parse5', + // --- Dead chain SDK (Binance Beacon Chain is decommissioned) --- + 'bnb-javascript-sdk-nobroadcast', ]) // Read deps from a nested package dir and add them to allDeps (so they get collected at top level). @@ -116,7 +141,9 @@ function addNestedDeps(nestedPkgDir: string) { function addDeps(pkg: string) { try { - const pjPath = join(nmSource, pkg, 'package.json') + // For file: linked packages, read package.json from the actual source directory + const pkgDir = fileLinkedPaths.get(pkg) || join(nmSource, pkg) + const pjPath = join(pkgDir, 'package.json') const pj = JSON.parse(readFileSync(pjPath, 'utf8')) for (const dep of Object.keys(pj.dependencies || {})) { if (!allDeps.has(dep) && !DEV_BLOCKLIST.has(dep)) { @@ -181,7 +208,8 @@ if (existsSync(nmDest)) { let copiedCount = 0 for (const dep of sorted) { - const src = join(nmSource, dep) + // For file: linked packages, copy from the actual source directory + const src = fileLinkedPaths.get(dep) || join(nmSource, dep) const dst = join(nmDest, dep) if (!existsSync(src)) { @@ -189,6 +217,13 @@ for (const dep of sorted) { continue } + // Verify the source has actual content (not just an empty node_modules stub) + const hasPj = existsSync(join(src, 'package.json')) + if (!hasPj && fileLinkedPaths.has(dep)) { + console.warn(` WARN: ${dep} file: link target has no package.json, skipping`) + continue + } + // Ensure parent dir exists for scoped packages (@keepkey/...) mkdirSync(dirname(dst), { recursive: true }) cpSync(src, dst, { recursive: true }) @@ -305,9 +340,22 @@ function pruneDir(dirPath: string) { pruneDir(nmDest) console.log(`[collect-externals] Pruned ${prunedCount} files/dirs (${(prunedSize / 1024 / 1024).toFixed(1)}MB removed)`) -// Remove non-macOS prebuilds, build artifacts, and native source files +// Remove prebuilds for OTHER platforms, build artifacts, and native source files const REMOVE_DIRS = ['node_gyp_bins', 'gyp', 'binding.gyp'] -const REMOVE_PREBUILD_PREFIXES = ['linux', 'win32', 'android'] +// Platform-aware: keep prebuilds for the current build platform, strip the rest +const isWindows = process.platform === 'win32' +const isMac = process.platform === 'darwin' +const REMOVE_PREBUILD_PREFIXES = isWindows + ? ['linux', 'darwin', 'android'] + : isMac + ? ['linux', 'win32', 'android'] + : ['darwin', 'win32', 'android'] // linux build +// HID prebuild directory prefixes (node-hid uses HID-{platform}-{arch} naming) +const REMOVE_HID_PREFIXES = isWindows + ? ['HID-linux', 'HID-darwin', 'HID_hidraw-linux'] + : isMac + ? ['HID-win', 'HID-linux', 'HID_hidraw-linux'] + : ['HID-win', 'HID-darwin'] // C/C++ source and build artifacts not needed at runtime (~7MB) const NATIVE_PRUNE_EXTENSIONS = ['.o', '.c', '.h', '.cc', '.cpp', '.gyp', '.gypi', '.vcxproj', '.m4', '.mk', '.am', '.in'] @@ -318,10 +366,9 @@ function cleanNativeArtifacts(dirPath: string) { for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { - // Remove non-macOS prebuilds (HID-win32-*, HID-linux-*, etc.) + // Remove prebuilds for other platforms (HID-win32-*, linux-x64-*, etc.) if (REMOVE_PREBUILD_PREFIXES.some(p => entry.name.startsWith(p)) || - entry.name.startsWith('HID-win') || entry.name.startsWith('HID-linux') || - entry.name.startsWith('HID_hidraw-linux')) { + REMOVE_HID_PREFIXES.some(p => entry.name.startsWith(p))) { try { const result = Bun.spawnSync(['du', '-sk', fullPath]) nativePrunedSize += parseInt(result.stdout.toString().split('\t')[0] || '0', 10) * 1024 diff --git a/projects/keepkey-vault/scripts/dev-hmr-windows.ps1 b/projects/keepkey-vault/scripts/dev-hmr-windows.ps1 new file mode 100644 index 0000000..624572a --- /dev/null +++ b/projects/keepkey-vault/scripts/dev-hmr-windows.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS + KeepKey Vault - Windows dev mode with Vite HMR +.DESCRIPTION + Starts Vite HMR on port 5173, builds the app, then runs Electrobun dev. + Also clears any existing process listening on port 5173. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Stop-Port { + param([int]$Port) + $connections = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue + if (-not $connections) { return } + + $procIds = $connections | Select-Object -ExpandProperty OwningProcess -Unique + foreach ($procId in $procIds) { + if ($procId -gt 0) { + try { + Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue + } catch {} + } + } +} + +function Stop-ProcessByNamePattern { + param([string]$Pattern) + Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -like $Pattern } | + ForEach-Object { + try { + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } catch {} + } +} + +# Ensure WebView2 uses a unique user data dir per run (avoids locked profile issues) +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$env:WEBVIEW2_USER_DATA_FOLDER = Join-Path $env:LOCALAPPDATA "com.keepkey.vault\\dev\\webview2-$timestamp" +$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222" + +# Resolve paths +if ($PSCommandPath) { + $ScriptDir = Split-Path -Path $PSCommandPath -Parent +} else { + $ScriptDir = Split-Path -Path $MyInvocation.MyCommand.Path -Parent +} +$ProjectDir = Split-Path -Path $ScriptDir -Parent + +Write-Step "Stopping existing dev/HMR processes" +Stop-Port -Port 5173 +Stop-ProcessByNamePattern -Pattern "*electrobun*" + +Write-Step "Starting Vite HMR (port 5173)" +$hmrProc = Start-Process -FilePath "bun" -ArgumentList @("run", "hmr") -WorkingDirectory $ProjectDir -PassThru + +# Wait for Vite server to bind +$maxWait = 20 +for ($i = 0; $i -lt $maxWait; $i++) { + $ready = Test-NetConnection -ComputerName "localhost" -Port 5173 -InformationLevel Quiet + if ($ready) { break } + Start-Sleep -Seconds 1 +} + +Push-Location $ProjectDir +try { + Write-Step "Building app (vite + electrobun build)" + bun run build + + Write-Step "Starting Electrobun dev" + bunx electrobun dev +} finally { + Pop-Location + if ($hmrProc -and -not $hmrProc.HasExited) { + try { + Stop-Process -Id $hmrProc.Id -Force -ErrorAction SilentlyContinue + } catch {} + } +} diff --git a/projects/keepkey-vault/src/bun/auth.ts b/projects/keepkey-vault/src/bun/auth.ts index 998ed94..aa61d57 100644 --- a/projects/keepkey-vault/src/bun/auth.ts +++ b/projects/keepkey-vault/src/bun/auth.ts @@ -164,9 +164,9 @@ export class AuthStore { requireAuth(req: Request): PairedClient { const token = this.extractBearerToken(req) - if (!token) throw new HttpError(403, 'Unauthorized') + if (!token) throw new HttpError(401, 'No bearer token — pair first via POST /auth/pair') const entry = this.validate(token) - if (!entry) throw new HttpError(403, 'Unauthorized') + if (!entry) throw new HttpError(401, 'Invalid or expired API key — re-pair via POST /auth/pair') return entry } diff --git a/projects/keepkey-vault/src/bun/db.ts b/projects/keepkey-vault/src/bun/db.ts index b101fe0..5d2d739 100644 --- a/projects/keepkey-vault/src/bun/db.ts +++ b/projects/keepkey-vault/src/bun/db.ts @@ -166,14 +166,16 @@ export function closeDb() { // ── Balance Cache ────────────────────────────────────────────────────── -export function getCachedBalances(deviceId: string): ChainBalance[] | null { +export function getCachedBalances(deviceId: string): { balances: ChainBalance[]; updatedAt: number } | null { try { if (!db) return null const rows = db.query( - 'SELECT chain_id, symbol, balance, balance_usd, address, tokens_json FROM balances WHERE device_id = ?' - ).all(deviceId) as Array<{ chain_id: string; symbol: string; balance: string; balance_usd: number; address: string; tokens_json: string | null }> + 'SELECT chain_id, symbol, balance, balance_usd, address, tokens_json, updated_at FROM balances WHERE device_id = ?' + ).all(deviceId) as Array<{ chain_id: string; symbol: string; balance: string; balance_usd: number; address: string; tokens_json: string | null; updated_at: number }> if (!rows || rows.length === 0) return null - return rows.map(r => { + let maxUpdatedAt = 0 + const balances = rows.map(r => { + if (r.updated_at > maxUpdatedAt) maxUpdatedAt = r.updated_at const entry: ChainBalance = { chainId: r.chain_id, symbol: r.symbol, @@ -186,6 +188,7 @@ export function getCachedBalances(deviceId: string): ChainBalance[] | null { } return entry }) + return { balances, updatedAt: maxUpdatedAt } } catch (e: any) { console.warn('[db] getCachedBalances failed:', e.message) return null diff --git a/projects/keepkey-vault/src/bun/eip712-decoder.ts b/projects/keepkey-vault/src/bun/eip712-decoder.ts new file mode 100644 index 0000000..5a5dacf --- /dev/null +++ b/projects/keepkey-vault/src/bun/eip712-decoder.ts @@ -0,0 +1,222 @@ +/** + * EIP-712 typed data decoder — extracts human-readable fields for signing approval UI. + * + * Two tiers: + * 1. Known descriptors (Permit2, ERC-2612 Permit, DAI Permit) matched by contract + primaryType + * 2. Generic fallback — reads types[primaryType] and auto-detects format from Solidity type names + */ +import type { EIP712DecodedField, EIP712DecodedInfo } from '../shared/types' + +// ── Helpers ────────────────────────────────────────────────────────────── + +function getNestedValue(obj: any, dotPath: string): any { + return dotPath.split('.').reduce((o, key) => o?.[key], obj) +} + +function humanizeFieldName(name: string): string { + // camelCase → Title Case: "maxFeePerGas" → "Max Fee Per Gas" + return name + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (s) => s.toUpperCase()) + .trim() +} + +function autoDetectFormat(solidityType: string, fieldName: string): EIP712DecodedField['format'] { + const lowerName = fieldName.toLowerCase() + if (solidityType === 'address') return 'address' + if (solidityType.startsWith('bytes')) return 'hex' + if (solidityType.startsWith('uint') || solidityType.startsWith('int')) { + if (lowerName.includes('expir') || lowerName.includes('deadline') || lowerName.includes('validto') || lowerName.includes('validuntil')) { + return 'datetime' + } + if (lowerName.includes('amount') || lowerName.includes('value') || lowerName.includes('nonce')) { + return 'amount' + } + return 'raw' + } + return 'raw' +} + +function formatValue(val: any, format: EIP712DecodedField['format']): string { + if (val === undefined || val === null) return '' + const str = String(val) + + switch (format) { + case 'address': + return str.length === 42 ? str : str + case 'datetime': { + const n = Number(str) + if (!n || n > 1e15) return str // already ms or invalid + try { + return new Date(n * 1000).toISOString().replace('T', ' ').replace('.000Z', ' UTC') + } catch { + return str + } + } + case 'amount': + return str + case 'hex': + return str.length > 66 ? str.slice(0, 66) + '...' : str + default: + return str.length > 200 ? str.slice(0, 200) + '...' : str + } +} + +// ── Known type descriptors ─────────────────────────────────────────────── + +const PERMIT2_ADDRESS = '0x000000000022d473030f116ddee9f6b43ac78ba3' + +interface KnownDescriptor { + match: (typedData: any) => boolean + operationName: string + extract: (message: any) => EIP712DecodedField[] +} + +const KNOWN_DESCRIPTORS: KnownDescriptor[] = [ + // Uniswap Permit2 — PermitSingle + { + match: (td) => + td.domain?.verifyingContract?.toLowerCase() === PERMIT2_ADDRESS && + td.primaryType === 'PermitSingle', + operationName: 'Permit2 (Single)', + extract: (msg) => { + const details = msg.details || {} + return [ + { label: 'Token', value: formatValue(details.token, 'address'), format: 'address', raw: details.token }, + { label: 'Amount', value: formatValue(details.amount, 'amount'), format: 'amount', raw: details.amount }, + { label: 'Expiration', value: formatValue(details.expiration, 'datetime'), format: 'datetime', raw: details.expiration }, + { label: 'Nonce', value: formatValue(details.nonce, 'raw'), format: 'raw' }, + { label: 'Spender', value: formatValue(msg.spender, 'address'), format: 'address', raw: msg.spender }, + { label: 'Sig Deadline', value: formatValue(msg.sigDeadline, 'datetime'), format: 'datetime', raw: msg.sigDeadline }, + ] + }, + }, + // Uniswap Permit2 — PermitBatch + { + match: (td) => + td.domain?.verifyingContract?.toLowerCase() === PERMIT2_ADDRESS && + td.primaryType === 'PermitBatch', + operationName: 'Permit2 (Batch)', + extract: (msg) => { + const details = Array.isArray(msg.details) ? msg.details : [] + const fields: EIP712DecodedField[] = [] + details.forEach((d: any, i: number) => { + const prefix = details.length > 1 ? `[${i + 1}] ` : '' + fields.push( + { label: `${prefix}Token`, value: formatValue(d.token, 'address'), format: 'address', raw: d.token }, + { label: `${prefix}Amount`, value: formatValue(d.amount, 'amount'), format: 'amount', raw: d.amount }, + { label: `${prefix}Expiration`, value: formatValue(d.expiration, 'datetime'), format: 'datetime', raw: d.expiration }, + ) + }) + fields.push( + { label: 'Spender', value: formatValue(msg.spender, 'address'), format: 'address', raw: msg.spender }, + { label: 'Sig Deadline', value: formatValue(msg.sigDeadline, 'datetime'), format: 'datetime', raw: msg.sigDeadline }, + ) + return fields + }, + }, + // ERC-2612 Permit (owner/spender/value/deadline) + { + match: (td) => + td.primaryType === 'Permit' && + td.types?.Permit?.some((f: any) => f.name === 'owner') && + td.types?.Permit?.some((f: any) => f.name === 'spender') && + td.types?.Permit?.some((f: any) => f.name === 'value') && + td.types?.Permit?.some((f: any) => f.name === 'deadline'), + operationName: 'ERC-2612 Permit', + extract: (msg) => [ + { label: 'Owner', value: formatValue(msg.owner, 'address'), format: 'address', raw: msg.owner }, + { label: 'Spender', value: formatValue(msg.spender, 'address'), format: 'address', raw: msg.spender }, + { label: 'Value', value: formatValue(msg.value, 'amount'), format: 'amount', raw: msg.value }, + { label: 'Nonce', value: formatValue(msg.nonce, 'raw'), format: 'raw' }, + { label: 'Deadline', value: formatValue(msg.deadline, 'datetime'), format: 'datetime', raw: msg.deadline }, + ], + }, + // DAI-style Permit (holder/spender/nonce/expiry/allowed) + { + match: (td) => + td.primaryType === 'Permit' && + td.types?.Permit?.some((f: any) => f.name === 'holder') && + td.types?.Permit?.some((f: any) => f.name === 'allowed'), + operationName: 'DAI Permit', + extract: (msg) => [ + { label: 'Holder', value: formatValue(msg.holder, 'address'), format: 'address', raw: msg.holder }, + { label: 'Spender', value: formatValue(msg.spender, 'address'), format: 'address', raw: msg.spender }, + { label: 'Nonce', value: formatValue(msg.nonce, 'raw'), format: 'raw' }, + { label: 'Expiry', value: formatValue(msg.expiry, 'datetime'), format: 'datetime', raw: msg.expiry }, + { label: 'Allowed', value: formatValue(msg.allowed, 'raw'), format: 'raw' }, + ], + }, +] + +// ── Generic fallback ───────────────────────────────────────────────────── + +function genericExtract(typedData: any): EIP712DecodedField[] { + const primaryType = typedData.primaryType + const typeDefs = typedData.types?.[primaryType] + const message = typedData.message || {} + + if (!Array.isArray(typeDefs)) { + // No type definition — dump top-level message keys + return Object.entries(message).map(([key, val]) => ({ + label: humanizeFieldName(key), + value: formatValue(val, typeof val === 'object' ? 'raw' : 'raw'), + format: 'raw' as const, + })) + } + + return typeDefs.map((field: { name: string; type: string }) => { + const format = autoDetectFormat(field.type, field.name) + const rawVal = message[field.name] + // For nested struct types, stringify the object + const displayVal = typeof rawVal === 'object' && rawVal !== null + ? JSON.stringify(rawVal) + : rawVal + return { + label: humanizeFieldName(field.name), + value: formatValue(displayVal, format), + format, + raw: String(rawVal ?? ''), + } + }) +} + +// ── Public API ─────────────────────────────────────────────────────────── + +export function decodeEIP712(typedData: any): EIP712DecodedInfo { + const domain = typedData.domain || {} + const primaryType = typedData.primaryType || 'Unknown' + const message = typedData.message || {} + + // Try known descriptors first + for (const desc of KNOWN_DESCRIPTORS) { + if (desc.match(typedData)) { + return { + operationName: desc.operationName, + domain: { + name: domain.name, + version: domain.version, + chainId: domain.chainId ? Number(domain.chainId) : undefined, + verifyingContract: domain.verifyingContract, + }, + primaryType, + fields: desc.extract(message), + isKnownType: true, + } + } + } + + // Generic fallback + return { + operationName: humanizeFieldName(primaryType), + domain: { + name: domain.name, + version: domain.version, + chainId: domain.chainId ? Number(domain.chainId) : undefined, + verifyingContract: domain.verifyingContract, + }, + primaryType, + fields: genericExtract(typedData), + isKnownType: false, + } +} diff --git a/projects/keepkey-vault/src/bun/engine-controller.ts b/projects/keepkey-vault/src/bun/engine-controller.ts index eed2c35..3e6c96b 100644 --- a/projects/keepkey-vault/src/bun/engine-controller.ts +++ b/projects/keepkey-vault/src/bun/engine-controller.ts @@ -151,8 +151,8 @@ export class EngineController extends EventEmitter { // ── Lifecycle ────────────────────────────────────────────────────────── async start() { - await this.fetchFirmwareManifest() - + // Register USB listeners BEFORE any await — if fetchFirmwareManifest() hangs + // or takes time, device attach/detach events during that window would be lost. usb.on('attach', (device) => { if (device.deviceDescriptor.idVendor !== KEEPKEY_VENDOR_ID) return console.log('[Engine] KeepKey USB attached') @@ -172,6 +172,8 @@ export class EngineController extends EventEmitter { this.updateState('disconnected') }) + await this.fetchFirmwareManifest() + // Device may already be plugged in await this.syncState() } @@ -209,7 +211,7 @@ export class EngineController extends EventEmitter { private async fetchFirmwareManifest() { try { - const res = await fetch(MANIFEST_URL) + const res = await fetch(MANIFEST_URL, { signal: AbortSignal.timeout(10000) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) this.manifest = await res.json() as FirmwareManifest this.latestFirmware = this.manifest.latest.firmware.version.replace(/^v/, '') @@ -290,11 +292,14 @@ export class EngineController extends EventEmitter { this.attachTransportListeners() this.lastError = null try { - console.log('[Engine] Getting features...') + // Use initialize() instead of getFeatures() — getFeatures() sends + // GetFeatures which fails in bootloader mode with "Unknown message". + // initialize() sends Initialize → Features and works in both modes. + console.log('[Engine] Initializing device...') this.cachedFeatures = await withTimeout( - result.wallet.getFeatures(), + result.wallet.initialize(), PAIR_TIMEOUT_MS, - 'getFeatures' + 'initialize' ) const hashVerification = this.verifyHashes(this.cachedFeatures) console.log('[Engine] Features:', JSON.stringify({ @@ -486,8 +491,24 @@ export class EngineController extends EventEmitter { const needsFw = fwVersion ? (this.versionLessThan(fwVersion, this.latestFirmware) || fwVersion === '4.0.0') : false - const needsBl = blVersion - ? this.versionLessThan(blVersion, this.latestBootloader) + + // 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) { + const blHash = base64ToHex(features.bootloaderHash) + if (blHash && this.manifest?.hashes?.bootloader) { + const resolved = this.manifest.hashes.bootloader[blHash] + if (resolved) { + effectiveBlVersion = resolved.replace(/^v/, '') + console.log(`[Engine] Resolved BL hash ${blHash.slice(0, 8)}… → v${effectiveBlVersion}`) + } + } + } + const needsBl = effectiveBlVersion + ? this.versionLessThan(effectiveBlVersion, this.latestBootloader) : bootloaderMode const hashes = features ? this.verifyHashes(features) : {} @@ -579,13 +600,19 @@ export class EngineController extends EventEmitter { if (!response.ok) throw new Error(`Failed to download firmware: ${response.status}`) const firmware = Buffer.from(await response.arrayBuffer()) - // Binary integrity check — compare downloaded file hash against manifest + // Binary integrity check — compare downloaded file hash against manifest. + // If the binary starts with "KPKY" magic bytes, strip the 256-byte container + // header before hashing — the manifest hash covers only the payload. if (this.manifest?.latest?.firmware?.hash) { - const downloadedHash = sha256Hex(firmware) + const hasKpkyHeader = firmware.length >= 256 + && firmware[0] === 0x4B && firmware[1] === 0x50 + && firmware[2] === 0x4B && firmware[3] === 0x59 // "KPKY" + const hashPayload = hasKpkyHeader ? firmware.subarray(256) : firmware + const downloadedHash = sha256Hex(hashPayload) if (downloadedHash !== this.manifest.latest.firmware.hash) { throw new Error(`Firmware binary integrity check failed: expected ${this.manifest.latest.firmware.hash}, got ${downloadedHash}`) } - console.log('[Engine] Firmware binary integrity verified') + console.log(`[Engine] Firmware binary integrity verified${hasKpkyHeader ? ' (KPKY header stripped)' : ''}`) } this.emit('firmware-progress', { percent: 30, message: 'Erasing current firmware...' }) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 7109e49..4a1bc3e 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1,4 +1,13 @@ import { BrowserView, BrowserWindow, Updater, Utils, ApplicationMenu } from "electrobun/bun" + +// ── Global error handlers (MUST be first — prevents silent crashes) ── +process.on('uncaughtException', (err) => { + console.error('[Vault] UNCAUGHT EXCEPTION:', err) +}) +process.on('unhandledRejection', (reason) => { + console.error('[Vault] UNHANDLED REJECTION:', reason) +}) + import { EngineController } from "./engine-controller" import { startRestApi, type RestApiCallbacks } from "./rest-api" import { AuthStore } from "./auth" @@ -138,7 +147,7 @@ function cacheAddress(chainId: string, path: string, address: string) { } catch { /* never block on cache failure */ } } -const DEV_SERVER_PORT = 5173 +const DEV_SERVER_PORT = 5177 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` const REST_API_PORT = 1646 @@ -190,10 +199,30 @@ const restCallbacks: RestApiCallbacks = { }, onSigningRequest: async (info: SigningRequestInfo) => { try { rpc.send['signing-request'](info) } catch { /* webview not ready */ } - return auth.requestSigningApproval(info.id) + // Bring window to front so user sees the approval prompt immediately + try { + mainWindow.setAlwaysOnTop(true) + mainWindow.focus() + } catch { /* window not ready */ } + try { + return await auth.requestSigningApproval(info.id) + } finally { + // Restore normal window level after user responds (or timeout) + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + } }, onPairRequest: (info) => { try { rpc.send['pair-request'](info) } catch { /* webview not ready */ } + // Bring window to front so user sees the pairing approval prompt + try { + mainWindow.setAlwaysOnTop(true) + mainWindow.focus() + } catch { /* window not ready */ } + }, + onPairDismissed: () => { + // Restore normal window level + dismiss frontend overlay (covers timeout case) + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } + try { rpc.send['pair-dismissed']({}) } catch { /* webview not ready */ } }, getVersion: () => appVersionCache, } @@ -382,6 +411,18 @@ const rpc = BrowserView.defineRPC({ } return result }, + solanaSignMessage: async (params) => { + if (!engine.wallet) throw new Error('No device connected') + const result = await engine.wallet.solanaSignMessage(params) + return { + signature: result.signature instanceof Uint8Array + ? Buffer.from(result.signature).toString('base64') + : result.signature, + publicKey: result.publicKey instanceof Uint8Array + ? Buffer.from(result.publicKey).toString('base64') + : result.publicKey, + } + }, // ── Pioneer integration (batch portfolio API) ──────────────── getBalances: async () => { @@ -393,6 +434,8 @@ const rpc = BrowserView.defineRPC({ pioneer = await getPioneer() } catch (e: any) { console.warn('[getBalances] Pioneer init failed (will return zero balances):', e.message) + // Notify UI so user can change server or get support + try { rpc.send['pioneer-error']({ message: e.message, url: getPioneerApiBase() }) } catch { /* webview not ready */ } } const wallet = engine.wallet as any @@ -483,53 +526,81 @@ const rpc = BrowserView.defineRPC({ if (chain.networkId) networkToChain.set(chain.networkId, chain.id) } - // 3. Single API call for ALL balances + prices + // 3. Single API call — use GetPortfolio (charts endpoint) which returns + // both native balances AND tokens (ERC-20, etc.) const results: ChainBalance[] = [] try { if (!pioneer) throw new Error('Pioneer client not available') const resp = await withTimeout( - pioneer.GetPortfolioBalances({ + pioneer.GetPortfolio({ pubkeys: pubkeys.map(p => ({ caip: p.caip, pubkey: p.pubkey })) }), PIONEER_TIMEOUT_MS, - 'GetPortfolioBalances' + 'GetPortfolio' ) - // Defensive response unwrapping — handle all known Pioneer response shapes: - // { data: { data: { balances: [...] } } } (Swagger double-wrap) - // { data: { balances: [...] } } (Swagger single-wrap) - // { data: [...] } (raw array) - const rawData = resp?.data?.data || resp?.data || {} - const data: any[] = rawData.balances || (Array.isArray(rawData) ? rawData : []) - - if (data.length === 0 && pubkeys.length > 0) { - console.warn(`[getBalances] Pioneer returned 0 balance entries for ${pubkeys.length} pubkeys — response shape:`, JSON.stringify(Object.keys(resp?.data || {})).slice(0, 200)) - } + // Unwrap Swagger double-wrap: { data: { data: { balances, tokens } } } + const portfolio = resp?.data?.data || resp?.data || {} + const nativeEntries: any[] = portfolio.balances || (Array.isArray(portfolio) ? portfolio : []) + const portfolioTokens: any[] = portfolio.tokens || [] - // Separate native balances from token entries - const nativeEntries: any[] = [] + console.log(`[getBalances] GetPortfolio response: ${nativeEntries.length} balances, ${portfolioTokens.length} tokens`) + + // Convert portfolio.tokens (different shape) into the same format as native entries + // so our existing token grouping logic works on them const tokenEntries: any[] = [] - for (const d of data) { + for (const t of portfolioTokens) { + if (!t.assetCaip) continue + // Skip native assets that leaked into tokens array + if (t.assetCaip.includes('/slip44:')) continue + tokenEntries.push({ + caip: t.assetCaip, + networkId: t.networkId, + symbol: t.token?.symbol || 'UNK', + name: t.token?.name || t.token?.coingeckoId || 'Unknown', + balance: t.token?.balance?.toString() || '0', + valueUsd: t.token?.balanceUSD || 0, + priceUsd: t.token?.price || 0, + decimals: t.token?.decimals ?? t.token?.decimal ?? 18, + type: 'token', + contract: t.assetCaip.match(/\/erc20:(0x[a-fA-F0-9]+)/)?.[1] || undefined, + }) + } + + // Also scan nativeEntries for any tokens mixed in (belt + suspenders) + const pureNatives: any[] = [] + for (const d of nativeEntries) { const caip = d.caip || '' - if (caip.includes('/erc20:') || (d.type === 'token' && !d.isNative)) { + const caipPath = caip.split('/')[1] || '' + const isTokenByCaip = caipPath && !caipPath.startsWith('slip44:') + const isTokenByType = d.type === 'token' && d.isNative !== true + if (isTokenByCaip || isTokenByType) { tokenEntries.push(d) } else { - nativeEntries.push(d) + pureNatives.push(d) } } - console.log(`[getBalances] Portfolio response: ${nativeEntries.length} natives, ${tokenEntries.length} tokens`) + console.log(`[getBalances] After classification: ${pureNatives.length} natives, ${tokenEntries.length} tokens`) // Group tokens by their parent chain (via networkId or CAIP prefix) + // Also log the networkToChain map so we can audit matching + console.log(`[getBalances] networkToChain map (${networkToChain.size} entries): ${JSON.stringify(Object.fromEntries(networkToChain))}`) + const tokensByChainId = new Map() + let tokensSkippedZero = 0, tokensSkippedNoChain = 0, tokensGrouped = 0 for (const tok of tokenEntries) { const bal = parseFloat(String(tok.balance ?? '0')) - if (bal <= 0) continue + if (bal <= 0) { tokensSkippedZero++; continue } // Determine parent chainId from networkId or CAIP-2 prefix const tokNetworkId = tok.networkId || '' const caipPrefix = (tok.caip || '').split('/')[0] // e.g. "eip155:1" const parentChainId = networkToChain.get(tokNetworkId) || networkToChain.get(caipPrefix) || null - if (!parentChainId) continue // skip tokens for chains we don't track + if (!parentChainId) { + tokensSkippedNoChain++ + console.warn(`[getBalances] Token DROPPED (no parent chain): ${tok.symbol} caip=${tok.caip} networkId=${tokNetworkId} caipPrefix=${caipPrefix} bal=${bal} usd=${tok.valueUsd}`) + continue + } // Extract contract address from CAIP: "eip155:1/erc20:0xdac17..." → "0xdac17..." const contractMatch = (tok.caip || '').match(/\/erc20:(0x[a-fA-F0-9]+)/) @@ -552,6 +623,12 @@ const rpc = BrowserView.defineRPC({ const existing = tokensByChainId.get(parentChainId) || [] existing.push(token) tokensByChainId.set(parentChainId, existing) + tokensGrouped++ + } + + console.log(`[getBalances] Token grouping: ${tokensGrouped} grouped, ${tokensSkippedZero} skipped (zero bal), ${tokensSkippedNoChain} DROPPED (no parent chain)`) + for (const [chainId, toks] of tokensByChainId) { + console.log(`[getBalances] ${chainId}: ${toks.length} tokens, $${toks.reduce((s, t) => s + t.balanceUsd, 0).toFixed(2)} — ${toks.map(t => t.symbol).join(', ')}`) } // Merge user-added custom tokens as placeholders @@ -581,8 +658,8 @@ const rpc = BrowserView.defineRPC({ for (const entry of pubkeys) { if (entry.chainId === 'bitcoin') { // Find the Pioneer response for this xpub - const match = nativeEntries.find((d: any) => d.pubkey === entry.pubkey) - || nativeEntries.find((d: any) => d.caip === entry.caip && d.address === entry.pubkey) + const match = pureNatives.find((d: any) => d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.caip === entry.caip && d.address === entry.pubkey) const bal = parseFloat(String(match?.balance ?? '0')) const usd = Number(match?.valueUsd ?? 0) btcTotalBalance += bal @@ -595,8 +672,8 @@ const rpc = BrowserView.defineRPC({ // EVM multi-address: aggregate per-chain, update per-address balance if (evmAddressSet.has(entry.pubkey.toLowerCase())) { - const match = nativeEntries.find((d: any) => d.caip === entry.caip && d.pubkey === entry.pubkey) - || nativeEntries.find((d: any) => d.caip === entry.caip && d.address?.toLowerCase() === entry.pubkey.toLowerCase()) + const match = pureNatives.find((d: any) => d.caip === entry.caip && d.pubkey === entry.pubkey) + || pureNatives.find((d: any) => d.caip === entry.caip && d.address?.toLowerCase() === entry.pubkey.toLowerCase()) const bal = parseFloat(String(match?.balance ?? '0')) const usd = Number(match?.valueUsd ?? 0) // Accumulate per-address USD for EvmAddressManager @@ -617,8 +694,8 @@ const rpc = BrowserView.defineRPC({ continue } - const match = nativeEntries.find((d: any) => d.caip === entry.caip) - || nativeEntries.find((d: any) => d.pubkey === entry.pubkey) + const match = pureNatives.find((d: any) => d.caip === entry.caip) + || pureNatives.find((d: any) => d.pubkey === entry.pubkey) const chainTokens = tokensByChainId.get(entry.chainId) // Sum token USD values into the chain total const tokenUsdTotal = chainTokens?.reduce((sum, t) => sum + t.balanceUsd, 0) || 0 @@ -688,6 +765,16 @@ const rpc = BrowserView.defineRPC({ } } + // ── Final audit log ── + const totalTokens = results.reduce((n, r) => n + (r.tokens?.length || 0), 0) + const totalUsd = results.reduce((n, r) => n + (r.balanceUsd || 0), 0) + console.log(`[getBalances] FINAL: ${results.length} chains, ${totalTokens} tokens, $${totalUsd.toFixed(2)}`) + for (const r of results) { + if (r.tokens && r.tokens.length > 0) { + console.log(`[getBalances] ${r.chainId}: ${r.tokens.length} tokens attached`) + } + } + return results }, @@ -1011,10 +1098,12 @@ const rpc = BrowserView.defineRPC({ approvePairing: async () => { const apiKey = auth.approvePairing() if (!apiKey) throw new Error('No pending pairing request') + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } return { apiKey } }, rejectPairing: async () => { auth.rejectPairing() + try { mainWindow.setAlwaysOnTop(false) } catch { /* ignore */ } }, approveSigningRequest: async (params) => { if (!auth.approveSigningRequest(params.id)) throw new Error('No pending signing request with that id') @@ -1065,7 +1154,9 @@ const rpc = BrowserView.defineRPC({ getCachedBalances: async () => { const deviceId = engine.getDeviceState().deviceId if (!deviceId) return null - return getCachedBalances(deviceId) + const result = getCachedBalances(deviceId) + if (!result) return null + return { balances: result.balances, updatedAt: result.updatedAt } }, // ── Watch-only mode ───────────────────────────────────── @@ -1077,7 +1168,8 @@ const rpc = BrowserView.defineRPC({ getWatchOnlyBalances: async () => { const snap = getLatestDeviceSnapshot() if (!snap) return null - return getCachedBalances(snap.deviceId) + const result = getCachedBalances(snap.deviceId) + return result?.balances ?? null }, getWatchOnlyPubkeys: async () => { const snap = getLatestDeviceSnapshot() @@ -1114,7 +1206,14 @@ const rpc = BrowserView.defineRPC({ await Updater.downloadUpdate() }, applyUpdate: async () => { - await Updater.applyUpdate() + // Fire-and-forget: Updater.applyUpdate() extracts the new version, + // replaces the running app, spawns a relaunch shell, then calls quit(). + // The quit() kills the Bun process before the RPC response can be sent, + // causing the frontend to see a timeout error. Instead, return immediately + // and let the status messages ('applying', 'replacing-app', 'complete') + // drive the frontend UI. + Updater.applyUpdate().catch(e => console.error('[Vault] applyUpdate failed:', e)) + return { started: true } }, getUpdateInfo: async () => { return Updater.updateInfo() || null @@ -1123,6 +1222,10 @@ const rpc = BrowserView.defineRPC({ version: await Updater.localInfo.version(), channel: await Updater.localInfo.channel(), }), + // ── Window controls (for custom titlebar) ───────────────── + windowClose: async () => { _mainWindow?.close() }, + windowMinimize: async () => { _mainWindow?.minimize() }, + windowMaximize: async () => { _mainWindow?.maximize() }, }, messages: {}, }, @@ -1159,11 +1262,19 @@ evmAddresses.on('change', (set: EvmAddressSet) => { try { rpc.send['evm-addresses-update'](set) } catch { /* webview not ready yet */ } }) -// Updater status changes → push to WebView +// Updater status changes → push to WebView (debounced to prevent spam) +let lastUpdateStatus = '' +let lastUpdateStatusTime = 0 Updater.onStatusChange((entry: any) => { try { + const status = entry.status || '' + const now = Date.now() + // Debounce: skip duplicate error statuses within 5 seconds + if ((status === 'error' || status === 'download-error') && status === lastUpdateStatus && now - lastUpdateStatusTime < 5000) return + lastUpdateStatus = status + lastUpdateStatusTime = now rpc.send['update-status']({ - status: entry.status, + status, message: entry.message, timestamp: entry.timestamp, progress: entry.details?.progress, @@ -1176,15 +1287,19 @@ Updater.onStatusChange((entry: any) => { // ── Window Setup ────────────────────────────────────────────────────── async function getMainViewUrl(): Promise { - const channel = await Updater.localInfo.channel() - if (channel === "dev") { - try { - await fetch(DEV_SERVER_URL, { method: "HEAD" }) - console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`) - return DEV_SERVER_URL - } catch { - console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support.") + try { + const channel = await Updater.localInfo.channel() + if (channel === "dev") { + try { + await fetch(DEV_SERVER_URL, { method: "HEAD" }) + console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`) + return DEV_SERVER_URL + } catch { + console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support.") + } } + } catch (e) { + console.warn('[Vault] Failed to detect channel, falling back to production view:', e) } return "views://mainview/index.html" } @@ -1225,10 +1340,12 @@ ApplicationMenu.setApplicationMenu([ }, ]) +let _mainWindow: BrowserWindow | null = null const mainWindow = new BrowserWindow({ title: "KeepKey Vault", url, rpc, + titleBarStyle: "hidden", frame: { width: 1200, height: 800, @@ -1236,6 +1353,67 @@ const mainWindow = new BrowserWindow({ y: 100, }, }) +_mainWindow = mainWindow + +// Set window icon on Windows via Win32 API (SendMessage WM_SETICON). +// Electrobun's setWindowIcon is a no-op on Windows (stub in nativeWrapper.cpp). +// mainWindow.ptr is the HWND, so we call user32.dll directly. +if (process.platform === 'win32') { + try { + const { dlopen, FFIType, ptr: ffiPtr } = require("bun:ffi") + const path = require("path") + const appRoot = path.resolve(import.meta.dir, "..", "..", "..") + const { existsSync } = require("fs") + // Prefer app-real.ico (proper ICO from production build) over app.ico (may be renamed PNG) + const realIco = path.join(appRoot, "Resources", "app-real.ico") + const fallbackIco = path.join(appRoot, "Resources", "app.ico") + const iconPath = existsSync(realIco) ? realIco : fallbackIco + + // LoadImageW from user32.dll to load .ico file + const user32 = dlopen("user32.dll", { + LoadImageW: { + args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.u32], + returns: FFIType.ptr, + }, + SendMessageW: { + args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], + returns: FFIType.ptr, + }, + GetSystemMetrics: { + args: [FFIType.i32], + returns: FFIType.i32, + }, + }) + + const IMAGE_ICON = 1 + const LR_LOADFROMFILE = 0x00000010 + const WM_SETICON = 0x0080 + const ICON_BIG = 1 + const ICON_SMALL = 0 + const SM_CXICON = 11 + const SM_CYICON = 12 + const SM_CXSMICON = 49 + const SM_CYSMICON = 50 + + // Encode icon path as UTF-16LE for LoadImageW + const iconPathW = Buffer.from(iconPath + '\0', 'utf16le') + + const cxIcon = user32.symbols.GetSystemMetrics(SM_CXICON) + const cyIcon = user32.symbols.GetSystemMetrics(SM_CYICON) + const cxSmIcon = user32.symbols.GetSystemMetrics(SM_CXSMICON) + const cySmIcon = user32.symbols.GetSystemMetrics(SM_CYSMICON) + + const bigIcon = user32.symbols.LoadImageW(null, iconPathW, IMAGE_ICON, cxIcon, cyIcon, LR_LOADFROMFILE) + const smallIcon = user32.symbols.LoadImageW(null, iconPathW, IMAGE_ICON, cxSmIcon, cySmIcon, LR_LOADFROMFILE) + + const hwnd = mainWindow.ptr + if (bigIcon) user32.symbols.SendMessageW(hwnd, WM_SETICON, ICON_BIG, bigIcon) + if (smallIcon) user32.symbols.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, smallIcon) + console.log('[Vault] Window icon set via Win32 API:', iconPath) + } catch (e: any) { + console.warn("[Vault] Failed to set window icon:", e.message) + } +} // Start engine (USB event listeners + initial device sync) await engine.start() diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index aa275ac..cbee53f 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -1,7 +1,8 @@ import type { EngineController } from './engine-controller' import type { AuthStore } from './auth' import { HttpError } from './auth' -import type { SigningRequestInfo, ApiLogEntry } from '../shared/types' +import type { SigningRequestInfo, ApiLogEntry, EIP712DecodedInfo } from '../shared/types' +import { decodeEIP712 } from './eip712-decoder' import { CHAINS } from '../shared/chains' import { readFileSync } from 'fs' import { join } from 'path' @@ -12,6 +13,7 @@ export interface RestApiCallbacks { onApiLog: (entry: ApiLogEntry) => void onSigningRequest: (info: SigningRequestInfo) => Promise onPairRequest: (info: { name: string; url: string; imageUrl: string }) => void + onPairDismissed?: () => void getVersion: () => string } @@ -32,6 +34,13 @@ function requireWallet(engine: EngineController) { return engine.wallet } +/** SLIP44 coin type → KeepKey firmware coin name (must match firmware coin table) */ +const SLIP44_TO_COIN: Record = { + 0: 'Bitcoin', 2: 'Litecoin', 3: 'Dogecoin', 5: 'Dash', + 20: 'DigiByte', 60: 'Ethereum', 118: 'Cosmos', 144: 'Ripple', + 145: 'BitcoinCash', 501: 'Solana', 931: 'Rune', +} + // ── Features cache (10s TTL, matches keepkey-desktop) ────────────────── let featuresCache: { timestamp: number; data: any } | null = null const FEATURES_TTL_MS = 10_000 @@ -306,7 +315,7 @@ const startTime = Date.now() /** Set of signing endpoints that require user approval */ const SIGNING_ROUTES = new Set([ '/eth/sign-transaction', '/eth/sign-typed-data', '/eth/sign', - '/utxo/sign-transaction', '/xrp/sign-transaction', '/solana/sign-transaction', + '/utxo/sign-transaction', '/xrp/sign-transaction', '/solana/sign-transaction', '/solana/sign-message', '/cosmos/sign-amino', '/cosmos/sign-amino-delegate', '/cosmos/sign-amino-undelegate', '/cosmos/sign-amino-redelegate', '/cosmos/sign-amino-withdraw-delegator-rewards-all', '/cosmos/sign-amino-ibc-transfer', @@ -545,8 +554,13 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 callbacks.onPairRequest({ name: body.name, url: body.url || '', imageUrl: body.imageUrl || '' }) } // requestPair requires user approval via UI — NOT auto-granted - const apiKey = await auth.requestPair(body) - return json({ apiKey }) + try { + const apiKey = await auth.requestPair(body) + return json({ apiKey }) + } finally { + // Dismiss UI overlay + restore window level on approve, reject, or timeout + callbacks?.onPairDismissed?.() + } } } @@ -563,12 +577,22 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // (we'll parse body again in the handler below — Bun caches it) try { const preview = await req.clone().json() as any - signingInfo.from = preview.from || preview.signerAddress - signingInfo.to = preview.to - signingInfo.value = preview.value signingInfo.chain = path.split('/')[1] // e.g. "eth", "cosmos" - signingInfo.chainId = preview.chainId || preview.chain_id - signingInfo.data = preview.data ? (preview.data.length > 66 ? preview.data.slice(0, 66) + '...' : preview.data) : undefined + + if (path === '/eth/sign-typed-data') { + // EIP-712: address + typedData structure (no from/to/value/data) + signingInfo.from = preview.address + signingInfo.chainId = preview.typedData?.domain?.chainId ? Number(preview.typedData.domain.chainId) : undefined + if (preview.typedData) { + signingInfo.typedDataDecoded = decodeEIP712(preview.typedData) + } + } else { + signingInfo.from = preview.from || preview.signerAddress + signingInfo.to = preview.to + signingInfo.value = preview.value + signingInfo.chainId = preview.chainId || preview.chain_id + signingInfo.data = preview.data ? (preview.data.length > 66 ? preview.data.slice(0, 66) + '...' : preview.data) : undefined + } } catch { /* body parse failed, non-fatal */ } const approved = await callbacks.onSigningRequest(signingInfo) @@ -797,9 +821,26 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 auth.requireAuth(req) const wallet = requireWallet(engine) const body = await parseRequest(req, S.EthSignTypedDataRequest) - const { addressNList } = auth.getAccount(body.address) - const result = await wallet.ethSignTypedData({ addressNList, typedData: body.typedData }) - return json(result) + + // Address resolution: cache first, then scan accounts + let addressNList: number[] + try { + addressNList = auth.getAccount(body.address).addressNList + } catch { + addressNList = await findEthAddressNList(wallet, auth, body.address) + } + + try { + const result = await wallet.ethSignTypedData({ addressNList, typedData: body.typedData }) + return json(result) + } catch (err: any) { + // Distinguish user cancellation from actual failures + const msg = String(err?.message || err || '').toLowerCase() + if (msg.includes('cancel') || msg.includes('rejected') || msg.includes('denied') || msg.includes('action cancelled')) { + return json({ error: 'User cancelled signing on device' }, 403) + } + throw err + } } if (path === '/eth/sign' && method === 'POST') { @@ -807,8 +848,8 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 const wallet = requireWallet(engine) const body = await parseRequest(req, S.EthSignRequest) const { addressNList } = auth.getAccount(body.address) - const msgBytes = Buffer.from(body.message.slice(2), 'hex') - const result = await wallet.ethSignMessage({ addressNList, message: msgBytes }) + // hdwallet expects message as a hex string (isHexString check), not Buffer + const result = await wallet.ethSignMessage({ addressNList, message: body.message }) return json(result) } @@ -1006,6 +1047,28 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 return json(result) } + // ── SOLANA MESSAGE SIGNING (firmware type 754) ────────────────── + if (path === '/solana/sign-message' && method === 'POST') { + auth.requireAuth(req) + const wallet = requireWallet(engine) + const body = await parseRequest(req, S.SolanaSignMessageRequest) + const addressNList = body.addressNList || body.address_n || [0x8000002C, 0x800001F5, 0x80000000, 0x80000000] + const result = await wallet.solanaSignMessage({ + addressNList, + message: body.message, + showDisplay: body.show_display !== false, + }) + // result: { publicKey: Uint8Array, signature: Uint8Array } + return json({ + signature: result.signature instanceof Uint8Array + ? Buffer.from(result.signature).toString('base64') + : result.signature, + publicKey: result.publicKey instanceof Uint8Array + ? Buffer.from(result.publicKey).toString('base64') + : result.publicKey, + }) + } + // ── DEVICE INFO (2 endpoints — read-only) ──────────────────── if (path === '/system/info/get-features' && method === 'POST') { auth.requireAuth(req) @@ -1228,9 +1291,9 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 }) continue } + const coinType = p.address_n.length >= 2 ? (p.address_n[1] >= 0x80000000 ? p.address_n[1] - 0x80000000 : p.address_n[1]) : 0 + const coin = p.coin || SLIP44_TO_COIN[coinType] || 'Bitcoin' try { - const coinType = p.address_n.length >= 2 ? (p.address_n[1] >= 0x80000000 ? p.address_n[1] - 0x80000000 : p.address_n[1]) : 0 - const coin = p.coin || (coinType === 60 ? 'Ethereum' : 'Bitcoin') const result = await wallet.getPublicKeys([{ addressNList: p.address_n, curve: 'secp256k1', @@ -1254,7 +1317,7 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 addressNList: p.address_n, }) } catch (err: any) { - console.warn(`[REST] batch pubkey failed for path ${JSON.stringify(p.address_n)}:`, err?.message) + console.warn(`[REST] batch pubkey failed for path ${JSON.stringify(p.address_n)} coin=${coin} scriptType=${p.script_type}:`, err?.message) } } diff --git a/projects/keepkey-vault/src/bun/schemas.ts b/projects/keepkey-vault/src/bun/schemas.ts index b2fbb81..f7e64ba 100644 --- a/projects/keepkey-vault/src/bun/schemas.ts +++ b/projects/keepkey-vault/src/bun/schemas.ts @@ -135,6 +135,14 @@ export const SolanaSignRequest = z.object({ raw_tx: z.string().min(1), }).strip() +/** POST /solana/sign-message — sign an arbitrary message (firmware type 754) */ +export const SolanaSignMessageRequest = z.object({ + address_n: z.array(z.number().int()).optional(), + addressNList: z.array(z.number().int()).optional(), + message: z.string().min(1), + show_display: z.boolean().optional(), +}).strip() + /** POST /system/info/get-public-key */ export const GetPublicKeyRequest = z.object({ diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 3675d92..90671be 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -14,7 +14,7 @@ import { SplashScreen } from "./components/SplashScreen" import { WatchOnlyPrompt } from "./components/WatchOnlyPrompt" import { DeviceClaimedDialog } from "./components/DeviceClaimedDialog" import { OobSetupWizard } from "./components/OobSetupWizard" -import { TopNav } from "./components/TopNav" +import { TopNav, TrafficLights } from "./components/TopNav" import type { NavTab } from "./components/TopNav" import { Dashboard } from "./components/Dashboard" import { AppStore } from "./components/AppStore" @@ -120,9 +120,14 @@ function App() { const [pairRequest, setPairRequest] = useState(null) useEffect(() => { - return onRpcMessage("pair-request", (payload) => { + const unsub1 = onRpcMessage("pair-request", (payload) => { setPairRequest(payload as PairingRequestInfo) }) + // Dismiss overlay on timeout or external resolution + const unsub2 = onRpcMessage("pair-dismissed", () => { + setPairRequest(null) + }) + return () => { unsub1(); unsub2() } }, []) const handleApprovePairing = useCallback(async () => { @@ -434,10 +439,41 @@ function App() { // ── Render phases ─────────────────────────────────────────────── + // Always-visible window controls + drag bar (all phases including splash/setup) + const windowControls = ( + + + + + ) + + // Always-visible update banner (all phases) + const updateBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" ? ( + setUpdateDismissed(true)} + /> + ) : null + // Watch-only mode: render dashboard with cached data (read-only) if (watchOnlyMode) { return ( - <>{firmwareDropZone} + <>{windowControls}{updateBanner}{firmwareDropZone} {firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} @@ -473,7 +509,7 @@ function App() { const needsPin = deviceState.state === "needs_pin" const needsPassphrase = deviceState.state === "needs_passphrase" return ( - <>{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} {firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} setWizardComplete(true)} /> ) } // ── Ready phase ───────────────────────────────────────────────── - const showBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" + // Warning/error are now bottom-right toasts — only push content down for actionable top banners + const showBanner = !updateDismissed && update.phase !== "idle" && update.phase !== "checking" && update.phase !== "warning" && update.phase !== "error" return ( - <>{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} + <>{windowControls}{updateBanner}{firmwareDropZone}{signingOverlay}{pairingOverlay}{passphraseOverlay}{charOverlay}{pinOverlay} {!portfolioLoaded && activeTab === "vault" && ( )} @@ -527,20 +564,9 @@ function App() { activeTab={activeTab} onTabChange={handleTabChange} /> - {showBanner && ( - setUpdateDismissed(true)} - /> - )} {/* pt: 54px TopNav + 50px banner height when visible */} - {activeTab === "vault" && } + {activeTab === "vault" && setSettingsOpen(true)} />} {activeTab === "apps" && } diff --git a/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx b/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx index 988e87e..5d1a793 100644 --- a/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx +++ b/projects/keepkey-vault/src/mainview/components/ApiAuditLog.tsx @@ -122,14 +122,39 @@ function truncateJson(data: any, maxLen = 500): string { } } -/** Collapsible JSON block */ +/** Collapsible JSON block with copy button */ function JsonBlock({ label, data }: { label: string; data: any }) { + const [copied, setCopied] = useState(false) if (data === undefined || data === null) return null + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + const fullJson = typeof data === "string" ? data : JSON.stringify(data, null, 2) + navigator.clipboard.writeText(fullJson).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }).catch(() => {}) + } + return ( - - {label} - + + + {label} + + + {copied ? "Copied!" : "Copy"} + + void watchOnly?: boolean + onOpenSettings?: () => void } -export function Dashboard({ onLoaded, watchOnly }: DashboardProps) { +/** Format a timestamp as a relative "time ago" string */ +function formatTimeAgo(ts: number): string { + const diff = Date.now() - ts + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export function Dashboard({ onLoaded, watchOnly, onOpenSettings }: DashboardProps) { const { t } = useTranslation("dashboard") const [selectedChain, setSelectedChain] = useState(null) const [balances, setBalances] = useState>(new Map()) - const [loadingBalances, setLoadingBalances] = useState(true) + const [loadingBalances, setLoadingBalances] = useState(false) const [initialLoaded, setInitialLoaded] = useState(false) const [activeSliceIndex, setActiveSliceIndex] = useState(0) - const [fetchKey, setFetchKey] = useState(0) const [customChainDefs, setCustomChainDefs] = useState([]) const [showAddChain, setShowAddChain] = useState(false) + const [pioneerError, setPioneerError] = useState(null) + const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) + const [tokenWarning, setTokenWarning] = useState(false) + const [hasEverRefreshed, setHasEverRefreshed] = useState(false) + + // Listen for Pioneer connection errors from backend + useEffect(() => { + return onRpcMessage("pioneer-error", (payload) => { + setPioneerError(payload as PioneerError) + }) + }, []) // Load custom chains on mount and register their explorer links useEffect(() => { @@ -46,73 +81,79 @@ export function Dashboard({ onLoaded, watchOnly }: DashboardProps) { .catch(() => {}) }, []) - // Cache-first: show cached balances instantly, then refresh with live data + // On mount: load cached balances ONLY (no live fetch — saves API credits) useEffect(() => { let cancelled = false - let retryTimer: ReturnType | undefined - // Phase 1: Load cached balances immediately (< 1ms from SQLite) async function loadCached() { - if (watchOnly || cancelled) return - try { - const cached = await rpcRequest('getCachedBalances', undefined, 3000) - if (!cancelled && cached && cached.length > 0) { - const map = new Map() - for (const b of cached) map.set(b.chainId, b) - setBalances(map) - console.log(`[Dashboard] Cache hit: ${cached.length} chains, $${cached.reduce((s, b) => s + (b.balanceUsd || 0), 0).toFixed(2)}`) - // Dismiss splash immediately with cached data - if (!initialLoaded) { - setInitialLoaded(true) - onLoaded?.() + if (watchOnly) { + // Watch-only still auto-fetches from cache + try { + const result = await rpcRequest('getWatchOnlyBalances', undefined, 5000) + if (!cancelled && result && result.length > 0) { + const map = new Map() + for (const b of result) map.set(b.chainId, b) + setBalances(map) } + } catch { /* watch-only cache unavailable */ } + if (!cancelled) { + setInitialLoaded(true) + onLoaded?.() } - } catch { /* cache unavailable, will wait for live data */ } - } + return + } - // Phase 2: Fetch live data (background refresh or primary if no cache) - async function fetchLive(attempt = 1) { - setLoadingBalances(true) - let hasTokenData = false try { - const result = watchOnly - ? await rpcRequest('getWatchOnlyBalances', undefined, 5000).then(r => r || []) - : await rpcRequest('getBalances', undefined, 120000) - if (!cancelled && result) { - const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) - const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) - hasTokenData = tokenTotal > 0 || balTotal > 0 || result.length > 0 - console.log(`[Dashboard] Live: ${result.length} chains, ${tokenTotal} tokens, $${balTotal.toFixed(2)} (attempt=${attempt})`) + const cached = await rpcRequest<{ balances: ChainBalance[]; updatedAt: number } | null>('getCachedBalances', undefined, 3000) + if (!cancelled && cached && cached.balances.length > 0) { const map = new Map() - for (const b of result) map.set(b.chainId, b) + for (const b of cached.balances) map.set(b.chainId, b) setBalances(map) + setCacheUpdatedAt(cached.updatedAt) + console.log(`[Dashboard] Cache hit: ${cached.balances.length} chains, $${cached.balances.reduce((s, b) => s + (b.balanceUsd || 0), 0).toFixed(2)}, age: ${formatTimeAgo(cached.updatedAt)}`) } - } catch (e: any) { - console.warn(`[Dashboard] ${watchOnly ? 'watchOnly' : 'getBalances'} failed (attempt=${attempt}):`, e.message) - } + } catch { /* cache unavailable */ } + if (!cancelled) { - setLoadingBalances(false) - if (!initialLoaded) { - setInitialLoaded(true) - onLoaded?.() - } - // Auto-retry once if first attempt returned no meaningful data - if (!watchOnly && !hasTokenData && attempt < 2 && !cancelled) { - console.log('[Dashboard] No balance data — auto-retrying in 3s') - retryTimer = setTimeout(() => { if (!cancelled) fetchLive(attempt + 1) }, 3000) - } + setInitialLoaded(true) + onLoaded?.() } } - // Execute: cache first, then live - loadCached().then(() => { if (!cancelled) fetchLive() }) + loadCached() + return () => { cancelled = true } + }, [watchOnly]) + + // Manual refresh: fetch live data from Pioneer API + const refreshBalances = useCallback(async () => { + if (loadingBalances || watchOnly) return + setLoadingBalances(true) + setPioneerError(null) + setTokenWarning(false) + + try { + const result = await rpcRequest('getBalances', undefined, 120000) + if (result) { + const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) + const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) + console.log(`[Dashboard] Live: ${result.length} chains, ${tokenTotal} tokens, $${balTotal.toFixed(2)}`) + const map = new Map() + for (const b of result) map.set(b.chainId, b) + setBalances(map) + setCacheUpdatedAt(Date.now()) + setHasEverRefreshed(true) - return () => { cancelled = true; clearTimeout(retryTimer) } - }, [fetchKey, watchOnly]) + // Warn if no token data came back (possible API issue) + if (tokenTotal === 0 && balTotal > 0) { + setTokenWarning(true) + } + } + } catch (e: any) { + console.warn('[Dashboard] getBalances failed:', e.message) + } - const refreshBalances = useCallback(() => { - if (!loadingBalances) setFetchKey((k) => k + 1) - }, [loadingBalances]) + setLoadingBalances(false) + }, [loadingBalances, watchOnly]) const totalUsd = useMemo(() => Array.from(balances.values()).reduce((sum, b) => sum + (b.balanceUsd || 0), 0), [balances]) @@ -144,6 +185,9 @@ export function Dashboard({ onLoaded, watchOnly }: DashboardProps) { return 0 }), [allChains, balances]) + // Is data stale? (loaded from cache but haven't refreshed yet this session) + const isStale = !hasEverRefreshed && !loadingBalances + if (selectedChain) { const bal = balances.get(selectedChain.id) return setSelectedChain(null)} /> @@ -151,6 +195,8 @@ export function Dashboard({ onLoaded, watchOnly }: DashboardProps) { return ( + + {/* Watch-only banner */} {watchOnly && ( )} + {/* Pioneer connection error banner */} + {pioneerError && ( + + + + + + + + + + {t("pioneerOfflineTitle", { defaultValue: "Balance server offline" })} + + + + {t("pioneerOfflineDesc", { defaultValue: "Unable to connect to {{url}}. Balances may be unavailable.", url: pioneerError.url })} + + + {onOpenSettings && ( + { + setPioneerError(null) + onOpenSettings() + }} + > + {t("changeServer", { defaultValue: "Change Server" })} + + )} + window.open("https://support.keepkey.com", "_blank")} + > + {t("getSupport", { defaultValue: "Get Support" })} + + { + setPioneerError(null) + refreshBalances() + }} + > + {t("retry", { defaultValue: "Retry" })} + + + + + )} + + {/* Token warning banner — shown when refresh succeeded but no tokens returned */} + {tokenWarning && !pioneerError && ( + + + + + + + + + {t("tokenWarningTitle")} + + + + {t("tokenWarningDesc")} + + + )} + {/* Portfolio Chart — or Welcome placeholder for empty wallets */} {hasAnyBalance ? ( )} - {/* Section Header + Chain Grid */} - - - {t("supportedChains")} - - - {loadingBalances && hasAnyBalance && } - {t("networksCount", { count: allChains.length })} - + - - - - - - - + + {loadingBalances ? ( + + ) : ( + + + + + )} + {loadingBalances + ? t("refreshing") + : cacheUpdatedAt + ? <> + { + const age = Date.now() - cacheUpdatedAt + if (age < 3_600_000) return "#4ADE80" + if (age < 86_400_000) return "#FBBF24" + return "#F87171" + })()}> + {formatTimeAgo(cacheUpdatedAt)} + + {" · "}{t("refreshBalances")} + + : t("refreshPrompt", { defaultValue: "Press to update balances" })} + + + + )} {sortedChains.map((chain) => { @@ -355,11 +539,11 @@ export function Dashboard({ onLoaded, watchOnly }: DashboardProps) { {bal ? ( - + {formatBalance(bal.balance)} {chain.symbol} {usdNum > 0 && ( - + )} {tokenCount > 0 && ( diff --git a/projects/keepkey-vault/src/mainview/components/SendForm.tsx b/projects/keepkey-vault/src/mainview/components/SendForm.tsx index 0b52425..ed5aa34 100644 --- a/projects/keepkey-vault/src/mainview/components/SendForm.tsx +++ b/projects/keepkey-vault/src/mainview/components/SendForm.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, Fragment } from "react" import { useTranslation } from "react-i18next" import { Box, Flex, Text, VStack, Button, Input } from "@chakra-ui/react" import { rpcRequest } from "../lib/rpc" -import { formatBalance } from "../lib/formatting" +import { formatBalance, formatUsd } from "../lib/formatting" import { getAsset } from "../../shared/assetLookup" import { QrScannerOverlay } from "./QrScannerOverlay" import type { ChainDef } from "../../shared/chains" @@ -42,6 +42,8 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve const { t } = useTranslation("send") const [recipient, setRecipient] = useState("") const [amount, setAmount] = useState("") + const [usdAmount, setUsdAmount] = useState("") + const [inputMode, setInputMode] = useState<'crypto' | 'usd'>('crypto') const [memo, setMemo] = useState("") const [isMax, setIsMax] = useState(false) const [feeLevel, setFeeLevel] = useState(5) // 1=slow, 5=avg, 10=fast @@ -67,6 +69,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve setError(null) setRecipient("") setAmount("") + setUsdAmount("") setMemo("") setIsMax(false) }, [tokenCaip]) @@ -81,6 +84,61 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve const balanceNum = parseFloat(displayBalance) const exceedsBalance = !isMax && !isNaN(amountNum) && amountNum > 0 && balanceNum > 0 && amountNum > balanceNum + // Derive per-unit USD price from available balance data + const pricePerUnit = useMemo(() => { + if (isTokenSend && token?.priceUsd) return token.priceUsd + if (!isTokenSend && balance?.balanceUsd && balance.balance) { + const bal = parseFloat(balance.balance) + if (bal > 0) return balance.balanceUsd / bal + } + return 0 + }, [isTokenSend, token?.priceUsd, balance?.balanceUsd, balance?.balance]) + + const hasPrice = pricePerUnit > 0 + + // Bidirectional conversion: crypto → USD + const handleCryptoChange = useCallback((v: string) => { + setIsMax(false) + setAmount(v) + if (hasPrice && v) { + const n = parseFloat(v) + if (!isNaN(n)) setUsdAmount((n * pricePerUnit).toFixed(2)) + else setUsdAmount("") + } else { + setUsdAmount("") + } + }, [hasPrice, pricePerUnit]) + + // Bidirectional conversion: USD → crypto + const handleUsdChange = useCallback((v: string) => { + setIsMax(false) + setUsdAmount(v) + if (hasPrice && v) { + const n = parseFloat(v) + if (!isNaN(n)) { + const crypto = n / pricePerUnit + setAmount(crypto < 1 ? crypto.toPrecision(8) : crypto.toFixed(8).replace(/\.?0+$/, '')) + } else { + setAmount("") + } + } else { + setAmount("") + } + }, [hasPrice, pricePerUnit]) + + // Swap input mode + const toggleInputMode = useCallback(() => { + setInputMode(prev => prev === 'crypto' ? 'usd' : 'crypto') + }, []) + + // USD equivalent of current amount for display + const amountUsdPreview = useMemo(() => { + if (!hasPrice || isMax) return null + const n = parseFloat(amount) + if (isNaN(n) || n <= 0) return null + return n * pricePerUnit + }, [amount, hasPrice, pricePerUnit, isMax]) + const recipientTooShort = useMemo(() => { if (!recipient) return false // Most addresses are 25+ chars; catch obvious typos @@ -159,6 +217,7 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve setError(null) setRecipient("") setAmount("") + setUsdAmount("") setMemo("") setIsMax(false) }, []) @@ -236,9 +295,16 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve {/* Balance display */} {t("available")} - - {formatBalance(displayBalance)} {displaySymbol} - + + + {formatBalance(displayBalance)} {displaySymbol} + + {hasPrice && ( + + ${formatUsd(parseFloat(displayBalance) * pricePerUnit)} + + )} + {/* Gas balance hint for token sends */} {isTokenSend && balance && ( @@ -285,29 +351,70 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve - - - { setIsMax(false); setAmount(v) }} - placeholder={t("amountPlaceholder")} - disabled={isMax} - /> - - - + + {/* Amount input with USD conversion */} + + + + {inputMode === 'crypto' ? `${t("amount")} (${displaySymbol})` : `${t("amount")} (USD)`} + + {hasPrice && ( + + )} + + + + inputMode === 'crypto' ? handleCryptoChange(e.target.value) : handleUsdChange(e.target.value)} + placeholder={inputMode === 'usd' ? '0.00' : t("amountPlaceholder")} + bg="kk.bg" + border="1px solid" + borderColor="kk.border" + color="kk.textPrimary" + size="sm" + fontFamily="mono" + disabled={isMax} + px="3" + /> + + + + + {/* Secondary display: shows the converted value */} + {!isMax && hasPrice && ( + + {inputMode === 'crypto' && amountUsdPreview !== null ? ( + ${formatUsd(amountUsdPreview)} + ) : inputMode === 'usd' && amount ? ( + {formatBalance(amount)} {displaySymbol} + ) : ( + + )} + {pricePerUnit > 0 && ( + 1 {displaySymbol} = ${formatUsd(pricePerUnit)} + )} + + )} + {needsMemo && ( {t("amount")} - {isMax ? 'MAX' : amount} {displaySymbol} + + {isMax ? 'MAX' : amount} {displaySymbol} + {!isMax && amountUsdPreview !== null && ( + ${formatUsd(amountUsdPreview)} + )} + {t("fee")} - {formatBalance(buildResult.fee)} {chain.symbol} + + {formatBalance(buildResult.fee)} {chain.symbol} + {buildResult.feeUsd != null && buildResult.feeUsd > 0 && ( + ${formatUsd(buildResult.feeUsd)} + )} + @@ -525,6 +642,14 @@ export function SendForm({ chain, address, balance, token, onClearToken, xpubOve ) } +function SwapIcon() { + return ( + + + + ) +} + function QrIcon() { return ( diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index 8ceee2a..2b4b58f 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -1,5 +1,7 @@ +import { useState } from "react" import { Flex, Text, Box, Image, IconButton, HStack } from "@chakra-ui/react" import { useTranslation } from "react-i18next" +import { rpcRequest } from "../lib/rpc" import { Z } from "../lib/z-index" import kkIcon from "../assets/icon.png" @@ -27,6 +29,51 @@ const GridIcon = () => ( ) +/** Window control buttons — order: maximize (green), minimize (yellow), close (red) so X is rightmost */ +export function TrafficLights() { + const [hover, setHover] = useState(false) + const dots: { color: string; action: string; icon: JSX.Element | null }[] = [ + { color: "#28C840", action: "windowMaximize", icon: hover ? ( + + ) : null }, + { color: "#FEBC2E", action: "windowMinimize", icon: hover ? ( + + ) : null }, + { color: "#FF5F57", action: "windowClose", icon: hover ? ( + + ) : null }, + ] + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {dots.map((d) => ( + rpcRequest(d.action as any)} + > + {d.icon} + + ))} + + ) +} + export function TopNav({ label, connected, firmwareVersion, firmwareVerified, onSettingsToggle, settingsOpen, activeTab, onTabChange, watchOnly }: TopNavProps) { const { t } = useTranslation("nav") @@ -59,11 +106,12 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, on borderBottom="1px solid" borderColor="kk.border" align="center" - px="4" + pr="4" zIndex={Z.nav} + className="electrobun-webkit-app-region-drag" > {/* Left: device icon + label */} - + - {/* Right: settings gear */} - + {/* Right: settings gear + traffic lights */} + + ) diff --git a/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx b/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx index 80db06b..bcd02f8 100644 --- a/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx +++ b/projects/keepkey-vault/src/mainview/components/UpdateBanner.tsx @@ -1,5 +1,6 @@ import { Box, Flex, Text, Button } from "@chakra-ui/react" import { useTranslation } from "react-i18next" +import { useEffect, useState } from "react" import type { UpdatePhaseUI } from "../hooks/useUpdateState" interface UpdateBannerProps { @@ -14,25 +15,101 @@ interface UpdateBannerProps { export function UpdateBanner({ phase, progress, message, error, onDownload, onApply, onDismiss }: UpdateBannerProps) { const { t } = useTranslation("update") + const [toastVisible, setToastVisible] = useState(false) + + // Auto-dismiss warning/error toasts after 20 seconds + useEffect(() => { + if (phase === "warning" || phase === "error") { + setToastVisible(true) + const timer = setTimeout(() => { + setToastVisible(false) + onDismiss() + }, 20_000) + return () => clearTimeout(timer) + } + setToastVisible(false) + }, [phase, error, message]) + // Hidden for idle and checking phases if (phase === "idle" || phase === "checking") return null + // Warning/error: render as subtle bottom-right toast + if (phase === "warning" || phase === "error") { + if (!toastVisible) return null + + const isError = phase === "error" + const bg = isError ? "rgba(255,23,68,0.12)" : "rgba(251,191,36,0.08)" + const border = isError ? "rgba(255,23,68,0.25)" : "rgba(251,191,36,0.18)" + const accent = isError ? "#FF6B6B" : "#FBBF24" + + return ( + + + + {isError ? ( + <> + + + + ) : ( + <> + + + + )} + + + {isError + ? t("errorWithMessage", { error: error || message || "Unknown error" }) + : t("checkFailed", { defaultValue: "Update check failed" })} + + + + + ) + } + + // Actionable phases (available, downloading, ready, applying): full-width top banner const bgColor = - phase === "error" ? "rgba(255,23,68,0.12)" - : phase === "warning" ? "rgba(251,191,36,0.10)" - : phase === "ready" ? "rgba(34,197,94,0.12)" + phase === "ready" ? "rgba(34,197,94,0.12)" : "rgba(192,168,96,0.12)" const borderColor = - phase === "error" ? "rgba(255,23,68,0.3)" - : phase === "warning" ? "rgba(251,191,36,0.25)" - : phase === "ready" ? "rgba(34,197,94,0.3)" + phase === "ready" ? "rgba(34,197,94,0.3)" : "rgba(192,168,96,0.3)" const accentColor = - phase === "error" ? "#FF6B6B" - : phase === "warning" ? "#FBBF24" - : phase === "ready" ? "#22C55E" + phase === "ready" ? "#22C55E" : "kk.gold" return ( @@ -61,17 +138,7 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp > {/* Icon */} - {phase === "error" ? ( - - - - - ) : phase === "warning" ? ( - - - - - ) : phase === "ready" ? ( + {phase === "ready" ? ( @@ -87,7 +154,7 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp {/* Text */} - {phase === "available" && (message || t("newVersionAvailable"))} + {phase === "available" && t("newVersionAvailable")} {phase === "downloading" && ( progress !== undefined ? t("downloadingWithProgress", { progress: Math.round(progress) }) @@ -95,8 +162,6 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp )} {phase === "ready" && t("readyToInstall")} {phase === "applying" && t("applying")} - {phase === "warning" && t("checkFailed", { defaultValue: "Update check failed, will retry" })} - {phase === "error" && t("errorWithMessage", { error: error || message || "Unknown error" })} {/* Progress bar for downloading */} {phase === "downloading" && progress !== undefined && ( @@ -129,16 +194,6 @@ export function UpdateBanner({ phase, progress, message, error, onDownload, onAp )} - {phase === "warning" && ( - - )} - {phase === "error" && ( - - )} diff --git a/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx b/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx index 1b1f4d2..a5d60d1 100644 --- a/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx +++ b/projects/keepkey-vault/src/mainview/components/device/SigningApproval.tsx @@ -1,8 +1,8 @@ -import { useEffect } from "react" +import { useEffect, useState } from "react" import { Box, Text, VStack, Flex, Button } from "@chakra-ui/react" import { useTranslation } from "react-i18next" import { Z } from "../../lib/z-index" -import type { SigningRequestInfo } from "../../../shared/types" +import type { SigningRequestInfo, EIP712DecodedInfo } from "../../../shared/types" interface SigningApprovalProps { request: SigningRequestInfo @@ -22,8 +22,33 @@ const METHOD_LABEL_KEYS: Record = { "/mayachain/sign-amino-transfer": "signing.methodMayaTransfer", "/mayachain/sign-amino-deposit": "signing.methodMayaDeposit", "/osmosis/sign-amino": "signing.methodOsmosisSign", + "/solana/sign-transaction": "signing.methodSolanaSignTx", + "/solana/sign-message": "signing.methodSolanaSignTx", } +const SIGNING_ANIMATIONS = ` + @keyframes signingPulseGlow { + 0%, 100% { box-shadow: 0 0 8px 2px rgba(192,168,96,0.4); } + 50% { box-shadow: 0 0 24px 8px rgba(192,168,96,0.7), 0 0 48px 16px rgba(192,168,96,0.15); } + } + @keyframes signingFlashBorder { + 0%, 100% { border-color: rgba(192,168,96,0.5); } + 50% { border-color: rgba(192,168,96,1); } + } + @keyframes signingBadgePulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.05); } + } + @keyframes signingOverlayIn { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + @keyframes signingCardIn { + 0% { opacity: 0; transform: scale(0.92) translateY(12px); } + 100% { opacity: 1; transform: scale(1) translateY(0); } + } +` + function DetailRow({ label, value }: { label: string; value?: string }) { if (!value) return null return ( @@ -38,8 +63,63 @@ function DetailRow({ label, value }: { label: string; value?: string }) { ) } +function TypedDataDetails({ decoded, t }: { decoded: EIP712DecodedInfo; t: (key: string, fallback?: string) => string }) { + return ( + + {/* Operation badge */} + + + {decoded.operationName} + + + + {/* Domain info */} + + {decoded.domain.name && ( + + )} + {decoded.domain.verifyingContract && ( + + )} + {decoded.domain.chainId !== undefined && ( + + )} + + + {/* Decoded fields */} + + {decoded.fields.map((field, i) => ( + + ))} + + + ) +} + export function SigningApproval({ request, onApprove, onReject }: SigningApprovalProps) { const { t } = useTranslation("device") + const [elapsed, setElapsed] = useState(0) + // Keyboard: Enter=approve, Escape=reject useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -50,8 +130,20 @@ export function SigningApproval({ request, onApprove, onReject }: SigningApprova return () => document.removeEventListener("keydown", handler) }, [onApprove, onReject]) + // Tick elapsed seconds for countdown timer + useEffect(() => { + const iv = setInterval(() => setElapsed((s) => s + 1), 1000) + return () => clearInterval(iv) + }, []) + const labelKey = METHOD_LABEL_KEYS[request.method] const methodLabel = labelKey ? t(labelKey) : request.method + const timeoutSec = 120 + const remaining = Math.max(0, timeoutSec - elapsed) + const minutes = Math.floor(remaining / 60) + const seconds = remaining % 60 + const timeStr = `${minutes}:${seconds.toString().padStart(2, "0")}` + const urgent = remaining <= 30 return ( + + {/* Urgent action badge */} + + + {t("signing.actionRequired", "Action Required")} + + + {/* Header */} @@ -99,23 +213,32 @@ export function SigningApproval({ request, onApprove, onReject }: SigningApprova {methodLabel} - {/* Details */} - - - - - - {request.chainId !== undefined && ( - - )} - - + {/* Details — typed data vs standard transaction */} + {request.typedDataDecoded ? ( + + ) : ( + + + + + + {request.chainId !== undefined && ( + + )} + + + )} + + {/* Countdown timer */} + + {t("signing.expiresIn", "Expires in {{time}}", { time: timeStr })} + {/* Action buttons */} @@ -124,6 +247,7 @@ export function SigningApproval({ request, onApprove, onReject }: SigningApprova bg="kk.gold" color="black" fontWeight="600" + size="lg" _hover={{ bg: "kk.goldHover" }} onClick={onApprove} > @@ -135,6 +259,7 @@ export function SigningApproval({ request, onApprove, onReject }: SigningApprova color="kk.textSecondary" border="1px solid" borderColor="kk.border" + size="lg" _hover={{ color: "white", borderColor: "kk.textSecondary" }} onClick={onReject} > diff --git a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts index f108dcc..3c444fe 100644 --- a/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts +++ b/projects/keepkey-vault/src/mainview/hooks/useUpdateState.ts @@ -32,8 +32,12 @@ function statusToPhase(status: string): UpdatePhaseUI { case 'delta-applied': return 'ready' case 'applying-update': + case 'applying': + case 'extracting': case 'replacing-app': + case 'launching-new-version': case 'relaunching': + case 'complete': return 'applying' case 'no-update-available': case 'up-to-date': @@ -117,9 +121,13 @@ export function useUpdateState() { const applyUpdate = useCallback(async () => { setState(prev => ({ ...prev, phase: 'applying', error: undefined })) try { - await rpcRequest('applyUpdate', undefined, 60000) - } catch (e: any) { - setState(prev => ({ ...prev, phase: 'error', error: e.message })) + // Bun side fires-and-forgets Updater.applyUpdate() then returns immediately. + // The actual update progress comes via 'update-status' messages. + // The app will quit+relaunch during this process, so connection loss is expected. + await rpcRequest('applyUpdate', undefined, 10000) + } catch { + // Ignore errors — the app closing during update causes RPC disconnect, + // which is expected behavior, not a failure. } }, []) diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json index a196dca..edb5685 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/dashboard.json @@ -1,6 +1,6 @@ { "supportedChains": "Supported Chains", - "refreshBalances": "Refresh balances", + "refreshBalances": "Refresh Balances", "connectDeviceToRefresh": "Connect device to refresh", "networksCount": "{{count}} networks", "addChain": "Add Chain", @@ -12,5 +12,12 @@ "welcomeSubtitle": "Your wallet is ready. Here's how to get started:", "welcomeTip1": "Tap any chain below, then hit Receive to get your deposit address", "welcomeTip2": "Send crypto to your address — your balance will appear here automatically", - "welcomeTip3": "Add custom EVM chains with the + card to track any network" + "welcomeTip3": "Add custom EVM chains with the + card to track any network", + "balancesStale": "Balances may be outdated", + "lastUpdated": "Last updated {{time}}", + "lastUpdatedNever": "Never loaded", + "refreshPrompt": "Press Refresh to load latest balances", + "tokenWarningTitle": "Token balances unavailable", + "tokenWarningDesc": "No token data was returned. Your native balances are shown but ERC-20 / token balances may be missing.", + "refreshing": "Refreshing..." } 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 3bc3ade..57c569a 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/device.json @@ -43,7 +43,11 @@ "methodThorchainDeposit": "THORChain Deposit", "methodMayaTransfer": "Maya Transfer", "methodMayaDeposit": "Maya Deposit", - "methodOsmosisSign": "Osmosis Sign" + "methodOsmosisSign": "Osmosis Sign", + "methodSolanaSignTx": "Solana Sign Transaction", + "domainName": "Domain", + "contract": "Contract", + "operationType": "Operation" }, "recovery": { "title": "Recover Your Wallet", diff --git a/projects/keepkey-vault/src/mainview/i18n/locales/en/send.json b/projects/keepkey-vault/src/mainview/i18n/locales/en/send.json index 6a74cfb..6fb0725 100644 --- a/projects/keepkey-vault/src/mainview/i18n/locales/en/send.json +++ b/projects/keepkey-vault/src/mainview/i18n/locales/en/send.json @@ -36,5 +36,6 @@ "sendAnother": "Send Another", "failedToBuild": "Failed to build transaction", "signingFailed": "Signing failed", - "broadcastFailed": "Broadcast failed" + "broadcastFailed": "Broadcast failed", + "switchInput": "Switch between crypto and USD input" } diff --git a/projects/keepkey-vault/src/mainview/lib/rpc.ts b/projects/keepkey-vault/src/mainview/lib/rpc.ts index 71cd3b9..0825130 100644 --- a/projects/keepkey-vault/src/mainview/lib/rpc.ts +++ b/projects/keepkey-vault/src/mainview/lib/rpc.ts @@ -24,6 +24,7 @@ const messageListeners = new Map>() let sendPacket: ((packet: RPCPacket) => void) | null = null let reconnectAttempts = 0 const MAX_RECONNECT_DELAY = 10000 +const MAX_RECONNECT_ATTEMPTS = 50 // Handle incoming packets from the Bun process function handlePacket(packet: RPCPacket) { @@ -150,8 +151,12 @@ function connectWebSocket(w: any) { function scheduleReconnect(w: any) { reconnectAttempts++ + if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { + console.error(`[rpc] Giving up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`) + return + } const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts - 1), MAX_RECONNECT_DELAY) - console.log(`[rpc] Reconnecting in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`) + console.log(`[rpc] Reconnecting in ${Math.round(delay)}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`) setTimeout(() => connectWebSocket(w), delay) } diff --git a/projects/keepkey-vault/src/mainview/main.tsx b/projects/keepkey-vault/src/mainview/main.tsx index 71e2b64..23257e3 100644 --- a/projects/keepkey-vault/src/mainview/main.tsx +++ b/projects/keepkey-vault/src/mainview/main.tsx @@ -7,6 +7,12 @@ import "./i18n" import splashBg from "./assets/splash-bg.png" import App from "./App" +// Global error handler — prevent stray promise rejections from crashing the WebView +window.addEventListener('unhandledrejection', (e) => { + console.error('[WebView] Unhandled rejection:', e.reason) + e.preventDefault() +}) + // Set background on so it's visible behind every overlay and phase document.body.style.background = `#000000 url(${splashBg}) center / cover no-repeat fixed` diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index 1f84489..3ade79a 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -120,7 +120,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { setPioneerApiBase: { params: { url: string }; response: AppSettings } // ── Balance cache (instant portfolio) ───────────────────────────── - getCachedBalances: { params: void; response: ChainBalance[] | null } + getCachedBalances: { params: void; response: { balances: ChainBalance[]; updatedAt: number } | null } // ── Watch-only mode ────────────────────────────────────────────── checkWatchOnlyCache: { params: void; response: { available: boolean; deviceLabel?: string; lastSynced?: number } } @@ -136,6 +136,10 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { applyUpdate: { params: void; response: void } getUpdateInfo: { params: void; response: UpdateInfo | null } getAppVersion: { params: void; response: { version: string; channel: string } } + // ── Window controls (custom titlebar) ────────────────────── + windowClose: { params: void; response: void } + windowMinimize: { params: void; response: void } + windowMaximize: { params: void; response: void } } messages: { 'device-state': DeviceStateInfo @@ -149,7 +153,9 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { 'camera-frame': string 'camera-error': string 'update-status': UpdateStatus + 'pioneer-error': { message: string; url: string } 'pair-request': PairingRequestInfo + 'pair-dismissed': Record 'signing-request': SigningRequestInfo 'signing-dismissed': { id: string } 'api-log': ApiLogEntry diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 17bcca9..b088bd6 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -224,6 +224,21 @@ export interface PairedAppInfo { addedOn: number } +export interface EIP712DecodedField { + label: string + value: string + format: 'address' | 'amount' | 'datetime' | 'raw' | 'hex' + raw?: string +} + +export interface EIP712DecodedInfo { + operationName: string + domain: { name?: string; version?: string; chainId?: number; verifyingContract?: string } + primaryType: string + fields: EIP712DecodedField[] + isKnownType: boolean +} + export interface SigningRequestInfo { id: string method: string @@ -234,6 +249,7 @@ export interface SigningRequestInfo { value?: string data?: string chainId?: number + typedDataDecoded?: EIP712DecodedInfo } export interface ApiLogEntry { diff --git a/projects/keepkey-vault/vite.config.ts b/projects/keepkey-vault/vite.config.ts index d702ead..0e75829 100644 --- a/projects/keepkey-vault/vite.config.ts +++ b/projects/keepkey-vault/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ }, }, server: { - port: 5173, + port: 5177, strictPort: true, }, }); diff --git a/scripts/build-windows-production.ps1 b/scripts/build-windows-production.ps1 new file mode 100644 index 0000000..a07341d --- /dev/null +++ b/scripts/build-windows-production.ps1 @@ -0,0 +1,582 @@ +<# +.SYNOPSIS + KeepKey Vault - Windows Production Build & Signing Script + +.DESCRIPTION + This script builds the KeepKey Vault Windows application, signs all executables + and DLLs with the Sectigo EV code signing certificate, and creates a signed + installer EXE using Inno Setup. + +.PARAMETER SkipBuild + Skip the build step (use existing build artifacts) + +.PARAMETER SkipSign + Skip code signing (for testing build process) + +.PARAMETER Thumbprint + Certificate thumbprint for code signing + +.PARAMETER OutputDir + Directory for final release artifacts + +.EXAMPLE + .\scripts\build-windows-production.ps1 + +.EXAMPLE + .\scripts\build-windows-production.ps1 -SkipBuild +#> + +param( + [switch]$SkipBuild = $false, + [switch]$SkipSign = $false, + [string]$Thumbprint = "986AEBA61CF6616393E74D8CBD3A09E836213BAA", + [string]$TimestampUrl = "http://timestamp.digicert.com", + [string]$OutputDir = "release-windows" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ============================================================================ +# Configuration +# ============================================================================ + +# Auto-detect Windows SDK version (find newest installed) +$SDK_BASE = "C:\Program Files (x86)\Windows Kits\10\bin" +$SIGNTOOL = $null +if (Test-Path $SDK_BASE) { + $sdkVersions = Get-ChildItem -Path $SDK_BASE -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object { [Version]$_.Name } -Descending + foreach ($sdk in $sdkVersions) { + $candidate = Join-Path $sdk.FullName "x64\signtool.exe" + if (Test-Path $candidate) { + $SIGNTOOL = $candidate + break + } + } +} +if (-not $SIGNTOOL) { + # Fallback: check PATH + $SIGNTOOL = (Get-Command "signtool.exe" -ErrorAction SilentlyContinue).Source +} + +# Find Inno Setup compiler +$ISCC = $null +$isccPaths = @( + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "C:\Program Files\Inno Setup 6\ISCC.exe", + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +) +foreach ($p in $isccPaths) { + if (Test-Path $p) { $ISCC = $p; break } +} + +# Resolve paths +if ($PSCommandPath) { + $ScriptDir = Split-Path -Path $PSCommandPath -Parent +} else { + $ScriptDir = Split-Path -Path $MyInvocation.MyCommand.Path -Parent +} +$RepoRoot = Split-Path -Path $ScriptDir -Parent +$ProjectDir = Join-Path $RepoRoot "projects\keepkey-vault" +$BuildDir = Join-Path $ProjectDir "build\dev-win-x64\keepkey-vault-dev" +$ArtifactsDir = Join-Path $RepoRoot $OutputDir + +# Read version from package.json +$PackageJson = Get-Content (Join-Path $ProjectDir "package.json") | ConvertFrom-Json +$Version = $PackageJson.version +$AppName = "KeepKey Vault" + +# ============================================================================ +# Helper Functions +# ============================================================================ + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host " [OK] $Message" -ForegroundColor Green +} + +function Write-Warning { + param([string]$Message) + Write-Host " [WARN] $Message" -ForegroundColor Yellow +} + +function Write-Error { + param([string]$Message) + Write-Host " [ERROR] $Message" -ForegroundColor Red +} + +function Assert-Tool { + param([string]$Path, [string]$Name) + if (-not (Test-Path $Path)) { + throw "$Name not found at: $Path" + } +} + +function Assert-Command { + param([string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' not found in PATH." + } +} + +function Sign-File { + param( + [string]$FilePath, + [string]$Description = "" + ) + + if ($SkipSign) { + Write-Warning "Skipping sign: $(Split-Path $FilePath -Leaf)" + return $true + } + + $fileName = Split-Path $FilePath -Leaf + $extension = [System.IO.Path]::GetExtension($FilePath).ToLower() + + # Skip .node files - they are native Node modules that signtool doesn't support + if ($extension -eq ".node") { + Write-Host " [SKIP] Native module (not signable): $fileName" -ForegroundColor Gray + return $true + } + + # Check if already signed + try { + $sig = Get-AuthenticodeSignature $FilePath + if ($sig.Status -eq 'Valid') { + Write-Success "Already signed: $fileName" + return $true + } + } catch {} + + $signArgs = @( + "sign", + "/sha1", $Thumbprint, + "/fd", "sha256", + "/tr", $TimestampUrl, + "/td", "sha256" + ) + + if ($Description) { + $signArgs += "/d" + $signArgs += $Description + } + + $signArgs += $FilePath + + $result = & $SIGNTOOL @signArgs 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Success "Signed: $fileName" + return $true + } else { + if ($result -match "not recognized") { + Write-Host " [SKIP] Not signable format: $fileName" -ForegroundColor Gray + return $true + } + Write-Error "Failed to sign: $fileName" + Write-Host " $result" -ForegroundColor Gray + return $false + } +} + +# ============================================================================ +# Pre-flight Checks +# ============================================================================ + +Write-Host "" +Write-Host "============================================" -ForegroundColor Magenta +Write-Host " KeepKey Vault v$Version - Windows Build " -ForegroundColor Magenta +Write-Host "============================================" -ForegroundColor Magenta +Write-Host "" + +Write-Step "Pre-flight checks" + +# Check tools +if (-not $SkipSign) { + if (-not $SIGNTOOL) { + throw "SignTool not found. Install the Windows SDK: https://developer.microsoft.com/windows/downloads/windows-sdk/" + } + Assert-Tool $SIGNTOOL "SignTool" + Write-Success "SignTool found: $SIGNTOOL" +} + +if (-not $ISCC) { + throw "Inno Setup not found. Install from https://jrsoftware.org/isdl.php or: winget install JRSoftware.InnoSetup" +} +Write-Success "Inno Setup found: $ISCC" + +Assert-Command "git" +Assert-Command "bun" +Assert-Command "yarn" +Write-Success "Build tools available (git, bun, yarn)" + +# Check certificate (if signing) +if (-not $SkipSign) { + $cert = Get-ChildItem -Path "Cert:\CurrentUser\My" -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $Thumbprint } + if (-not $cert) { + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My" -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $Thumbprint } + } + + if ($cert) { + Write-Success "Certificate found: $($cert.Subject)" + Write-Host " Valid until: $($cert.NotAfter)" -ForegroundColor Gray + + if ($cert.NotAfter -lt (Get-Date).AddDays(30)) { + Write-Warning "Certificate expires in less than 30 days!" + } + } else { + throw "Certificate not found with thumbprint: $Thumbprint`nMake sure your USB signing token is connected." + } +} + +# ============================================================================ +# Build Application +# ============================================================================ + +if (-not $SkipBuild) { + Write-Step "Updating git submodules (selective)" + Push-Location $RepoRoot + # Only init the submodules we actually need — recursive init pulls deeply + # nested firmware deps whose paths exceed Windows MAX_PATH (260 chars) + git submodule update --init modules/hdwallet + git submodule update --init modules/proto-tx-builder-vendored + git submodule update --init modules/keepkey-firmware + Pop-Location + + Write-Step "Building proto-tx-builder" + Push-Location (Join-Path $RepoRoot "modules\proto-tx-builder") + bun install + Pop-Location + + Write-Step "Building hdwallet" + Push-Location (Join-Path $RepoRoot "modules\hdwallet") + yarn install + yarn build + Pop-Location + + Write-Step "Installing keepkey-vault dependencies" + Push-Location $ProjectDir + # bun install may exit non-zero due to ENOENT errors on deeply nested + # transitive deps inside file-linked workspace packages. These are not + # needed at build time (collect-externals resolves them). Tolerate this. + $ErrorActionPreference = 'Continue' + bun install + $ErrorActionPreference = 'Stop' + Pop-Location + + Write-Step "Building Electrobun Windows app" + Push-Location $ProjectDir + bun run build + Pop-Location + + Write-Success "Build completed" +} else { + Write-Step "Skipping build (using existing artifacts)" +} + +# Verify build exists +if (-not (Test-Path $BuildDir)) { + throw "Build directory not found: $BuildDir`nRun without -SkipBuild flag." +} + +# ============================================================================ +# Sign Executables and DLLs +# ============================================================================ + +Write-Step "Signing executables and DLLs" + +$binDir = Join-Path $BuildDir "bin" +$filesToSign = @() + +# Find all .exe and .dll files in bin/ +$filesToSign += Get-ChildItem -Path $binDir -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue +$filesToSign += Get-ChildItem -Path $binDir -Filter "*.dll" -Recurse -ErrorAction SilentlyContinue + +# Also sign any .node and .dll files in Resources/ +$resourcesDir = Join-Path $BuildDir "Resources" +$filesToSign += Get-ChildItem -Path $resourcesDir -Filter "*.node" -Recurse -ErrorAction SilentlyContinue +$filesToSign += Get-ChildItem -Path $resourcesDir -Filter "*.dll" -Recurse -ErrorAction SilentlyContinue + +# Also sign the wrapper exe +$wrapperFile = Join-Path $BuildDir "KeepKeyVault.exe" +if (Test-Path $wrapperFile) { + $filesToSign += Get-Item $wrapperFile +} + +Write-Host " Found $($filesToSign.Count) files to sign" -ForegroundColor Gray + +$signedCount = 0 +$failedCount = 0 + +foreach ($file in $filesToSign) { + if (Sign-File -FilePath $file.FullName -Description $AppName) { + $signedCount++ + } else { + $failedCount++ + } +} + +Write-Host "" +Write-Host " Signed: $signedCount, Failed: $failedCount" -ForegroundColor $(if ($failedCount -eq 0) { "Green" } else { "Yellow" }) + +# ============================================================================ +# Prepare App Icon (convert PNG to ICO if needed) +# ============================================================================ + +Write-Step "Preparing app icon" + +$IconPng = Join-Path $BuildDir "Resources\app.ico" # Actually a PNG despite extension +$IconIco = Join-Path $BuildDir "Resources\app-real.ico" + +if (-not (Test-Path $IconIco)) { + Add-Type -AssemblyName System.Drawing + $png = [System.Drawing.Image]::FromFile($IconPng) + + $sizes = @(16, 32, 48, 256) + $imageData = @() + + foreach ($size in $sizes) { + $bmp = New-Object System.Drawing.Bitmap($size, $size) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $g.DrawImage($png, 0, 0, $size, $size) + $g.Dispose() + + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $imageData += ,$ms.ToArray() + $ms.Dispose() + $bmp.Dispose() + } + $png.Dispose() + + $fs = [System.IO.File]::Create($IconIco) + $bw = New-Object System.IO.BinaryWriter($fs) + $bw.Write([UInt16]0) + $bw.Write([UInt16]1) + $bw.Write([UInt16]$sizes.Count) + + $dataOffset = 6 + (16 * $sizes.Count) + for ($i = 0; $i -lt $sizes.Count; $i++) { + $bw.Write([byte]($sizes[$i] -band 0xFF)) + $bw.Write([byte]($sizes[$i] -band 0xFF)) + $bw.Write([byte]0) + $bw.Write([byte]0) + $bw.Write([UInt16]1) + $bw.Write([UInt16]32) + $bw.Write([UInt32]$imageData[$i].Length) + $bw.Write([UInt32]$dataOffset) + $dataOffset += $imageData[$i].Length + } + for ($i = 0; $i -lt $sizes.Count; $i++) { + $bw.Write($imageData[$i]) + } + $bw.Close() + $fs.Close() + + Write-Success "Converted PNG to ICO: app-real.ico" +} else { + Write-Success "Icon already exists: app-real.ico" +} + +# Replace the renamed PNG with the real ICO so LoadImageW works at runtime +Copy-Item $IconIco $IconPng -Force +Write-Success "Replaced app.ico with real ICO format" + +# ============================================================================ +# Build Wrapper EXE (KeepKeyVault.exe) +# NOTE: No spaces in filename - Bun Workers silently fail with spaces in paths +# ============================================================================ + +Write-Step "Building wrapper EXE" + +$WrapperExe = Join-Path $BuildDir "KeepKeyVault.exe" +$WrapperSrc = Join-Path $ScriptDir "wrapper-launcher.zig" + +if (-not (Test-Path $WrapperExe)) { + # Find Zig compiler + $ZigExe = $null + $zigPaths = @( + (Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages\zig*" -Recurse -Filter "zig.exe" -ErrorAction SilentlyContinue | Select-Object -First 1) + ) + foreach ($z in $zigPaths) { + if ($z) { $ZigExe = $z.FullName; break } + } + if (-not $ZigExe) { + $ZigExe = Get-Command "zig" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + } + + if ($ZigExe) { + Write-Host " Using Zig: $ZigExe" -ForegroundColor Gray + Push-Location (Split-Path $WrapperSrc -Parent) + & $ZigExe build-exe $WrapperSrc -O ReleaseSmall --subsystem windows "-femit-bin=$WrapperExe" + Pop-Location + + if ($LASTEXITCODE -eq 0) { + Write-Success "Built: KeepKeyVault.exe" + } else { + throw "Failed to compile wrapper EXE with Zig" + } + } else { + throw "Zig compiler not found. Install via: winget install zig.zig" + } +} else { + Write-Success "Wrapper EXE already exists" +} + +# Embed KeepKey icon into all EXEs +# Electrobun's rcedit call fails (ENOENT — hardcoded CI path), so we do it ourselves. +$RceditExe = Join-Path $ProjectDir "node_modules\rcedit\bin\rcedit-x64.exe" +if ((Test-Path $IconIco) -and (Test-Path $RceditExe)) { + # Skip bun.exe — rcedit on 113MB binary can corrupt it; bun runs headless anyway + $exesToIcon = @($WrapperExe, (Join-Path $BuildDir "bin\launcher.exe")) + foreach ($exePath in $exesToIcon) { + if (Test-Path $exePath) { + $exeName = Split-Path $exePath -Leaf + Write-Host " Embedding icon into $exeName..." -ForegroundColor Gray + & $RceditExe $exePath --set-icon $IconIco + if ($LASTEXITCODE -eq 0) { + Write-Success "Icon embedded into $exeName" + } else { + Write-Warning "Failed to embed icon into $exeName" + } + } + } +} elseif (-not (Test-Path $RceditExe)) { + Write-Warning "rcedit not found - EXEs will use default icon" +} + +# ============================================================================ +# Create Output Directory +# ============================================================================ + +Write-Step "Preparing release artifacts" + +if (Test-Path $ArtifactsDir) { + Remove-Item $ArtifactsDir -Recurse -Force +} +New-Item -ItemType Directory -Path $ArtifactsDir | Out-Null + +# ============================================================================ +# Build Installer EXE with Inno Setup +# ============================================================================ + +Write-Step "Downloading WebView2 bootstrapper (for Windows 10 support)" + +$WebView2Bootstrapper = Join-Path $BuildDir "MicrosoftEdgeWebview2Setup.exe" +if (-not (Test-Path $WebView2Bootstrapper)) { + $webview2Url = "https://go.microsoft.com/fwlink/p/?LinkId=2124703" + Write-Host " Downloading from Microsoft..." -ForegroundColor Gray + try { + Invoke-WebRequest -Uri $webview2Url -OutFile $WebView2Bootstrapper -UseBasicParsing + $sizeKB = [math]::Round((Get-Item $WebView2Bootstrapper).Length / 1024) + Write-Success "Downloaded WebView2 bootstrapper: ${sizeKB} KB" + } catch { + $errMsg = $_.Exception.Message + Write-Warning "Failed to download WebView2 bootstrapper: $errMsg" + Write-Warning "Windows 10 users may need to install WebView2 manually" + } +} else { + Write-Success "WebView2 bootstrapper already exists" +} + +# ============================================================================ +# Build Installer EXE with Inno Setup +# ============================================================================ + +Write-Step "Building installer EXE with Inno Setup" + +$IssFile = Join-Path $ScriptDir "installer.iss" +if (-not (Test-Path $IssFile)) { + throw "Inno Setup script not found: $IssFile" +} + +$isccArgs = @( + "/DMyAppVersion=$Version", + "/DMySourceDir=$BuildDir", + "/DMyOutputDir=$ArtifactsDir", + $IssFile +) + +& $ISCC @isccArgs + +if ($LASTEXITCODE -ne 0) { + throw "Inno Setup compilation failed with exit code $LASTEXITCODE" +} + +$InstallerExe = Join-Path $ArtifactsDir "KeepKey-Vault-$Version-win-x64-setup.exe" +Write-Success "Created installer: $(Split-Path $InstallerExe -Leaf)" + +# Sign the installer EXE itself +if (-not $SkipSign) { + Write-Step "Signing installer EXE" + $signed = Sign-File -FilePath $InstallerExe -Description "$AppName Installer" + if (-not $signed) { + Write-Error "Failed to sign the installer EXE!" + } +} + +# ============================================================================ +# Generate Checksums +# ============================================================================ + +Write-Step "Generating checksums" + +$checksumFile = Join-Path $ArtifactsDir "SHA256SUMS.txt" +$artifacts = Get-ChildItem -Path $ArtifactsDir -File | Where-Object { $_.Name -notlike "*.txt" } + +$checksums = @() +foreach ($file in $artifacts) { + $hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower() + $checksums += "$hash $($file.Name)" + Write-Host " $($file.Name): $hash" -ForegroundColor Gray +} + +$checksums | Out-File -FilePath $checksumFile -Encoding UTF8 +Write-Success "Created: SHA256SUMS.txt" + +# ============================================================================ +# Summary +# ============================================================================ + +Write-Host "" +Write-Host "============================================" -ForegroundColor Green +Write-Host " Build Complete!" -ForegroundColor Green +Write-Host "============================================" -ForegroundColor Green +Write-Host "" +Write-Host "Version: $Version" -ForegroundColor White +Write-Host "Output: $ArtifactsDir" -ForegroundColor White +Write-Host "" +Write-Host "Artifacts:" -ForegroundColor Cyan + +$finalArtifacts = Get-ChildItem -Path $ArtifactsDir -File +foreach ($file in $finalArtifacts) { + $size = [math]::Round($file.Length / 1MB, 2) + Write-Host " - $($file.Name) ${size} MB" -ForegroundColor White +} + +Write-Host "" + +if (-not $SkipSign) { + Write-Host "All executables have been signed with EV certificate." -ForegroundColor Green + Write-Host "" + Write-Host "Next steps:" -ForegroundColor Yellow + Write-Host " 1. Test the installer: run the setup EXE" -ForegroundColor Gray + Write-Host " 2. Upload EXE to GitHub release" -ForegroundColor Gray + Write-Host " 3. Verify SmartScreen reputation" -ForegroundColor Gray +} else { + Write-Host "WARNING: Artifacts are NOT signed - test build only" -ForegroundColor Yellow +} + +Write-Host "" diff --git a/scripts/installer.iss b/scripts/installer.iss new file mode 100644 index 0000000..73fb855 --- /dev/null +++ b/scripts/installer.iss @@ -0,0 +1,73 @@ +; KeepKey Vault - Inno Setup Installer Script +; This file is generated/maintained alongside build-windows-production.ps1 +; NOTE: Install dir uses "KeepKeyVault" (no space) because Bun Workers +; silently fail when the file path contains spaces. + +#define MyAppName "KeepKey Vault" +#define MyAppDirName "KeepKeyVault" +#define MyAppPublisher "KEY HODLERS LLC" +#define MyAppURL "https://github.com/keepkey/keepkey-vault" +#define MyAppExeName "KeepKeyVault.exe" + +; Version and source dir are passed via /D command line defines +; e.g. ISCC /DMyAppVersion=1.0.0 /DMySourceDir=C:\path\to\build + +[Setup] +AppId={{B8E3F2A1-5C7D-4E9F-A1B2-3C4D5E6F7A8B} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppDirName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputDir={#MyOutputDir} +OutputBaseFilename=KeepKey-Vault-{#MyAppVersion}-win-x64-setup +SetupIconFile={#MySourceDir}\Resources\app-real.ico +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +UninstallDisplayIcon={app}\Resources\app-real.ico +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.17763 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "{#MySourceDir}\KeepKeyVault.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#MySourceDir}\bin\*"; DestDir: "{app}\bin"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#MySourceDir}\Resources\*"; DestDir: "{app}\Resources"; Flags: ignoreversion recursesubdirs createallsubdirs +; WebView2 bootstrapper — extracted to temp, deleted after install +Source: "{#MySourceDir}\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: ignoreversion deleteafterinstall + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\Resources\app-real.ico" +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 +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 new file mode 100644 index 0000000..fe99b66 --- /dev/null +++ b/scripts/wrapper-launcher.zig @@ -0,0 +1,88 @@ +const std = @import("std"); + +const HANDLE = *anyopaque; +const BOOL = i32; +const DWORD = u32; +const WORD = u16; +const LPWSTR = ?[*:0]u16; + +const STARTUPINFOW = extern struct { + cb: DWORD = @sizeOf(STARTUPINFOW), + lpReserved: LPWSTR = null, + lpDesktop: LPWSTR = null, + lpTitle: LPWSTR = null, + dwX: DWORD = 0, + dwY: DWORD = 0, + dwXSize: DWORD = 0, + dwYSize: DWORD = 0, + dwXCountChars: DWORD = 0, + dwYCountChars: DWORD = 0, + dwFillAttribute: DWORD = 0, + dwFlags: DWORD = 0, + wShowWindow: WORD = 0, + cbReserved2: WORD = 0, + lpReserved2: ?*u8 = null, + hStdInput: ?HANDLE = null, + hStdOutput: ?HANDLE = null, + hStdError: ?HANDLE = null, +}; + +const PROCESS_INFORMATION = extern struct { + hProcess: ?HANDLE = null, + hThread: ?HANDLE = null, + dwProcessId: DWORD = 0, + dwThreadId: DWORD = 0, +}; + +extern "kernel32" fn CreateProcessW( + lpApplicationName: ?[*:0]const u16, + lpCommandLine: ?[*:0]u16, + lpProcessAttributes: ?*anyopaque, + lpThreadAttributes: ?*anyopaque, + bInheritHandles: BOOL, + dwCreationFlags: DWORD, + lpEnvironment: ?*anyopaque, + lpCurrentDirectory: ?[*:0]const u16, + lpStartupInfo: *STARTUPINFOW, + lpProcessInformation: *PROCESS_INFORMATION, +) callconv(.winapi) BOOL; + +extern "kernel32" fn CloseHandle(hObject: HANDLE) callconv(.winapi) BOOL; + +pub fn main() !void { + const alloc = std.heap.page_allocator; + + var buf: [1024]u8 = undefined; + const exe_dir = std.fs.selfExeDirPath(&buf) catch return; + + var arena = std.heap.ArenaAllocator.init(alloc); + defer arena.deinit(); + const a = arena.allocator(); + + const launcher_path = try std.fs.path.join(a, &.{ exe_dir, "bin", "launcher.exe" }); + const cmd = try std.fmt.allocPrint(a, "\"{s}\"", .{launcher_path}); + + const cmd_w = try std.unicode.utf8ToUtf16LeAllocZ(a, cmd); + const cwd_w = try std.unicode.utf8ToUtf16LeAllocZ(a, exe_dir); + + var si = STARTUPINFOW{}; + var pi = PROCESS_INFORMATION{}; + + const ok = CreateProcessW( + null, + cmd_w, + null, + null, + 0, + 0, + null, + cwd_w, + &si, + &pi, + ); + + if (ok != 0) { + if (pi.hProcess) |h| _ = CloseHandle(h); + if (pi.hThread) |h| _ = CloseHandle(h); + } +}