0.0.2 loading states + wallet dialogs + monero unlock flow

This commit is contained in:
2026-08-02 12:15:55 +08:00
parent 50a7771d97
commit c9e492ad95
6 changed files with 399 additions and 80 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: { app: {
name: 'koins', name: 'koins',
identifier: 'koins.tzgyn.com', identifier: 'koins.tzgyn.com',
version: '0.0.1', version: '0.0.2',
}, },
build: { build: {
// Vite builds to dist/, we copy from there // Vite builds to dist/, we copy from there
Binary file not shown.
@@ -46,6 +46,21 @@ export const MoneroWallet = () => {
let loading = $state(false) let loading = $state(false)
let error = $state('') let error = $state('')
let selectedAccountIndex = $state(0) let selectedAccountIndex = $state(0)
let opening = $state(false)
let openingDepth = 0
let passwordRequired = $state(false)
let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null
let loginInFlight: Promise<void> | null = null
const beginOpening = () => {
openingDepth += 1
opening = true
}
const endOpening = () => {
openingDepth = Math.max(0, openingDepth - 1)
if (openingDepth === 0) opening = false
}
const checkBiometric = async () => { const checkBiometric = async () => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
@@ -76,7 +91,12 @@ export const MoneroWallet = () => {
} }
const login = async () => { const login = async () => {
if (walletOpen) return
if (loginInFlight) return loginInFlight
loginInFlight = (async () => {
accountType = 'monero' accountType = 'monero'
beginOpening()
try {
if (installed && !running) { if (installed && !running) {
await start() await start()
await checkStatus() await checkStatus()
@@ -85,6 +105,19 @@ export const MoneroWallet = () => {
if (wallets.length > 0) { if (wallets.length > 0) {
const pw = await moneroGetStoredPassword(wallets[0]) const pw = await moneroGetStoredPassword(wallets[0])
if (pw) await openExistingWallet(wallets[0], pw) if (pw) await openExistingWallet(wallets[0], pw)
else passwordRequired = true
}
error = ''
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to open wallet'
} finally {
endOpening()
}
})()
try {
await loginInFlight
} finally {
loginInFlight = null
} }
} }
@@ -143,6 +176,8 @@ export const MoneroWallet = () => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
const pw = password ?? (await moneroGetStoredPassword(name)) const pw = password ?? (await moneroGetStoredPassword(name))
if (!pw) throw new Error('Password required') if (!pw) throw new Error('Password required')
beginOpening()
try {
await electrobun.rpc.request.moneroOpenWallet({ await electrobun.rpc.request.moneroOpenWallet({
name, name,
password: pw, password: pw,
@@ -150,6 +185,9 @@ export const MoneroWallet = () => {
walletName = name walletName = name
walletOpen = true walletOpen = true
await refresh() await refresh()
} finally {
endOpening()
}
} }
const checkStatus = async () => { const checkStatus = async () => {
@@ -184,9 +222,20 @@ export const MoneroWallet = () => {
const start = async () => { const start = async () => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
const status = await electrobun.rpc.request.moneroStart({}) beginOpening()
try {
if (!startInFlight) {
startInFlight = electrobun.rpc.request.moneroStart({})
const status = await startInFlight
running = status.running running = status.running
connected = status.connected connected = status.connected
} else {
await startInFlight
}
} finally {
startInFlight = null
endOpening()
}
} }
const stop = async () => { const stop = async () => {
@@ -195,6 +244,7 @@ export const MoneroWallet = () => {
running = false running = false
walletOpen = false walletOpen = false
connected = false connected = false
passwordRequired = false
address = '' address = ''
balAtomic = '0' balAtomic = '0'
unlockedAtomic = '0' unlockedAtomic = '0'
@@ -209,6 +259,8 @@ export const MoneroWallet = () => {
storePw?: boolean, storePw?: boolean,
) => { ) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
beginOpening()
try {
const result = await electrobun.rpc.request.moneroCreateWallet({ const result = await electrobun.rpc.request.moneroCreateWallet({
name, name,
password, password,
@@ -219,6 +271,9 @@ export const MoneroWallet = () => {
if (storePw) await moneroStorePassword(name, password) if (storePw) await moneroStorePassword(name, password)
await refresh() await refresh()
return result return result
} finally {
endOpening()
}
} }
const restoreWallet = async ( const restoreWallet = async (
@@ -229,6 +284,8 @@ export const MoneroWallet = () => {
storePw?: boolean, storePw?: boolean,
) => { ) => {
if (!electrobun.rpc) throw new Error('RPC not available') if (!electrobun.rpc) throw new Error('RPC not available')
beginOpening()
try {
const result = await electrobun.rpc.request.moneroRestoreWallet({ const result = await electrobun.rpc.request.moneroRestoreWallet({
name, name,
password, password,
@@ -240,14 +297,22 @@ export const MoneroWallet = () => {
address = result.address address = result.address
if (storePw) await moneroStorePassword(name, password) if (storePw) await moneroStorePassword(name, password)
await refresh() await refresh()
} finally {
endOpening()
}
} }
const openWallet = async (name: string, password: string) => { const openWallet = async (name: string, password: string) => {
if (!electrobun.rpc) return if (!electrobun.rpc) return
beginOpening()
try {
await electrobun.rpc.request.moneroOpenWallet({ name, password }) await electrobun.rpc.request.moneroOpenWallet({ name, password })
walletName = name walletName = name
walletOpen = true walletOpen = true
await refresh() await refresh()
} finally {
endOpening()
}
} }
const autoUnlock = async (name?: string) => { const autoUnlock = async (name?: string) => {
@@ -255,15 +320,36 @@ export const MoneroWallet = () => {
const target = name ?? wallets[0] const target = name ?? wallets[0]
if (!target) return false if (!target) return false
const pw = await moneroGetStoredPassword(target) const pw = await moneroGetStoredPassword(target)
if (!pw) return false if (!pw) {
passwordRequired = true
return false
}
beginOpening()
try {
await electrobun.rpc.request.moneroOpenWallet({ await electrobun.rpc.request.moneroOpenWallet({
name: target, name: target,
password: pw, password: pw,
}) })
walletName = target walletName = target
walletOpen = true walletOpen = true
passwordRequired = false
error = ''
await refresh() await refresh()
return true return true
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to open wallet'
return false
} finally {
endOpening()
}
}
const unlockWallet = async (password: string, remember = false) => {
const target = wallets[0]
if (!target) return
await openWallet(target, password)
passwordRequired = false
if (remember) await moneroStorePassword(target, password)
} }
const refresh = async () => { const refresh = async () => {
@@ -404,6 +490,12 @@ export const MoneroWallet = () => {
get loading() { get loading() {
return loading return loading
}, },
get opening() {
return opening
},
get passwordRequired() {
return passwordRequired
},
get error() { get error() {
return error return error
}, },
@@ -451,6 +543,7 @@ export const MoneroWallet = () => {
restoreWallet, restoreWallet,
openWallet, openWallet,
autoUnlock, autoUnlock,
unlockWallet,
refresh, refresh,
send, send,
sendAll, sendAll,
+37 -5
View File
@@ -123,6 +123,17 @@
} }
}) })
$effect(() => {
if (
moneroWallet.ready &&
!moneroWallet.walletOpen &&
!moneroWallet.opening &&
!moneroWallet.passwordRequired
) {
moneroWallet.login()
}
})
$effect(() => { $effect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const handler = (event: ErrorEvent) => { const handler = (event: ErrorEvent) => {
@@ -181,7 +192,7 @@
{:else} {:else}
<div class="flex flex-col items-center pt-4 pb-2"> <div class="flex flex-col items-center pt-4 pb-2">
<p class="text-xs text-muted-foreground">Total Portfolio Value</p> <p class="text-xs text-muted-foreground">Total Portfolio Value</p>
{#if loadingXrp || loadingXmr} {#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div> <div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
{:else} {:else}
<p class="mt-1 text-3xl font-semibold tabular-nums"> <p class="mt-1 text-3xl font-semibold tabular-nums">
@@ -240,9 +251,7 @@
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{#if loadingXmr && !moneroWallet.walletOpen} {#if moneroWallet.walletOpen}
<div class="h-14 flex items-center"><Loader /></div>
{:else if moneroWallet.walletOpen}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p> <p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{#if xmrPrice} {#if xmrPrice}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums"> <p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
@@ -252,8 +261,31 @@
${fmtUsd(parseFloat(xmrPrice) )} / XMR ${fmtUsd(parseFloat(xmrPrice) )} / XMR
</p> </p>
{/if} {/if}
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
<p class="text-sm text-muted-foreground py-3">Wallet locked — unlock to view balance</p>
{:else if moneroWallet.wallets.length > 0} {:else if moneroWallet.wallets.length > 0}
<p class="text-sm text-muted-foreground py-3">Open wallet to view balance</p> <div class="h-14 flex items-center gap-2">
<Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p>
</div>
{:else if moneroWallet.opening}
<div class="h-14 flex items-center gap-2">
<Loader />
<p class="text-sm text-muted-foreground">Starting wallet server...</p>
</div>
{:else if moneroWallet.error}
<div class="py-3">
<p class="text-xs text-destructive break-words">{moneroWallet.error}</p>
<Button
variant="outline"
size="sm"
class="mt-2 gap-1.5"
onclick={() => moneroWallet.login()}>
Retry
</Button>
</div>
{:else if loadingXmr}
<div class="h-14 flex items-center"><Loader /></div>
{:else} {:else}
<Button <Button
onclick={() => navigate('/monero')} onclick={() => navigate('/monero')}
+71 -2
View File
@@ -19,8 +19,27 @@
let moneroMnemonic = $state('') let moneroMnemonic = $state('')
let moneroRestoreHeight = $state<number | undefined>(undefined) let moneroRestoreHeight = $state<number | undefined>(undefined)
let walletPassword = $state('')
let rememberPassword = $state(true)
let unlocking = $state(false)
let unlockError = $state('')
let initStarted = false let initStarted = false
async function unlockWithPassword() {
if (unlocking || !walletPassword.trim()) return
unlocking = true
unlockError = ''
try {
await w.unlockWallet(walletPassword.trim(), rememberPassword)
walletPassword = ''
} catch (e) {
unlockError = e instanceof Error ? e.message : 'Failed to unlock wallet'
} finally {
unlocking = false
}
}
$effect(() => { $effect(() => {
if (w.walletOpen && w.walletName) { if (w.walletOpen && w.walletName) {
navigate('/monero/wallet/:name', { navigate('/monero/wallet/:name', {
@@ -40,7 +59,7 @@
}) })
$effect(() => { $effect(() => {
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading) { if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
w.autoUnlock(w.wallets[0]) w.autoUnlock(w.wallets[0])
} }
}) })
@@ -66,6 +85,15 @@
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{:else if w.opening}
<Card>
<CardContent class="flex items-center justify-center gap-2 py-8">
<Loader />
<span class="text-sm text-muted-foreground">
{!w.running ? 'Starting wallet server...' : 'Opening wallet...'}
</span>
</CardContent>
</Card>
{:else if !w.installed} {:else if !w.installed}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -99,7 +127,48 @@
</CardContent> </CardContent>
</Card> </Card>
{:else if !w.walletOpen} {:else if !w.walletOpen}
{#if w.wallets.length > 0} {#if w.opening}
<Card>
<CardContent class="flex items-center justify-center py-8">
<Loader />
<span class="ml-2 text-sm text-muted-foreground">
Opening wallet...
</span>
</CardContent>
</Card>
{:else if w.passwordRequired && w.wallets.length > 0}
<Card>
<CardHeader>
<CardTitle>Wallet locked</CardTitle>
<CardDescription>
Enter the password for {w.wallets[0]} to open it
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-3">
<Input
type="password"
placeholder="Wallet password"
bind:value={walletPassword} />
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
bind:checked={rememberPassword}
class="h-3.5 w-3.5" />
Remember password for auto-unlock
</label>
<Button
onclick={unlockWithPassword}
disabled={unlocking || !walletPassword.trim()}>
{unlocking ? 'Opening...' : 'Unlock Wallet'}
</Button>
{#if unlockError}
<p class="text-xs text-destructive">{unlockError}</p>
{/if}
</div>
</CardContent>
</Card>
{:else if w.wallets.length > 0}
<Card> <Card>
<CardContent class="flex items-center justify-center py-8"> <CardContent class="flex items-center justify-center py-8">
<Loader /> <Loader />
@@ -24,6 +24,8 @@
import Send from '@lucide/svelte/icons/send' import Send from '@lucide/svelte/icons/send'
import QrCodeIcon from '@lucide/svelte/icons/qr-code' import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import Plus from '@lucide/svelte/icons/plus' import Plus from '@lucide/svelte/icons/plus'
import Users from '@lucide/svelte/icons/users'
import Tags from '@lucide/svelte/icons/tags'
import Loader from '$lib/components/loader.svelte' import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte' import QrCode from '$lib/components/qr.svelte'
import { navigate, route } from 'sv-router/generated' import { navigate, route } from 'sv-router/generated'
@@ -97,9 +99,53 @@
let accountDialogOpen = $state(false) let accountDialogOpen = $state(false)
let accountLabel = $state('') let accountLabel = $state('')
let accountBusy = $state(false)
let accountError = $state('')
let subaddressDialogOpen = $state(false) let subaddressDialogOpen = $state(false)
let subaddressAccountIndex = $state('') let subaddressAccountIndex = $state('')
let subaddressLabel = $state('') let subaddressLabel = $state('')
let subaddressBusy = $state(false)
let subaddressError = $state('')
$effect(() => {
if (!subaddressAccountIndex && w.accounts.length > 0) {
subaddressAccountIndex = String(w.accounts[0].index)
}
})
async function handleCreateAccount() {
if (accountBusy) return
accountBusy = true
accountError = ''
try {
await w.createAccount(accountLabel || undefined)
accountDialogOpen = false
accountLabel = ''
} catch (e) {
accountError = e instanceof Error ? e.message : 'Failed to create account'
} finally {
accountBusy = false
}
}
async function handleCreateSubaddress() {
if (subaddressBusy || !subaddressAccountIndex) return
subaddressBusy = true
subaddressError = ''
try {
await w.createSubaddress(
parseInt(subaddressAccountIndex),
subaddressLabel || undefined,
)
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
} catch (e) {
subaddressError = e instanceof Error ? e.message : 'Failed to create subaddress'
} finally {
subaddressBusy = false
}
}
const walletName = $derived(route.params.name) const walletName = $derived(route.params.name)
@@ -134,16 +180,27 @@
}) })
onMount(() => { onMount(() => {
if (!w.walletOpen || w.walletName !== walletName) { if (w.walletOpen && w.walletName === walletName) {
navigate('/monero')
}
w.refresh() w.refresh()
electrobun.rpc?.request electrobun.rpc?.request
.fetchMoneroPrice({}) .fetchMoneroPrice({})
.then((p) => (moneroPrice = p)) .then((p) => (moneroPrice = p))
}
})
$effect(() => {
if (!w.opening && (!w.walletOpen || w.walletName !== walletName)) {
navigate('/monero')
}
}) })
</script> </script>
{#if !w.walletOpen || w.walletName !== walletName}
<div class="flex flex-col items-center justify-center gap-3 py-24">
<Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p>
</div>
{:else}
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col items-center py-2"> <div class="flex flex-col items-center py-2">
{#if w.loading} {#if w.loading}
@@ -340,14 +397,44 @@
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<div class="flex items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Users size={16} class="text-muted-foreground" />
</div>
<div class="space-y-0.5">
<Dialog.Title>Create Account</Dialog.Title> <Dialog.Title>Create Account</Dialog.Title>
<Dialog.Description>Optional label for the new account</Dialog.Description> <Dialog.Description>
New account with its own balance and addresses
</Dialog.Description>
</div>
</div>
</Dialog.Header> </Dialog.Header>
<div class="px-6 pb-4 space-y-3"> <div class="px-6 pb-4 space-y-3">
<Input placeholder="Account label (optional)" bind:value={accountLabel} /> <div class="space-y-1.5">
<label class="text-xs font-medium" for="account-label">Label</label>
<Input id="account-label" placeholder="Savings" bind:value={accountLabel} />
<p class="text-xs text-muted-foreground">
Optional — shown next to the account in your wallet
</p>
</div>
{#if accountError}
<p class="text-xs text-destructive">{accountError}</p>
{/if}
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={() => { accountDialogOpen = false; accountLabel = '' }}>Cancel</Button> <Button
<Button size="sm" onclick={async () => { await w.createAccount(accountLabel || undefined); accountDialogOpen = false; accountLabel = '' }}>Create</Button> variant="outline"
size="sm"
disabled={accountBusy}
onclick={() => {
accountDialogOpen = false
accountLabel = ''
accountError = ''
}}>
Cancel
</Button>
<Button size="sm" disabled={accountBusy} onclick={handleCreateAccount}>
{accountBusy ? 'Creating...' : 'Create'}
</Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -360,29 +447,66 @@
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<div class="flex items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Tags size={16} class="text-muted-foreground" />
</div>
<div class="space-y-0.5">
<Dialog.Title>Create Subaddress</Dialog.Title> <Dialog.Title>Create Subaddress</Dialog.Title>
<Dialog.Description>Account index and optional label</Dialog.Description> <Dialog.Description>
Receiving address tied to an account
</Dialog.Description>
</div>
</div>
</Dialog.Header> </Dialog.Header>
<div class="px-6 pb-4 space-y-3"> <div class="px-6 pb-4 space-y-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs font-medium">Account</label> <label class="text-xs font-medium" for="subaddress-account">Account</label>
<Select type="single" bind:value={subaddressAccountIndex}> <Select type="single" bind:value={subaddressAccountIndex}>
<SelectTrigger> <SelectTrigger>
{subaddressAccountIndex ? `Account ${subaddressAccountIndex}` : 'Select account'} Account {subaddressAccountIndex}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each w.accounts as acct} {#each w.accounts as acct}
<SelectItem value={String(acct.index)}> <SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label ? ` - ${acct.label}` : ''} Account {acct.index}{acct.label ? ` - ${acct.label}` : ''} ({atomicToXmr(acct.unlockedBalance)} XMR)
</SelectItem> </SelectItem>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>
<p class="text-xs text-muted-foreground">
Where the subaddress belongs
</p>
</div> </div>
<Input placeholder="Subaddress label (optional)" bind:value={subaddressLabel} /> <div class="space-y-1.5">
<label class="text-xs font-medium" for="subaddress-label">Label</label>
<Input id="subaddress-label" placeholder="Donations" bind:value={subaddressLabel} />
<p class="text-xs text-muted-foreground">
Optional — helps you track who sent you funds
</p>
</div>
{#if subaddressError}
<p class="text-xs text-destructive">{subaddressError}</p>
{/if}
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={() => { subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Cancel</Button> <Button
<Button size="sm" disabled={!subaddressAccountIndex} onclick={async () => { await w.createSubaddress(parseInt(subaddressAccountIndex), subaddressLabel || undefined); subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Create</Button> variant="outline"
size="sm"
disabled={subaddressBusy}
onclick={() => {
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
subaddressError = ''
}}>
Cancel
</Button>
<Button
size="sm"
disabled={subaddressBusy || !subaddressAccountIndex}
onclick={handleCreateSubaddress}>
{subaddressBusy ? 'Creating...' : 'Create'}
</Button>
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -456,3 +580,4 @@
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/if}