add biometric support for monero wallet open/create/restore, add monero settings card, fix monero logout to stop rpc

- Added moneroGetStoredPassword / moneroStorePassword keychain helpers
- moneroCreateWallet and moneroRestoreWallet accept storePw flag to save password to keychain
- moneroOpenExistingWallet accepts optional password (falls back to keychain lookup)
- Monero UI: 'Save password with Touch ID' checkbox hides password field, auto-generates random password, saves to keychain
- Monero UI: 'Open with Touch ID' button uses biometric auth + retrieves stored password
- Monero Settings card: enter wallet password to save with Touch ID for existing wallets
- logout() now async, calls moneroStop() to kill RPC process so re-login requires explicit wallet open
This commit is contained in:
2026-05-18 05:31:41 +08:00
parent 9846e6464d
commit 9396ffb1e0
3 changed files with 98 additions and 31 deletions
@@ -261,7 +261,7 @@
<Button variant="outline" onclick={() => navigate('/settings')}> <Button variant="outline" onclick={() => navigate('/settings')}>
<SettingsIcon size={16} /> <SettingsIcon size={16} />
</Button> </Button>
<Button variant="outline" onclick={() => { w.logout(); navigate('/') }}> <Button variant="outline" onclick={async () => { await w.logout(); navigate('/') }}>
Logout Logout
</Button> </Button>
</div> </div>
+25 -12
View File
@@ -142,20 +142,27 @@ export const Wallet = () => {
} }
} }
const logout = () => { const logout = async () => {
await moneroStop()
accountType = null accountType = null
seed = '' seed = ''
address = '' address = ''
balance = '0' balance = '0'
tokenBalances = [] tokenBalances = []
transactions = [] transactions = []
moneroWalletOpen = false }
moneroBalAtomic = '0'
moneroUnlockedAtomic = '0' const moneroGetStoredPassword = async (name: string): Promise<string | null> => {
moneroAddress = '' if (!electrobun.rpc) return null
moneroTxs = [] const [raw] = await tryCatch(
moneroWalletName = '' electrobun.rpc.request.getSecret({ service: 'koins', name: `monero_pw_${name}` }),
moneroAccounts = [] )
return raw ?? null
}
const moneroStorePassword = async (name: string, password: string) => {
if (!electrobun.rpc) return
await electrobun.rpc.request.setSecret({ service: 'koins', name: `monero_pw_${name}`, value: password })
} }
const switchNetwork = async (id: NetworkId) => { const switchNetwork = async (id: NetworkId) => {
@@ -334,9 +341,11 @@ export const Wallet = () => {
moneroWallets = wallets ?? [] moneroWallets = wallets ?? []
} }
const moneroOpenExistingWallet = async (name: string, password: string) => { const moneroOpenExistingWallet = async (name: string, password?: string) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
await electrobun.rpc.request.moneroOpenWallet({ name, password }) const pw = password ?? await moneroGetStoredPassword(name)
if (!pw) throw new Error('Password required')
await electrobun.rpc.request.moneroOpenWallet({ name, password: pw })
moneroWalletName = name moneroWalletName = name
moneroWalletOpen = true moneroWalletOpen = true
await moneroRefresh() await moneroRefresh()
@@ -415,17 +424,18 @@ export const Wallet = () => {
moneroWallets = [] moneroWallets = []
} }
const moneroCreateWallet = async (name: string, password: string) => { const moneroCreateWallet = async (name: string, password: string, storePw?: boolean) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroCreateWallet({ name, password }) const result = await electrobun.rpc.request.moneroCreateWallet({ name, password })
moneroWalletName = name moneroWalletName = name
moneroWalletOpen = true moneroWalletOpen = true
moneroAddress = result.address moneroAddress = result.address
if (storePw) await moneroStorePassword(name, password)
await moneroRefresh() await moneroRefresh()
return result return result
} }
const moneroRestoreWallet = async (name: string, password: string, mnemonic: string, restoreHeight?: number) => { const moneroRestoreWallet = async (name: string, password: string, mnemonic: string, restoreHeight?: number, storePw?: boolean) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const result = await electrobun.rpc.request.moneroRestoreWallet({ const result = await electrobun.rpc.request.moneroRestoreWallet({
name, password, mnemonic, restoreHeight, name, password, mnemonic, restoreHeight,
@@ -433,6 +443,7 @@ export const Wallet = () => {
moneroWalletName = name moneroWalletName = name
moneroWalletOpen = true moneroWalletOpen = true
moneroAddress = result.address moneroAddress = result.address
if (storePw) await moneroStorePassword(name, password)
await moneroRefresh() await moneroRefresh()
} }
@@ -536,6 +547,8 @@ export const Wallet = () => {
moneroFetchAccounts, moneroFetchAccounts,
moneroListWallets, moneroListWallets,
moneroOpenExistingWallet, moneroOpenExistingWallet,
moneroGetStoredPassword,
moneroStorePassword,
} }
} }
+72 -18
View File
@@ -12,6 +12,7 @@
} from '$lib/components/ui/card/index.js' } from '$lib/components/ui/card/index.js'
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 Fingerprint from '@lucide/svelte/icons/fingerprint'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
const w = wallet const w = wallet
@@ -22,6 +23,8 @@
let moneroRestoreHeight = $state<number | undefined>(undefined) let moneroRestoreHeight = $state<number | undefined>(undefined)
let moneroSelectedWallet = $state('') let moneroSelectedWallet = $state('')
let moneroSelectedWalletPass = $state('') let moneroSelectedWalletPass = $state('')
let moneroUseBiometric = $state(false)
let moneroSettingsPw = $state('')
let initStarted = false let initStarted = false
$effect(() => { $effect(() => {
@@ -122,32 +125,57 @@
{/each} {/each}
</div> </div>
{#if moneroSelectedWallet} {#if moneroSelectedWallet}
<div class="flex gap-2 items-end"> <div class="flex flex-col gap-2">
<Input {#if w.biometricAvailable}
type="password" <Button
placeholder="Enter password" onclick={async () => {
bind:value={moneroSelectedWalletPass} const ok = await w.unlockWithBiometrics()
/> if (ok) {
<Button await w.moneroOpenExistingWallet(moneroSelectedWallet)
onclick={() => w.moneroOpenExistingWallet(moneroSelectedWallet, moneroSelectedWalletPass)} if (!w.moneroWalletOpen) w.error = 'No password saved for this wallet'
disabled={w.loading || !moneroSelectedWalletPass} }
> }}
Open disabled={w.loading}>
</Button> <Fingerprint size={14} />
Open with Touch ID
</Button>
{/if}
<div class="flex gap-2 items-end">
<Input
type="password"
placeholder="Enter password"
bind:value={moneroSelectedWalletPass}
/>
<Button
onclick={() => w.moneroOpenExistingWallet(moneroSelectedWallet, moneroSelectedWalletPass)}
disabled={w.loading || !moneroSelectedWalletPass}
>
Open
</Button>
</div>
</div> </div>
{/if} {/if}
<hr class="border-muted" /> <hr class="border-muted" />
{/if} {/if}
<p class="text-xs text-muted-foreground">Or create a new wallet:</p> <p class="text-xs text-muted-foreground">Or create a new wallet:</p>
<Input placeholder="Wallet name" bind:value={moneroWalletName} /> <Input placeholder="Wallet name" bind:value={moneroWalletName} />
<Input type="password" placeholder="Password" bind:value={moneroWalletPass} /> {#if w.biometricAvailable}
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" bind:checked={moneroUseBiometric} />
Save password with Touch ID
</label>
{/if}
{#if !moneroUseBiometric}
<Input type="password" placeholder="Password" bind:value={moneroWalletPass} />
{/if}
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
onclick={async () => { onclick={async () => {
const result = await w.moneroCreateWallet(moneroWalletName, moneroWalletPass) const pw = moneroUseBiometric ? crypto.randomUUID() : moneroWalletPass
const result = await w.moneroCreateWallet(moneroWalletName, pw, moneroUseBiometric)
moneroMnemonic = result.mnemonic moneroMnemonic = result.mnemonic
}} }}
disabled={w.loading || !moneroWalletName || !moneroWalletPass}> disabled={w.loading || !moneroWalletName || (!moneroUseBiometric && !moneroWalletPass)}>
Create Wallet Create Wallet
</Button> </Button>
</div> </div>
@@ -155,9 +183,12 @@
<p class="text-xs text-muted-foreground">Restore from seed</p> <p class="text-xs text-muted-foreground">Restore from seed</p>
<Textarea placeholder="Enter your Monero seed phrase (16 or 25 words)" bind:value={moneroMnemonic} /> <Textarea placeholder="Enter your Monero seed phrase (16 or 25 words)" bind:value={moneroMnemonic} />
<Input type="number" placeholder="Restore height (optional)" bind:value={moneroRestoreHeight} /> <Input type="number" placeholder="Restore height (optional)" bind:value={moneroRestoreHeight} />
{#if !moneroUseBiometric}
<Input type="password" placeholder="Password" bind:value={moneroWalletPass} />
{/if}
<Button <Button
onclick={() => w.moneroRestoreWallet(moneroWalletName, moneroWalletPass, moneroMnemonic, moneroRestoreHeight)} onclick={() => w.moneroRestoreWallet(moneroWalletName, moneroUseBiometric ? crypto.randomUUID() : moneroWalletPass, moneroMnemonic, moneroRestoreHeight, moneroUseBiometric)}
disabled={w.loading || !moneroWalletName || !moneroWalletPass || !moneroMnemonic.trim()}> disabled={w.loading || !moneroWalletName || (!moneroUseBiometric && !moneroWalletPass) || !moneroMnemonic.trim()}>
Restore Wallet Restore Wallet
</Button> </Button>
</div> </div>
@@ -209,13 +240,36 @@
<Button variant="outline" onclick={() => w.moneroStop()}> <Button variant="outline" onclick={() => w.moneroStop()}>
Stop Stop
</Button> </Button>
<Button variant="outline" onclick={() => { w.logout(); navigate('/') }}> <Button variant="outline" onclick={async () => { await w.logout(); window.location.href = '/' }}>
Logout Logout
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Monero settings -->
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent>
{#if w.biometricAvailable}
<p class="text-xs font-medium mb-2">Save wallet password with Touch ID</p>
<p class="text-xs text-muted-foreground mb-3">Enter your wallet password to enable biometric unlock</p>
<div class="flex gap-2 items-end">
<Input type="password" placeholder="Wallet password" bind:value={moneroSettingsPw} />
<Button size="sm" onclick={async () => {
await w.moneroStorePassword(w.moneroWalletName, moneroSettingsPw)
moneroSettingsPw = ''
w.error = ''
}} disabled={!moneroSettingsPw || w.loading}>Save</Button>
</div>
{:else}
<p class="text-xs text-muted-foreground">Biometric authentication not available on this device</p>
{/if}
</CardContent>
</Card>
<!-- Monero transactions --> <!-- Monero transactions -->
<Card> <Card>
<CardHeader> <CardHeader>