From b130e92294966e3a7e44f5af8a676fb6112bd5c1 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 18 May 2026 03:57:00 +0800 Subject: [PATCH] add Touch ID wallet unlock via Swift biometric helper + Bun spawn --- apps/desktop/electrobun.config.ts | 1 + apps/desktop/package.json | 13 +++---- apps/desktop/src/bun/index.ts | 23 +++++++++++++ apps/desktop/src/bun/lib/biometric.ts | 31 +++++++++++++++++ apps/desktop/src/bun/native/biometric-helper | Bin 0 -> 54880 bytes .../src/bun/native/biometric-helper.swift | 32 ++++++++++++++++++ .../src/lib/components/EvmDashboard.svelte | 21 +++++++++++- apps/desktop/src/lib/electrobun.ts | 8 +++++ apps/desktop/src/lib/states/wallet.svelte.ts | 30 ++++++++++++++++ 9 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/bun/lib/biometric.ts create mode 100755 apps/desktop/src/bun/native/biometric-helper create mode 100644 apps/desktop/src/bun/native/biometric-helper.swift diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index b7440b5..3c1c41b 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -12,6 +12,7 @@ export default { 'dist/index.html': 'views/mainview/index.html', 'dist/assets': 'views/mainview/assets', 'drizzle': 'drizzle', + 'src/bun/native/biometric-helper': 'native/biometric-helper', }, // Ignore Vite output in watch mode — HMR handles view rebuilds separately watchIgnore: ['dist/**'], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e203303..0d1e677 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,15 +3,16 @@ "private": true, "type": "module", "scripts": { - "start": "vite build && electrobun dev", - "dev": "electrobun dev --watch", + "start": "bun run build:native && vite build && electrobun dev", + "dev": "bun run build:native && electrobun dev --watch", "dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"", "hmr": "vite --port 5173", - "build:dev": "vite build && electrobun build", - "build:canary": "vite build && electrobun build --env=canary", - "build:stable": "vite build && electrobun build --env=stable", + "build:dev": "bun run build:native && vite build && electrobun build", + "build:canary": "bun run build:native && vite build && electrobun build --env=canary", + "build:stable": "bun run build:native && vite build && electrobun build --env=stable", "db:push": "bun x --bun drizzle-kit push", - "db:generate": "drizzle-kit generate" + "db:generate": "drizzle-kit generate", + "build:native": "swiftc -O -o src/bun/native/biometric-helper src/bun/native/biometric-helper.swift" }, "dependencies": { "@alchemy/wallet-apis": "^5.0.0-beta.28", diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 594b40a..2a83881 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -27,6 +27,7 @@ import { getBinaryDir, MoneroWalletManager, } from './lib/monero' +import { canPromptTouchID, promptTouchID } from './lib/biometric' const env = getENV() @@ -133,6 +134,14 @@ type TxEntry = { type RPC = { bun: RPCSchema<{ requests: { + biometricCanAuth: { + params: {} + response: boolean + } + biometricAuth: { + params: { reason: string } + response: boolean + } getSecret: { params: { service: string @@ -357,6 +366,20 @@ const rpc = BrowserView.defineRPC({ maxRequestTime: 120000, handlers: { requests: { + biometricCanAuth: async () => { + try { + return canPromptTouchID() + } catch { + return false + } + }, + biometricAuth: async ({ reason }) => { + try { + return promptTouchID(reason) + } catch { + return false + } + }, getSecret: async ({ name, service }) => { return await Bun.secrets.get({ name, service }) }, diff --git a/apps/desktop/src/bun/lib/biometric.ts b/apps/desktop/src/bun/lib/biometric.ts new file mode 100644 index 0000000..6814635 --- /dev/null +++ b/apps/desktop/src/bun/lib/biometric.ts @@ -0,0 +1,31 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { PATHS } from 'electrobun/bun' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +function findHelper(): string { + const candidates = [ + join(__dirname, '..', 'native', 'biometric-helper'), + join(PATHS.RESOURCES_FOLDER, 'native', 'biometric-helper'), + ] + for (const p of candidates) { + if (existsSync(p)) return p + } + throw new Error( + `biometric-helper not found at any of:\n${candidates.join('\n')}`, + ) +} + +const HELPER_PATH = findHelper() + +export function canPromptTouchID(): boolean { + const result = Bun.spawnSync([HELPER_PATH, '--check']) + return result.exitCode === 0 +} + +export function promptTouchID(reason: string): boolean { + const result = Bun.spawnSync([HELPER_PATH, reason]) + return result.exitCode === 0 +} diff --git a/apps/desktop/src/bun/native/biometric-helper b/apps/desktop/src/bun/native/biometric-helper new file mode 100755 index 0000000000000000000000000000000000000000..98b81668dfeb8de2a8b850f055f328f2e38a0713 GIT binary patch literal 54880 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src/bun/native/biometric-helper.swift b/apps/desktop/src/bun/native/biometric-helper.swift new file mode 100644 index 0000000..2dc8126 --- /dev/null +++ b/apps/desktop/src/bun/native/biometric-helper.swift @@ -0,0 +1,32 @@ +import Foundation +import LocalAuthentication + +let context = LAContext() +var error: NSError? +let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + +let isCheck = CommandLine.arguments.contains("--check") +if isCheck { + print(available) + exit(available ? 0 : 1) +} + +guard available else { + print(error!.localizedDescription) + exit(1) +} + +let reason = CommandLine.arguments.last ?? "Authenticate" +let sem = DispatchSemaphore(value: 0) + +context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in + if success { + exit(0) + } + if let err = authError { + print(err.localizedDescription) + } + exit(1) +} + +_ = sem.wait(timeout: .distantFuture) diff --git a/apps/desktop/src/lib/components/EvmDashboard.svelte b/apps/desktop/src/lib/components/EvmDashboard.svelte index fb31315..5f7479d 100644 --- a/apps/desktop/src/lib/components/EvmDashboard.svelte +++ b/apps/desktop/src/lib/components/EvmDashboard.svelte @@ -14,6 +14,7 @@ import ArrowDown from '@lucide/svelte/icons/arrow-down' import ArrowUp from '@lucide/svelte/icons/arrow-up' import QrCodeIcon from '@lucide/svelte/icons/qr-code' + import Fingerprint from '@lucide/svelte/icons/fingerprint' import * as Dialog from '$lib/components/ui/dialog/index.js' import QRCode from 'qrcode' @@ -101,8 +102,26 @@ {:else if w.isLocked} + + Wallet Locked + Your seed is stored in the system keychain + -

