import path from 'node:path' import { createAdapterPlan, createServerPlan, formatStartupError, killSidecar, mergeProxyEnv, POWERSHELL_PATH_OVERRIDE_ENV, proxyUrlFromElectronProxyRules, pushStartupLog, reserveLocalPort, SERVER_BIND_HOST, SERVER_CONTROL_HOST, spawnSidecar, waitForServer, windowsPowerShellOverride, type SidecarChild, } from './sidecarManager' import { readDesktopTerminalConfig, resolveDesktopTerminalShell } from './terminal' type ServerRuntimeOptions = { desktopRoot: string appRoot?: string h5DistDir?: string resolveSystemProxy?: (url: string) => Promise } export class ElectronServerRuntime { private readonly desktopRoot: string private readonly appRoot: string private readonly h5DistDir: string private readonly resolveSystemProxy?: (url: string) => Promise private sidecarEnvPromise: Promise | null = null private server: { url: string, child: SidecarChild } | null = null private adapters: SidecarChild[] = [] private startupError: string | null = null private startPromise: Promise | null = null constructor(options: ServerRuntimeOptions) { this.desktopRoot = options.desktopRoot this.appRoot = options.appRoot ?? options.desktopRoot this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist') this.resolveSystemProxy = options.resolveSystemProxy } async startServer(): Promise { if (this.server) return this.server.url if (this.startPromise) return this.startPromise this.startPromise = this.startServerOnce() try { return await this.startPromise } finally { this.startPromise = null } } async getServerUrl(): Promise { if (this.server) return this.server.url if (this.startupError) throw new Error(this.startupError) return await this.startServer() } async restartAdaptersSidecars(): Promise { this.stopAdaptersSidecars() const serverUrl = await this.getServerUrl() await this.startAdaptersSidecars(serverUrl) } stopAll(sync = false) { this.stopAdaptersSidecars(sync) if (this.server) { killSidecar(this.server.child, sync) this.server = null } } private async startServerOnce(): Promise { const port = await reserveLocalPort(SERVER_BIND_HOST) const url = `http://${SERVER_CONTROL_HOST}:${port}` const logs: string[] = [] const env = await this.resolveSidecarBaseEnv() const plan = createServerPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, port, h5DistDir: this.h5DistDir, env, }) try { const child = spawnSidecar(plan) this.captureLogs(child, 'claude-server', logs) await waitForServer(SERVER_CONTROL_HOST, port) this.server = { url, child } this.startupError = null await this.startAdaptersSidecars(url) return url } catch (error) { const message = error instanceof Error ? error.message : String(error) this.startupError = formatStartupError(message, logs) throw new Error(this.startupError) } } private async startAdaptersSidecars(serverUrl: string): Promise { const env = await this.resolveSidecarBaseEnv() for (const [label, flag] of [ ['feishu', '--feishu'], ['telegram', '--telegram'], ['wechat', '--wechat'], ['dingtalk', '--dingtalk'], ['whatsapp', '--whatsapp'], ] as const) { try { const child = spawnSidecar(createAdapterPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, h5DistDir: this.h5DistDir, serverUrl, flag, env, })) this.captureLogs(child, `claude-adapters:${label}`) this.adapters.push(child) } catch (error) { console.error(`[desktop] failed to start ${label} adapter sidecar`, error) } } } private stopAdaptersSidecars(sync = false) { for (const child of this.adapters.splice(0)) { killSidecar(child, sync) } } private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) { child.stdout.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.log(`[${label}] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`) }) child.stderr.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.error(`[${label}] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`) }) child.on('exit', (code, signal) => { const line = `sidecar exited (code=${code}, signal=${signal})` console.log(`[${label}] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`) }) } private async resolveSidecarBaseEnv(): Promise { this.sidecarEnvPromise ??= this.resolveSidecarBaseEnvOnce() return await this.sidecarEnvPromise } private async resolveSidecarBaseEnvOnce(): Promise { if (!this.resolveSystemProxy) return this.applyPowerShellOverride(process.env) try { const rules = await this.resolveSystemProxy('https://auth.openai.com/') return this.applyPowerShellOverride(mergeProxyEnv( process.env, proxyUrlFromElectronProxyRules(rules), )) } catch (error) { console.error('[desktop] failed to resolve system proxy for sidecars', error) return this.applyPowerShellOverride(process.env) } } // On Windows, forward the user's chosen PowerShell to the agent sidecar so its // PowerShellTool honors the same shell as the UI terminal (regression from the // Tauri build, where this lived in src-tauri/src/lib.rs). Best-effort: never // block sidecar startup, and never override an explicitly set env var. private applyPowerShellOverride(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (process.platform !== 'win32' || env[POWERSHELL_PATH_OVERRIDE_ENV]) return env try { const shell = resolveDesktopTerminalShell('win32', readDesktopTerminalConfig(env)) const override = windowsPowerShellOverride(shell, 'win32') if (override) return { ...env, [POWERSHELL_PATH_OVERRIDE_ENV]: override } } catch { // Misconfigured custom shell etc. — fall through to the unmodified env. } return env } }