add Touch ID wallet unlock via Swift biometric helper + Bun spawn
This commit is contained in:
@@ -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/**'],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
BIN
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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user