diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore
index 3aee7df..1257b95 100644
--- a/apps/desktop/.gitignore
+++ b/apps/desktop/.gitignore
@@ -24,3 +24,4 @@ dist-ssr
*.sw?
.router
+artifacts
diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts
index 3c1c41b..f958f7f 100644
--- a/apps/desktop/electrobun.config.ts
+++ b/apps/desktop/electrobun.config.ts
@@ -11,6 +11,7 @@ export default {
copy: {
'dist/index.html': 'views/mainview/index.html',
'dist/assets': 'views/mainview/assets',
+ 'dist/icons': 'views/mainview/icons',
'drizzle': 'drizzle',
'src/bun/native/biometric-helper': 'native/biometric-helper',
},
diff --git a/apps/desktop/index.html b/apps/desktop/index.html
index 466f110..b2f57ca 100644
--- a/apps/desktop/index.html
+++ b/apps/desktop/index.html
@@ -2,12 +2,17 @@
-
+
sv-router Starter
+
diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts
index 3d42d9c..a3cc018 100644
--- a/apps/desktop/src/bun/index.ts
+++ b/apps/desktop/src/bun/index.ts
@@ -9,6 +9,7 @@ import {
import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
import { db } from './lib/db'
import { join } from 'path'
+import { existsSync } from 'fs'
import { getENV } from './lib/get-env'
import { createEvmHandlers } from './rpc/evm'
import { createMoneroHandlers } from './rpc/monero'
@@ -17,15 +18,22 @@ import { canPromptTouchID, promptTouchID } from './lib/biometric'
import { sqlite } from './lib/sqlite'
import { evmWallets } from './lib/db/schema'
import type { RPC } from '../lib/rpc-schema'
+import { log, logError } from './lib/logger'
const env = getENV()
-console.log(env)
+log(`env: ${JSON.stringify(env)}`)
-if (env.env === 'prod') {
- migrate(db, {
- migrationsFolder: join(PATHS.RESOURCES_FOLDER, 'app', 'drizzle'),
- })
+if (env.env === 'stable') {
+ try {
+ log('running migrations...')
+ migrate(db, {
+ migrationsFolder: join(PATHS.RESOURCES_FOLDER, 'app', 'drizzle'),
+ })
+ log('migrations complete')
+ } catch (e) {
+ logError('migration failed', e)
+ }
}
const DEV_SERVER_PORT = 5173
@@ -33,19 +41,17 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
async function getMainViewUrl(): Promise {
const channel = await Updater.localInfo.channel()
+ log(`channel from Updater: ${channel}`)
if (channel === 'dev') {
try {
await fetch(DEV_SERVER_URL, { method: 'HEAD' })
- console.log(
- `HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`,
- )
+ log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`)
return DEV_SERVER_URL
} catch {
- console.log(
- "Vite dev server not running. Run 'bun run dev:hmr' for HMR support.",
- )
+ log("Vite dev server not running, falling back to bundled view")
}
}
+ log('using bundled view')
return 'views://mainview/index.html'
}
@@ -76,6 +82,12 @@ ApplicationMenu.setApplicationMenu([
const url = await getMainViewUrl()
+log(`view URL: ${url}`)
+const viewsRoot = join(PATHS.RESOURCES_FOLDER, 'app', 'views')
+log(`viewsRoot: ${viewsRoot}`)
+log(`viewsRoot exists: ${existsSync(viewsRoot)}`)
+log(`index.html exists: ${existsSync(join(viewsRoot, 'mainview', 'index.html'))}`)
+
const moneroState = {
manager: null as MoneroWalletState | null,
downloading: false,
@@ -159,14 +171,19 @@ rpc.setRequestHandler({
margin: 1,
})
},
+ logToFile: async ({ message }) => {
+ log(message)
+ },
...createEvmHandlers(rpc, syncState),
...createMoneroHandlers(moneroState),
})
+log('creating BrowserWindow...')
const win = new BrowserWindow({
title: 'Koins',
url,
rpc,
+ viewsRoot: join(PATHS.RESOURCES_FOLDER, 'app', 'views'),
frame: {
width: 900,
height: 700,
@@ -174,5 +191,6 @@ const win = new BrowserWindow({
y: 200,
},
})
+log('BrowserWindow created')
-console.log('Koins app started!')
+log('Koins app started!')
diff --git a/apps/desktop/src/bun/lib/get-env.ts b/apps/desktop/src/bun/lib/get-env.ts
index 1bd397d..23d3ea5 100644
--- a/apps/desktop/src/bun/lib/get-env.ts
+++ b/apps/desktop/src/bun/lib/get-env.ts
@@ -1,21 +1,28 @@
-const env = (Bun.env.ELECTROBUN_ENV as string) || 'dev'
+import { PATHS } from 'electrobun/bun'
+import { readFileSync, existsSync } from 'fs'
+import { join } from 'path'
-console.log('ENV', env)
-
-export const getENV = () => {
- let appName
- let dbFilename
- if (env === 'stable') {
- appName = 'koins'
- dbFilename = 'koins.db'
- } else {
- appName = 'koins-dev'
- dbFilename = 'koins-dev.db'
+function detectEnv(): { env: string; appName: string; dbFilename: string } {
+ const channel = (Bun.env.ELECTROBUN_ENV as string) || ''
+ if (channel === 'stable') {
+ return { env: 'stable', appName: 'koins', dbFilename: 'koins.db' }
}
- return {
- env,
- appName,
- dbFilename,
- }
+ try {
+ const p = join(PATHS.RESOURCES_FOLDER, 'version.json')
+ if (existsSync(p)) {
+ const { channel: c } = JSON.parse(readFileSync(p, 'utf-8'))
+ if (c === 'stable') {
+ return { env: 'stable', appName: 'koins', dbFilename: 'koins.db' }
+ }
+ }
+ } catch {}
+
+ return { env: 'dev', appName: 'koins-dev', dbFilename: 'koins-dev.db' }
}
+
+const env = detectEnv()
+
+console.log('ENV', env.env)
+
+export const getENV = () => env
diff --git a/apps/desktop/src/bun/lib/logger.ts b/apps/desktop/src/bun/lib/logger.ts
new file mode 100644
index 0000000..d0905e8
--- /dev/null
+++ b/apps/desktop/src/bun/lib/logger.ts
@@ -0,0 +1,28 @@
+import { appendFileSync, mkdirSync, existsSync } from 'fs'
+import { join } from 'path'
+import envPaths from 'env-paths'
+import { getENV } from './get-env'
+
+const { appName } = getENV()
+const { data } = envPaths(appName, { suffix: '' })
+const logDir = join(data, 'logs')
+mkdirSync(logDir, { recursive: true })
+
+const date = () => {
+ const d = new Date()
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+const file = join(logDir, `${appName}-${date()}.log`)
+
+export const log = (msg: string) => {
+ const line = `[${new Date().toISOString()}] ${msg}\n`
+ try {
+ appendFileSync(file, line, 'utf-8')
+ } catch {}
+}
+
+export const logError = (msg: string, err?: unknown) => {
+ const detail = err instanceof Error ? err.stack || err.message : String(err ?? '')
+ log(`[ERROR] ${msg}${detail ? ' — ' + detail : ''}`)
+}
diff --git a/apps/desktop/src/lib/components/EvmDashboard.svelte b/apps/desktop/src/lib/components/EvmDashboard.svelte
index aec68e4..32afe3c 100644
--- a/apps/desktop/src/lib/components/EvmDashboard.svelte
+++ b/apps/desktop/src/lib/components/EvmDashboard.svelte
@@ -6,9 +6,9 @@
} from '$lib/states/evm-wallet.svelte.js'
import { Button } from '$lib/components/ui/button/index.js'
import { Textarea } from '$lib/components/ui/textarea/index.js'
-import { generateMnemonic } from 'viem/accounts'
-import { english } from 'viem/accounts'
-import { navigate } from 'sv-router/generated'
+ import { generateMnemonic } from 'viem/accounts'
+ import { english } from 'viem/accounts'
+ import { navigate } from 'sv-router/generated'
import {
Card,
CardContent,
@@ -30,6 +30,12 @@ import { navigate } from 'sv-router/generated'
import * as Dialog from '$lib/components/ui/dialog/index.js'
import QRCode from 'qrcode'
import Loader from './loader.svelte'
+ import { LineChart } from 'layerchart'
+ import { scaleUtc } from 'd3-scale'
+ import { curveLinear } from 'd3-shape'
+ import * as Chart from '$lib/components/ui/chart/index.js'
+ import { formatEther, parseEther } from 'viem'
+ import { frontendLog, frontendLogError } from '$lib/electrobun.js'
let { networkId }: { networkId: NetworkId } = $props()
@@ -161,6 +167,57 @@ import { navigate } from 'sv-router/generated'
})
}
})
+
+ function isNative(tx: TxEntry): boolean {
+ return !tx.contractAddress
+ }
+
+ const balanceHistory = $derived.by(() => {
+ const native = w.transactions.filter(isNative)
+ const sorted = [...native].reverse()
+ if (sorted.length === 0) return []
+ let cum = 0n
+ const addr = w.address.toLowerCase()
+ const points: { date: Date; balance: number }[] = []
+ for (const tx of sorted) {
+ console.log(tx.value)
+ const val = parseEther(tx.value)
+ if (tx.from.toLowerCase() === addr) cum -= val
+ else cum += val
+ points.push({
+ date: new Date(Number(tx.timeStamp) * 1000),
+ balance: Number(formatEther(cum)),
+ })
+ }
+ const cur = Number(w.balance)
+ const offset = cur - Number(formatEther(cum))
+ for (const p of points) p.balance += offset
+ return points
+ })
+
+ $inspect(balanceHistory)
+
+ let selectedRange = $state<'1D' | '1W' | '1M' | '1Y' | 'ALL'>('ALL')
+
+ const rangeCutoff = $derived(
+ selectedRange === 'ALL'
+ ? 0
+ : Date.now() -
+ (
+ {
+ '1D': 86400000,
+ '1W': 604800000,
+ '1M': 2592000000,
+ '1Y': 31536000000,
+ } as const
+ )[selectedRange],
+ )
+
+ const chartData = $derived(
+ selectedRange === 'ALL'
+ ? balanceHistory
+ : balanceHistory.filter((p) => p.date.getTime() >= rangeCutoff),
+ )
{#if !w.ready}
@@ -437,12 +494,23 @@ import { navigate } from 'sv-router/generated'
{/if}
{#if gasPrice}
- {@const gasNative = Number(gasPrice) * 21000 / 1e9}
+ {@const gasNative = (Number(gasPrice) * 21000) / 1e9}
- Gas: {Number(gasPrice).toFixed(1)} Gwei
- ({gasNative.toLocaleString(undefined, { minimumFractionDigits: 6, maximumFractionDigits: 6 })} {w.symbol})
+ Gas: {Number(gasPrice).toFixed(1)} Gwei ({gasNative.toLocaleString(
+ undefined,
+ {
+ minimumFractionDigits: 6,
+ maximumFractionDigits: 6,
+ },
+ )}
+ {w.symbol})
{#if nativePrice}
- ${(gasNative * Number(nativePrice.value)).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}
+ ${(
+ gasNative * Number(nativePrice.value)
+ ).toLocaleString(undefined, {
+ minimumFractionDigits: 4,
+ maximumFractionDigits: 4,
+ })}
{/if}
{/if}
@@ -519,6 +587,88 @@ import { navigate } from 'sv-router/generated'
+ {#if balanceHistory.length > 1}
+
+
+
+
Balance
+
+ {#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
+
+ {/each}
+
+
+
+
+ {#if chartData.length > 1}
+
+
+ a.balance < b.balance ? a : b,
+ ).balance,
+ chartData.reduce((a, b) =>
+ a.balance > b.balance ? a : b,
+ ).balance,
+ ]}
+ props={{
+ spline: {
+ curve: curveLinear,
+ motion: 'tween',
+ strokeWidth: 2,
+ },
+ yAxis: {},
+ xAxis: {
+ format: (v: Date) =>
+ v.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }),
+ },
+ highlight: { points: { r: 4 } },
+ }}>
+ {#snippet tooltip()}
+ {
+ return value.toLocaleDateString()
+ }}
+ valueFormatter={(value: Number) => {
+ return Number(value).toFixed(6)
+ }} />
+ {/snippet}
+
+
+ {:else}
+
+ No transactions in this time range.
+
+ {/if}
+
+
+ {/if}
+
Transactions
diff --git a/apps/desktop/src/lib/electrobun.ts b/apps/desktop/src/lib/electrobun.ts
index 9ce1bba..0b44451 100644
--- a/apps/desktop/src/lib/electrobun.ts
+++ b/apps/desktop/src/lib/electrobun.ts
@@ -23,3 +23,14 @@ const rpc = Electroview.defineRPC({
},
})
export const electrobun = new Electroview({ rpc })
+
+export async function frontendLog(message: string) {
+ try {
+ await electrobun.rpc?.request.logToFile({ message: `[FE] ${message}` })
+ } catch {}
+}
+
+export async function frontendLogError(message: string, err?: unknown) {
+ const detail = err instanceof Error ? err.stack || err.message : String(err ?? '')
+ await frontendLog(`[ERROR] ${message} — ${detail}`)
+}
diff --git a/apps/desktop/src/lib/rpc-schema.ts b/apps/desktop/src/lib/rpc-schema.ts
index 5d78e00..23b0c2a 100644
--- a/apps/desktop/src/lib/rpc-schema.ts
+++ b/apps/desktop/src/lib/rpc-schema.ts
@@ -148,6 +148,7 @@ export type GeneralRpcRequests = {
setSecret: { params: { service: string; name: string; value: string }; response: void }
openExternal: { params: { url: string }; response: void }
generateQrCode: { params: { text: string; size?: number }; response: string }
+ logToFile: { params: { message: string }; response: void }
}
export type EvmRpcRequests = {
diff --git a/apps/desktop/src/routes/index.svelte b/apps/desktop/src/routes/index.svelte
index 399feff..c58371d 100644
--- a/apps/desktop/src/routes/index.svelte
+++ b/apps/desktop/src/routes/index.svelte
@@ -11,15 +11,50 @@
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import { onMount } from 'svelte'
+ import { frontendLog, frontendLogError } from '$lib/electrobun.js'
const w = wallet
+ let feError = $state(null)
+
onMount(() => {
- w.init()
- moneroWallet.init()
+ frontendLog('index.svelte mounted')
+ w.init().catch((e) => frontendLogError('w.init() failed', e))
+ moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
+ })
+
+ $effect(() => {
+ if (typeof window !== 'undefined') {
+ const handler = (event: ErrorEvent) => {
+ const msg = event.error?.stack || event.error?.message || event.message || 'unknown error'
+ feError = msg
+ frontendLogError('uncaught error', msg)
+ }
+ const rejectHandler = (event: PromiseRejectionEvent) => {
+ const msg = event.reason?.stack || event.reason?.message || String(event.reason || 'unknown')
+ feError = msg
+ frontendLogError('unhandled rejection', msg)
+ }
+ window.addEventListener('error', handler)
+ window.addEventListener('unhandledrejection', rejectHandler)
+ return () => {
+ window.removeEventListener('error', handler)
+ window.removeEventListener('unhandledrejection', rejectHandler)
+ }
+ }
})
+{#if feError}
+
+
+
Frontend Error
+
{feError}
+
+
+{/if}
+
{#if !w.ready}
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index cdc6b96..cd40449 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -5,6 +5,7 @@ import { defineConfig } from 'vite'
import { fileURLToPath } from 'url'
export default defineConfig({
+ base: '',
plugins: [tailwindcss(), svelte(), router()],
resolve: {
alias: {