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 0000000..98b8166 Binary files /dev/null and b/apps/desktop/src/bun/native/biometric-helper differ 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,