Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions electron/ipc/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export function registerProjectHandlers() {
}))

ipcMain.handle('projects:openDialog', ipcHandler(async () => {
if (process.env.DAEMON_SMOKE_TEST === '1' && process.env.DAEMON_SMOKE_PROJECT_DIALOG_PATH) {
return process.env.DAEMON_SMOKE_PROJECT_DIALOG_PATH
}

const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select Project Folder',
Expand Down
53 changes: 53 additions & 0 deletions electron/ipc/spawnagents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ipcMain } from 'electron'
import { ipcHandler } from '../services/IpcHandlerFactory'
import * as SpawnAgents from '../services/SpawnAgentsService'

export function registerSpawnAgentsHandlers() {
ipcMain.handle('spawnagents:list', ipcHandler(async (_event, ownerPubkey: string) => {
return SpawnAgents.listAgents(ownerPubkey)
}))

ipcMain.handle('spawnagents:get', ipcHandler(async (_event, agentId: string) => {
return SpawnAgents.getAgent(agentId)
}))

ipcMain.handle('spawnagents:trades', ipcHandler(async (_event, agentId: string, limit?: number, offset?: number) => {
return SpawnAgents.getTrades(agentId, limit, offset)
}))

ipcMain.handle('spawnagents:positions', ipcHandler(async (_event, agentId: string) => {
return SpawnAgents.getPositions(agentId)
}))

ipcMain.handle('spawnagents:events', ipcHandler(async (_event, since: number, agentId?: string, limit?: number) => {
return SpawnAgents.getEvents(since, agentId, limit)
}))

ipcMain.handle('spawnagents:spawn-status', ipcHandler(async (_event, ref: string) => {
return SpawnAgents.pollSpawnStatus(ref)
}))

ipcMain.handle('spawnagents:initiate-spawn', ipcHandler(async (_event, input: SpawnAgents.SpawnInput) => {
return SpawnAgents.initiateSpawn(input)
}))

ipcMain.handle('spawnagents:initiate-spawn-child', ipcHandler(async (_event, parentAgentId: string, walletId: string, input: SpawnAgents.SpawnChildInput) => {
return SpawnAgents.initiateSpawnChild(parentAgentId, walletId, input)
}))

ipcMain.handle('spawnagents:withdraw', ipcHandler(async (_event, agentId: string, walletId: string, amountSol: number) => {
return SpawnAgents.withdraw(agentId, walletId, amountSol)
}))

ipcMain.handle('spawnagents:kill', ipcHandler(async (_event, agentId: string, walletId: string) => {
return SpawnAgents.killAgent(agentId, walletId)
}))

ipcMain.handle('spawnagents:spawn-and-fund', ipcHandler(async (_event, walletId: string, input: SpawnAgents.SpawnInput) => {
return SpawnAgents.spawnAndFund(walletId, input)
}))

ipcMain.handle('spawnagents:spawn-child-and-fund', ipcHandler(async (_event, parentAgentId: string, walletId: string, input: SpawnAgents.SpawnChildInput) => {
return SpawnAgents.spawnChildAndFund(parentAgentId, walletId, input)
}))
}
9 changes: 9 additions & 0 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { registerRecoveryHandlers } from '../ipc/recovery'
import { registerEngineHandlers } from '../ipc/engine'
import { registerToolHandlers } from '../ipc/tools'
import { registerPumpFunHandlers } from '../ipc/pumpfun'
import { registerSpawnAgentsHandlers } from '../ipc/spawnagents'
import { startEventStream as startSpawnAgentsEventStream, stopEventStream as stopSpawnAgentsEventStream } from '../services/SpawnAgentsService'
import { registerBrowserHandlers } from '../ipc/browser'
import { registerDeployHandlers } from '../ipc/deploy'
import { registerEmailHandlers } from '../ipc/email'
Expand All @@ -45,6 +47,7 @@ import { registerAgentStationHandlers } from '../ipc/agentStation'
import { registerReplayHandlers } from '../ipc/replay'
import { registerLspHandlers } from '../ipc/lsp'
import { registerTelemetryHandlers, initTelemetry } from '../ipc/telemetry'
import { flushRemoteTelemetry } from '../services/RemoteTelemetryService'
import { clearLoadedWallets } from '../services/RecoveryService'
import { maybeRecoverUnstableUiState, type UiRecoveryResult } from '../services/SettingsService'
import { shutdownAllLspSessions } from '../services/LspService'
Expand Down Expand Up @@ -124,6 +127,7 @@ const indexHtml = path.join(RENDERER_DIST, 'index.html')
function cleanupRuntimeState() {
killAllSessions()
shutdownAllLspSessions()
stopSpawnAgentsEventStream()
clearLoadedWallets()
closeDb()
}
Expand Down Expand Up @@ -177,6 +181,8 @@ function registerAllIpc() {
registerEngineHandlers()
registerToolHandlers()
registerPumpFunHandlers()
registerSpawnAgentsHandlers()
startSpawnAgentsEventStream()
registerBrowserHandlers()
registerDeployHandlers()
registerEmailHandlers()
Expand Down Expand Up @@ -403,6 +409,9 @@ async function createWindow() {
app.whenReady().then(() => {
if (SMOKE_TEST_MODE) console.log('[smoke] app:ready')
initTelemetry(app.getVersion() || '3.0.8')
flushRemoteTelemetry().catch((err) => {
console.warn('[telemetry] Remote telemetry startup failed:', err instanceof Error ? err.message : String(err))
})
createWindow().catch((err) => {
console.error('[smoke] createWindow:error', err)
})
Expand Down
20 changes: 20 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,26 @@ contextBridge.exposeInMainWorld('daemon', {
importKeypair: (walletId: string) => ipcRenderer.invoke('pumpfun:import-keypair', walletId),
},

spawnAgents: {
list: (ownerPubkey: string) => ipcRenderer.invoke('spawnagents:list', ownerPubkey),
get: (agentId: string) => ipcRenderer.invoke('spawnagents:get', agentId),
trades: (agentId: string, limit?: number, offset?: number) => ipcRenderer.invoke('spawnagents:trades', agentId, limit, offset),
positions: (agentId: string) => ipcRenderer.invoke('spawnagents:positions', agentId),
events: (since: number, agentId?: string, limit?: number) => ipcRenderer.invoke('spawnagents:events', since, agentId, limit),
spawnStatus: (ref: string) => ipcRenderer.invoke('spawnagents:spawn-status', ref),
initiateSpawn: (input: import('../services/SpawnAgentsService').SpawnInput) => ipcRenderer.invoke('spawnagents:initiate-spawn', input),
initiateSpawnChild: (parentAgentId: string, walletId: string, input: import('../services/SpawnAgentsService').SpawnChildInput) => ipcRenderer.invoke('spawnagents:initiate-spawn-child', parentAgentId, walletId, input),
spawnAndFund: (walletId: string, input: import('../services/SpawnAgentsService').SpawnInput) => ipcRenderer.invoke('spawnagents:spawn-and-fund', walletId, input),
spawnChildAndFund: (parentAgentId: string, walletId: string, input: import('../services/SpawnAgentsService').SpawnChildInput) => ipcRenderer.invoke('spawnagents:spawn-child-and-fund', parentAgentId, walletId, input),
withdraw: (agentId: string, walletId: string, amountSol: number) => ipcRenderer.invoke('spawnagents:withdraw', agentId, walletId, amountSol),
kill: (agentId: string, walletId: string) => ipcRenderer.invoke('spawnagents:kill', agentId, walletId),
onEvent: (callback: (ev: import('../services/SpawnAgentsService').SpawnEvent) => void) => {
const handler = (_e: unknown, ev: import('../services/SpawnAgentsService').SpawnEvent) => callback(ev)
ipcRenderer.on('spawnagents:event', handler)
return () => { ipcRenderer.off('spawnagents:event', handler) }
},
},

launch: {
listLaunchpads: () => ipcRenderer.invoke('launch:list-launchpads'),
listWalletOptions: (projectId?: string | null) => ipcRenderer.invoke('launch:list-wallet-options', projectId),
Expand Down
154 changes: 154 additions & 0 deletions electron/services/RemoteTelemetryService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import { app } from 'electron'

import { getDb } from '../db/db'
import { sanitizeErrorMessage } from '../security/PrivacyGuard'

const DEFAULT_TELEMETRY_ENDPOINT = 'https://daemon-landing.vercel.app/api/telemetry'
const STATE_TABLE = 'remote_telemetry_state'

type TelemetryEventName = 'first_open' | 'daily_active'

type TelemetryPayload = {
schemaVersion: 1
eventName: TelemetryEventName
eventId: string
installId: string
timestamp: number
appVersion: string
platform: NodeJS.Platform
arch: NodeJS.Architecture
osVersion: string
locale: string
isPackaged: boolean
isBackfill?: boolean
estimatedFirstSeenAt?: number
}

function remoteTelemetryEnabled(): boolean {
if (process.env.DAEMON_TELEMETRY_DISABLED === '1') return false
if (process.env.DAEMON_REMOTE_TELEMETRY_DEV === '1') return true
return app.isPackaged
}

function endpoint(): string {
return process.env.DAEMON_TELEMETRY_ENDPOINT?.trim() || DEFAULT_TELEMETRY_ENDPOINT
}

function utcDay(timestamp = Date.now()): string {
return new Date(timestamp).toISOString().slice(0, 10)
}

function ensureStateTable(): void {
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`)
}

function getState(key: string): string | null {
ensureStateTable()
const row = getDb()
.prepare(`SELECT value FROM ${STATE_TABLE} WHERE key = ?`)
.get(key) as { value: string } | undefined
return row?.value ?? null
}

function setState(key: string, value: string): void {
ensureStateTable()
getDb()
.prepare(`
INSERT INTO ${STATE_TABLE} (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`)
.run(key, value, Date.now())
}

function getInstallId(): string {
const existing = getState('install_id')
if (existing && /^[0-9a-f-]{36}$/i.test(existing)) return existing

const installId = randomUUID()
setState('install_id', installId)
setState('install_created_at', String(Date.now()))
return installId
}

function firstLocalTelemetryTimestamp(): number | null {
try {
const row = getDb()
.prepare('SELECT MIN(timestamp) as firstSeenAt FROM telemetry_events')
.get() as { firstSeenAt: number | null } | undefined
return typeof row?.firstSeenAt === 'number' ? row.firstSeenAt : null
} catch {
return null
}
}

function buildPayload(eventName: TelemetryEventName, installId: string): TelemetryPayload {
const firstSeenAt = firstLocalTelemetryTimestamp()
const installCreatedAt = Number(getState('install_created_at') ?? '')
const estimatedFirstSeenAt = firstSeenAt ?? (Number.isFinite(installCreatedAt) ? installCreatedAt : null)

return {
schemaVersion: 1,
eventName,
eventId: randomUUID(),
installId,
timestamp: Date.now(),
appVersion: app.getVersion() || 'unknown',
platform: process.platform,
arch: process.arch,
osVersion: os.release(),
locale: app.getLocale() || Intl.DateTimeFormat().resolvedOptions().locale || 'unknown',
isPackaged: app.isPackaged,
...(eventName === 'first_open' && firstSeenAt ? { isBackfill: true } : {}),
...(estimatedFirstSeenAt ? { estimatedFirstSeenAt } : {}),
}
}

async function postTelemetry(payload: TelemetryPayload): Promise<void> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5_000)

try {
const res = await fetch(endpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
})

if (!res.ok) {
throw new Error(`Telemetry POST failed with HTTP ${res.status}`)
}
} finally {
clearTimeout(timeout)
}
}

async function sendOnce(eventName: TelemetryEventName, stateKey: string, installId: string): Promise<void> {
if (getState(stateKey)) return

const payload = buildPayload(eventName, installId)
await postTelemetry(payload)
setState(stateKey, String(payload.timestamp))
}

export async function flushRemoteTelemetry(): Promise<void> {
if (!remoteTelemetryEnabled()) return

try {
const installId = getInstallId()
await sendOnce('first_open', 'first_open_sent_at', installId)
await sendOnce('daily_active', `daily_active_sent:${utcDay()}`, installId)
} catch (err) {
console.warn('[telemetry] Remote telemetry flush failed:', sanitizeErrorMessage(err))
}
}
Loading
Loading