Unlocking...

+ {#if w.biometricAvailable} + + {:else} +

+ Touch ID not available on this device +

+ {/if} + {#if w.error} +

{w.error}

+ {/if}
{:else} diff --git a/apps/desktop/src/lib/electrobun.ts b/apps/desktop/src/lib/electrobun.ts index 89dcc06..564a5d4 100644 --- a/apps/desktop/src/lib/electrobun.ts +++ b/apps/desktop/src/lib/electrobun.ts @@ -92,6 +92,14 @@ export type TokenBalanceResult = { type RPC = { bun: { requests: { + biometricCanAuth: { + params: {} + response: boolean + } + biometricAuth: { + params: { reason: string } + response: boolean + } getSecret: { params: { service: string diff --git a/apps/desktop/src/lib/states/wallet.svelte.ts b/apps/desktop/src/lib/states/wallet.svelte.ts index f8c4e5b..9d121da 100644 --- a/apps/desktop/src/lib/states/wallet.svelte.ts +++ b/apps/desktop/src/lib/states/wallet.svelte.ts @@ -77,6 +77,7 @@ export const Wallet = () => { let moneroTxs = $state([]) let moneroInstalled = $state(false) let moneroDownloading = $state(false) + let biometricAvailable = $state(false) let moneroWalletName = $state('') let moneroAccounts = $state([]) let moneroWallets = $state([]) @@ -115,6 +116,7 @@ export const Wallet = () => { seed = raw await refresh() } + await checkBiometric() try { } catch (e) { console.log(e) @@ -159,6 +161,32 @@ export const Wallet = () => { } } + const checkBiometric = async () => { + if (!electrobun.rpc) return + const [ok] = await tryCatch( + electrobun.rpc.request.biometricCanAuth({}), + ) + biometricAvailable = ok === true + } + + const unlockWithBiometrics = async () => { + if (!electrobun.rpc) return false + const [authed] = await tryCatch( + electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }), + ) + if (!authed) return false + const [raw] = await tryCatch( + electrobun.rpc.request.getSecret({ + service: 'koins', + name: 'vault', + }), + ) + if (!raw) return false + seed = raw + await refresh() + return true + } + const lock = () => { seed = '' address = '' @@ -399,9 +427,11 @@ export const Wallet = () => { get moneroWalletName() { return moneroWalletName }, get moneroAccounts() { return moneroAccounts }, get moneroWallets() { return moneroWallets }, + get biometricAvailable() { return biometricAvailable }, refresh, init, lock, + unlockWithBiometrics, saveVault, switchNetwork, saveApiKey,