diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index a7e86a3..b2593f8 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -4,7 +4,7 @@ export default { app: { name: 'koins', identifier: 'koins.tzgyn.com', - version: '0.0.1', + version: '0.0.2', }, build: { // Vite builds to dist/, we copy from there diff --git a/apps/desktop/src/bun/native/biometric-helper b/apps/desktop/src/bun/native/biometric-helper index bc74588..1880231 100755 Binary files a/apps/desktop/src/bun/native/biometric-helper and b/apps/desktop/src/bun/native/biometric-helper differ diff --git a/apps/desktop/src/lib/states/monero-wallet.svelte.ts b/apps/desktop/src/lib/states/monero-wallet.svelte.ts index 9a5f829..e9bb82d 100644 --- a/apps/desktop/src/lib/states/monero-wallet.svelte.ts +++ b/apps/desktop/src/lib/states/monero-wallet.svelte.ts @@ -46,6 +46,21 @@ export const MoneroWallet = () => { let loading = $state(false) let error = $state('') 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 | null = null + + const beginOpening = () => { + openingDepth += 1 + opening = true + } + + const endOpening = () => { + openingDepth = Math.max(0, openingDepth - 1) + if (openingDepth === 0) opening = false + } const checkBiometric = async () => { if (!electrobun.rpc) return @@ -76,15 +91,33 @@ export const MoneroWallet = () => { } const login = async () => { - accountType = 'monero' - if (installed && !running) { - await start() - await checkStatus() - } - await listWallets() - if (wallets.length > 0) { - const pw = await moneroGetStoredPassword(wallets[0]) - if (pw) await openExistingWallet(wallets[0], pw) + if (walletOpen) return + if (loginInFlight) return loginInFlight + loginInFlight = (async () => { + accountType = 'monero' + beginOpening() + try { + if (installed && !running) { + await start() + await checkStatus() + } + await listWallets() + if (wallets.length > 0) { + const pw = await moneroGetStoredPassword(wallets[0]) + 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,13 +176,18 @@ export const MoneroWallet = () => { if (!electrobun.rpc) throw new Error('RPC not available') const pw = password ?? (await moneroGetStoredPassword(name)) if (!pw) throw new Error('Password required') - await electrobun.rpc.request.moneroOpenWallet({ - name, - password: pw, - }) - walletName = name - walletOpen = true - await refresh() + beginOpening() + try { + await electrobun.rpc.request.moneroOpenWallet({ + name, + password: pw, + }) + walletName = name + walletOpen = true + await refresh() + } finally { + endOpening() + } } const checkStatus = async () => { @@ -184,9 +222,20 @@ export const MoneroWallet = () => { const start = async () => { if (!electrobun.rpc) return - const status = await electrobun.rpc.request.moneroStart({}) - running = status.running - connected = status.connected + beginOpening() + try { + if (!startInFlight) { + startInFlight = electrobun.rpc.request.moneroStart({}) + const status = await startInFlight + running = status.running + connected = status.connected + } else { + await startInFlight + } + } finally { + startInFlight = null + endOpening() + } } const stop = async () => { @@ -195,6 +244,7 @@ export const MoneroWallet = () => { running = false walletOpen = false connected = false + passwordRequired = false address = '' balAtomic = '0' unlockedAtomic = '0' @@ -209,16 +259,21 @@ export const MoneroWallet = () => { storePw?: boolean, ) => { if (!electrobun.rpc) throw new Error('RPC not available') - const result = await electrobun.rpc.request.moneroCreateWallet({ - name, - password, - }) - walletName = name - walletOpen = true - address = result.address - if (storePw) await moneroStorePassword(name, password) - await refresh() - return result + beginOpening() + try { + const result = await electrobun.rpc.request.moneroCreateWallet({ + name, + password, + }) + walletName = name + walletOpen = true + address = result.address + if (storePw) await moneroStorePassword(name, password) + await refresh() + return result + } finally { + endOpening() + } } const restoreWallet = async ( @@ -229,25 +284,35 @@ export const MoneroWallet = () => { storePw?: boolean, ) => { if (!electrobun.rpc) throw new Error('RPC not available') - const result = await electrobun.rpc.request.moneroRestoreWallet({ - name, - password, - mnemonic, - restoreHeight, - }) - walletName = name - walletOpen = true - address = result.address - if (storePw) await moneroStorePassword(name, password) - await refresh() + beginOpening() + try { + const result = await electrobun.rpc.request.moneroRestoreWallet({ + name, + password, + mnemonic, + restoreHeight, + }) + walletName = name + walletOpen = true + address = result.address + if (storePw) await moneroStorePassword(name, password) + await refresh() + } finally { + endOpening() + } } const openWallet = async (name: string, password: string) => { if (!electrobun.rpc) return - await electrobun.rpc.request.moneroOpenWallet({ name, password }) - walletName = name - walletOpen = true - await refresh() + beginOpening() + try { + await electrobun.rpc.request.moneroOpenWallet({ name, password }) + walletName = name + walletOpen = true + await refresh() + } finally { + endOpening() + } } const autoUnlock = async (name?: string) => { @@ -255,15 +320,36 @@ export const MoneroWallet = () => { const target = name ?? wallets[0] if (!target) return false const pw = await moneroGetStoredPassword(target) - if (!pw) return false - await electrobun.rpc.request.moneroOpenWallet({ - name: target, - password: pw, - }) - walletName = target - walletOpen = true - await refresh() - return true + if (!pw) { + passwordRequired = true + return false + } + beginOpening() + try { + await electrobun.rpc.request.moneroOpenWallet({ + name: target, + password: pw, + }) + walletName = target + walletOpen = true + passwordRequired = false + error = '' + await refresh() + 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 () => { @@ -404,6 +490,12 @@ export const MoneroWallet = () => { get loading() { return loading }, + get opening() { + return opening + }, + get passwordRequired() { + return passwordRequired + }, get error() { return error }, @@ -451,6 +543,7 @@ export const MoneroWallet = () => { restoreWallet, openWallet, autoUnlock, + unlockWallet, refresh, send, sendAll, diff --git a/apps/desktop/src/routes/index.svelte b/apps/desktop/src/routes/index.svelte index 469cce9..43a37ea 100644 --- a/apps/desktop/src/routes/index.svelte +++ b/apps/desktop/src/routes/index.svelte @@ -123,6 +123,17 @@ } }) + $effect(() => { + if ( + moneroWallet.ready && + !moneroWallet.walletOpen && + !moneroWallet.opening && + !moneroWallet.passwordRequired + ) { + moneroWallet.login() + } + }) + $effect(() => { if (typeof window !== 'undefined') { const handler = (event: ErrorEvent) => { @@ -181,7 +192,7 @@ {:else}

Total Portfolio Value

- {#if loadingXrp || loadingXmr} + {#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
{:else}

@@ -240,9 +251,7 @@ - {#if loadingXmr && !moneroWallet.walletOpen} -

- {:else if moneroWallet.walletOpen} + {#if moneroWallet.walletOpen}

{fmtBal(xmrBalance, 4)}

{#if xmrPrice}

@@ -252,8 +261,31 @@ ${fmtUsd(parseFloat(xmrPrice) )} / XMR

{/if} + {:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0} +

Wallet locked — unlock to view balance

{:else if moneroWallet.wallets.length > 0} -

Open wallet to view balance

+
+ +

Opening wallet...

+
+ {:else if moneroWallet.opening} +
+ +

Starting wallet server...

+
+ {:else if moneroWallet.error} +
+

{moneroWallet.error}

+ +
+ {:else if loadingXmr} +
{:else} + {#if unlockError} +

{unlockError}

+ {/if} +
+ + + {:else if w.wallets.length > 0} diff --git a/apps/desktop/src/routes/monero/wallet/[name].svelte b/apps/desktop/src/routes/monero/wallet/[name].svelte index d8f2d7a..c31f185 100644 --- a/apps/desktop/src/routes/monero/wallet/[name].svelte +++ b/apps/desktop/src/routes/monero/wallet/[name].svelte @@ -24,6 +24,8 @@ import Send from '@lucide/svelte/icons/send' import QrCodeIcon from '@lucide/svelte/icons/qr-code' 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 QrCode from '$lib/components/qr.svelte' import { navigate, route } from 'sv-router/generated' @@ -97,9 +99,53 @@ let accountDialogOpen = $state(false) let accountLabel = $state('') + let accountBusy = $state(false) + let accountError = $state('') let subaddressDialogOpen = $state(false) let subaddressAccountIndex = $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) @@ -134,16 +180,27 @@ }) onMount(() => { - if (!w.walletOpen || w.walletName !== walletName) { + if (w.walletOpen && w.walletName === walletName) { + w.refresh() + electrobun.rpc?.request + .fetchMoneroPrice({}) + .then((p) => (moneroPrice = p)) + } + }) + + $effect(() => { + if (!w.opening && (!w.walletOpen || w.walletName !== walletName)) { navigate('/monero') } - w.refresh() - electrobun.rpc?.request - .fetchMoneroPrice({}) - .then((p) => (moneroPrice = p)) }) +{#if !w.walletOpen || w.walletName !== walletName} +
+ +

Opening wallet...

+
+{:else}
{#if w.loading} @@ -340,14 +397,44 @@ - Create Account - Optional label for the new account +
+
+ +
+
+ Create Account + + New account with its own balance and addresses + +
+
- +
+ + +

+ Optional — shown next to the account in your wallet +

+
+ {#if accountError} +

{accountError}

+ {/if}
- - + +
@@ -360,29 +447,66 @@ - Create Subaddress - Account index and optional label +
+
+ +
+
+ Create Subaddress + + Receiving address tied to an account + +
+
- + +

+ Where the subaddress belongs +

- +
+ + +

+ Optional — helps you track who sent you funds +

+
+ {#if subaddressError} +

{subaddressError}

+ {/if}
- - + +
@@ -455,4 +579,5 @@
-
\ No newline at end of file + +{/if} \ No newline at end of file