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: {