add Touch ID wallet unlock via Swift biometric helper + Bun spawn

This commit is contained in:
2026-05-18 03:57:00 +08:00
parent d0648d62ef
commit b130e92294
9 changed files with 152 additions and 7 deletions
+1
View File
@@ -12,6 +12,7 @@ export default {
'dist/index.html': 'views/mainview/index.html', 'dist/index.html': 'views/mainview/index.html',
'dist/assets': 'views/mainview/assets', 'dist/assets': 'views/mainview/assets',
'drizzle': 'drizzle', 'drizzle': 'drizzle',
'src/bun/native/biometric-helper': 'native/biometric-helper',
}, },
// Ignore Vite output in watch mode — HMR handles view rebuilds separately // Ignore Vite output in watch mode — HMR handles view rebuilds separately
watchIgnore: ['dist/**'], watchIgnore: ['dist/**'],
+7 -6
View File
@@ -3,15 +3,16 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite build && electrobun dev", "start": "bun run build:native && vite build && electrobun dev",
"dev": "electrobun dev --watch", "dev": "bun run build:native && electrobun dev --watch",
"dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"", "dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"",
"hmr": "vite --port 5173", "hmr": "vite --port 5173",
"build:dev": "vite build && electrobun build", "build:dev": "bun run build:native && vite build && electrobun build",
"build:canary": "vite build && electrobun build --env=canary", "build:canary": "bun run build:native && vite build && electrobun build --env=canary",
"build:stable": "vite build && electrobun build --env=stable", "build:stable": "bun run build:native && vite build && electrobun build --env=stable",
"db:push": "bun x --bun drizzle-kit push", "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": { "dependencies": {
"@alchemy/wallet-apis": "^5.0.0-beta.28", "@alchemy/wallet-apis": "^5.0.0-beta.28",
+23
View File
@@ -27,6 +27,7 @@ import {
getBinaryDir, getBinaryDir,
MoneroWalletManager, MoneroWalletManager,
} from './lib/monero' } from './lib/monero'
import { canPromptTouchID, promptTouchID } from './lib/biometric'
const env = getENV() const env = getENV()
@@ -133,6 +134,14 @@ type TxEntry = {
type RPC = { type RPC = {
bun: RPCSchema<{ bun: RPCSchema<{
requests: { requests: {
biometricCanAuth: {
params: {}
response: boolean
}
biometricAuth: {
params: { reason: string }
response: boolean
}
getSecret: { getSecret: {
params: { params: {
service: string service: string
@@ -357,6 +366,20 @@ const rpc = BrowserView.defineRPC<RPC>({
maxRequestTime: 120000, maxRequestTime: 120000,
handlers: { handlers: {
requests: { requests: {
biometricCanAuth: async () => {
try {
return canPromptTouchID()
} catch {
return false
}
},
biometricAuth: async ({ reason }) => {
try {
return promptTouchID(reason)
} catch {
return false
}
},
getSecret: async ({ name, service }) => { getSecret: async ({ name, service }) => {
return await Bun.secrets.get({ name, service }) return await Bun.secrets.get({ name, service })
}, },
+31
View File
@@ -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
}
Binary file not shown.
@@ -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)
@@ -14,6 +14,7 @@
import ArrowDown from '@lucide/svelte/icons/arrow-down' import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up' import ArrowUp from '@lucide/svelte/icons/arrow-up'
import QrCodeIcon from '@lucide/svelte/icons/qr-code' 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 * as Dialog from '$lib/components/ui/dialog/index.js'
import QRCode from 'qrcode' import QRCode from 'qrcode'
@@ -101,8 +102,26 @@
</Card> </Card>
{:else if w.isLocked} {:else if w.isLocked}
<Card> <Card>
<CardHeader>
<CardTitle>Wallet Locked</CardTitle>
<CardDescription>Your seed is stored in the system keychain</CardDescription>
</CardHeader>
<CardContent> <CardContent>
<p class="text-muted-foreground text-xs">Unlocking...</p> {#if w.biometricAvailable}
<Button
onclick={() => w.unlockWithBiometrics()}
disabled={w.loading}>
<Fingerprint size={16} />
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
</Button>
{:else}
<p class="text-muted-foreground text-xs">
Touch ID not available on this device
</p>
{/if}
{#if w.error}
<p class="mt-3 text-red-500">{w.error}</p>
{/if}
</CardContent> </CardContent>
</Card> </Card>
{:else} {:else}
+8
View File
@@ -92,6 +92,14 @@ export type TokenBalanceResult = {
type RPC = { type RPC = {
bun: { bun: {
requests: { requests: {
biometricCanAuth: {
params: {}
response: boolean
}
biometricAuth: {
params: { reason: string }
response: boolean
}
getSecret: { getSecret: {
params: { params: {
service: string service: string
@@ -77,6 +77,7 @@ export const Wallet = () => {
let moneroTxs = $state<MoneroTxEntry[]>([]) let moneroTxs = $state<MoneroTxEntry[]>([])
let moneroInstalled = $state(false) let moneroInstalled = $state(false)
let moneroDownloading = $state(false) let moneroDownloading = $state(false)
let biometricAvailable = $state(false)
let moneroWalletName = $state('') let moneroWalletName = $state('')
let moneroAccounts = $state<MoneroAccountEntry[]>([]) let moneroAccounts = $state<MoneroAccountEntry[]>([])
let moneroWallets = $state<string[]>([]) let moneroWallets = $state<string[]>([])
@@ -115,6 +116,7 @@ export const Wallet = () => {
seed = raw seed = raw
await refresh() await refresh()
} }
await checkBiometric()
try { try {
} catch (e) { } catch (e) {
console.log(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 = () => { const lock = () => {
seed = '' seed = ''
address = '' address = ''
@@ -399,9 +427,11 @@ export const Wallet = () => {
get moneroWalletName() { return moneroWalletName }, get moneroWalletName() { return moneroWalletName },
get moneroAccounts() { return moneroAccounts }, get moneroAccounts() { return moneroAccounts },
get moneroWallets() { return moneroWallets }, get moneroWallets() { return moneroWallets },
get biometricAvailable() { return biometricAvailable },
refresh, refresh,
init, init,
lock, lock,
unlockWithBiometrics,
saveVault, saveVault,
switchNetwork, switchNetwork,
saveApiKey, saveApiKey,