mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
H5 远程访问的三处不稳定来源修复,让手机出门在外也能稳定连接、长任务不丢。 #767 令牌与端口固定: - 令牌明文持久化到 cc-haha/settings.json,重启后二维码/令牌随时可查、可复制; enable 复用现有令牌、disable 保留令牌,仅 regenerate 才轮换。手改 token 字段 即自定义固定令牌。完整令牌只经 local-trusted 面返回,远端 403。 - 新增可选固定端口 fixedPort,并在未配置时复用上次端口(desktop-server-state.json sticky,Electron/Tauri 双壳共享),反向代理/手机书签跨重启不失效;占用时回退随机。 #764 断连不杀正在运行的 CLI: - 客户端断开时若该会话仍在跑一轮任务,不再 30s 后强杀子进程,而是等任务跑完; 手机锁屏/切后台时长任务在后台跑完,重连即见结果。 - 空闲清理超时改为可配 disconnectGraceSeconds(H5 访问设置页,默认 30s), 经 disconnectGraceConfig 同步缓存供 close 处理读取。 server / Electron / Tauri / React 四层 + 五语言 i18n + 配套单测全部覆盖。
199 lines
6.5 KiB
TypeScript
199 lines
6.5 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,
|
|
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)
|
|
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
|
|
}
|
|
}
|