cc-haha/desktop/electron/services/serverRuntime.ts
程序员阿江(Relakkes) 96a5842a01 fix(native): support Windows arm64 desktop startup (#954)
Add Windows ARM64 desktop release packaging, verify architecture-specific sidecar/native files in package smoke, and give the Electron sidecar more startup time plus early diagnostics for slow Windows ARM launches.

Tested: bun test electron/services/sidecarManager.test.ts
Tested: bun test scripts/quality-gate/package-smoke/index.test.ts scripts/release-update-metadata.test.ts scripts/pr/release-workflow.test.ts
Tested: bun run check:native
Tested: bun run check:policy
Not-tested: full bun run verify / coverage; this was a local issue-fix handoff, not PR-ready validation.
Confidence: high
Scope-risk: moderate
2026-07-02 18:27:55 +08:00

200 lines
6.6 KiB
TypeScript

import path from 'node:path'
import {
createAdapterPlan,
createServerPlan,
formatStartupError,
killSidecar,
mergeProxyEnv,
POWERSHELL_PATH_OVERRIDE_ENV,
preferredServerPorts,
proxyUrlFromElectronProxyRules,
pushStartupLog,
reserveServerPort,
SERVER_BIND_HOST,
SERVER_CONTROL_HOST,
SERVER_STARTUP_TIMEOUT_MS,
spawnSidecar,
waitForServer,
windowsPowerShellOverride,
writeLastServerPort,
type SidecarChild,
} from './sidecarManager'
import { readDesktopTerminalConfig, resolveDesktopTerminalShell } from './terminal'
type ServerRuntimeOptions = {
desktopRoot: string
appRoot?: string
h5DistDir?: string
resolveSystemProxy?: (url: string) => Promise<string>
}
export class ElectronServerRuntime {
private readonly desktopRoot: string
private readonly appRoot: string
private readonly h5DistDir: string
private readonly resolveSystemProxy?: (url: string) => Promise<string>
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
private server: { url: string, child: SidecarChild } | null = null
private adapters: SidecarChild[] = []
private startupError: string | null = null
private startPromise: Promise<string> | 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<string> {
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<string> {
if (this.server) return this.server.url
if (this.startupError) throw new Error(this.startupError)
return await this.startServer()
}
async restartAdaptersSidecars(): Promise<void> {
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<string> {
// Prefer the configured fixed port, then the previous run's port, so
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts())
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, SERVER_STARTUP_TIMEOUT_MS)
writeLastServerPort(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<void> {
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<NodeJS.ProcessEnv> {
this.sidecarEnvPromise ??= this.resolveSidecarBaseEnvOnce()
return await this.sidecarEnvPromise
}
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
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
}
}