514 lines
18 KiB
Markdown
514 lines
18 KiB
Markdown
# Electron 子进程跨平台无窗口启动方案
|
||
|
||
> 本文档记录了 Qiming Agent Electron 客户端在子进程启动时的跨平台兼容性问题及解决方案。
|
||
|
||
## 问题描述
|
||
|
||
在 Electron 应用中使用 `child_process.spawn()` 启动 Node.js 子进程时,会遇到以下平台特定问题:
|
||
|
||
| 平台 | 问题 | 影响 |
|
||
|------|------|------|
|
||
| **Windows** | CMD 控制台窗口闪烁/弹出 | 用户体验差,看起来像恶意软件 |
|
||
| **macOS** | 子进程出现在 Dock 栏并跳动 | 用户体验差,显示为独立应用 |
|
||
| **Linux** | 可能显示在任务栏 | 取决于桌面环境 |
|
||
|
||
## 问题根因
|
||
|
||
### Windows CMD 弹窗
|
||
|
||
1. npm 安装的包在 `node_modules/.bin/` 目录下生成 `.cmd` 和 `.ps1` 脚本
|
||
2. 这些 `.cmd` 文件会调用 `cmd.exe` 执行
|
||
3. 即使设置了 `windowsHide: true`,`cmd.exe` 可能忽略父进程的窗口标志
|
||
4. 结果:控制台窗口短暂闪烁或持续显示
|
||
|
||
### macOS Dock 图标
|
||
|
||
1. 使用 `spawn(process.execPath, ...)` + `ELECTRON_RUN_AS_NODE=1` 启动子进程
|
||
2. `process.execPath` 指向 Electron 可执行文件
|
||
3. macOS 将其识别为**新的应用实例**
|
||
4. 即使以 Node.js 模式运行,仍会出现在 Dock 中
|
||
|
||
## 社区讨论
|
||
|
||
这是一个**广泛存在的社区问题**,多个项目和 issue 都有相关讨论:
|
||
|
||
### 相关 GitHub Issues
|
||
|
||
| Issue | 项目 | 描述 |
|
||
|-------|------|------|
|
||
| [#480](https://github.com/anthropics/claude-agent-sdk-python/issues/480) | claude-agent-sdk-python | Windows subprocess 终端窗口隐藏 |
|
||
| [#2634](https://github.com/electron/electron/issues/2634) | Electron | Electron spawn 子进程问题 |
|
||
| [#4179](https://github.com/tauri-apps/tauri/discussions/4179) | Tauri | 启动后隐藏 CMD 窗口 |
|
||
|
||
### Node.js 官方文档
|
||
|
||
> `windowsHide <boolean>` - Hide the subprocess console window that would normally be created on Windows systems. **Default: false**
|
||
>
|
||
> — [Node.js child_process 文档](https://nodejs.org/api/child_process.html)
|
||
|
||
**注意**:Node.js v11.2.0 将 `windowsHide` 默认值恢复为 `false`,这意味着 GUI 应用需要**显式设置** `windowsHide: true`。
|
||
|
||
### 常见方案对比
|
||
|
||
| 方案 | 社区使用度 | 优点 | 缺点 |
|
||
|------|-----------|------|------|
|
||
| `windowsHide: true` | ⭐⭐⭐⭐⭐ | 简单,官方支持 | 对 `.cmd` 文件可能无效 |
|
||
| `cross-spawn` 模块 | ⭐⭐⭐⭐ | 跨平台自动处理 | 仍使用 `.cmd` 文件 |
|
||
| 绕过 `.cmd` 直接执行 JS | ⭐⭐⭐ | 彻底解决 | 需要解析入口 |
|
||
| `CREATE_NO_WINDOW` (Rust) | ⭐⭐⭐ | Windows API 级别 | 仅限 Rust/Native |
|
||
|
||
### 为什么 `windowsHide: true` 对 `.cmd` 文件可能无效
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ spawn('npm', [...], { windowsHide: true }) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ npm.cmd (batch file) │ │
|
||
│ │ │ │ │
|
||
│ │ ▼ (创建新的 cmd.exe 进程) │ │
|
||
│ │ ┌─────────────────────────────────────────────┐ │ │
|
||
│ │ │ cmd.exe /c "node npm-cli.js" │ │ │
|
||
│ │ │ │ │ │ │
|
||
│ │ │ │ ⚠️ cmd.exe 可能忽略父进程的 │ │ │
|
||
│ │ │ │ windowsHide 标志 │ │ │
|
||
│ │ │ ▼ │ │ │
|
||
│ │ │ [控制台窗口弹出] │ │ │
|
||
│ │ └─────────────────────────────────────────────┘ │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**解决方案**:绕过 `.cmd` 文件,直接执行 JS 入口文件。
|
||
|
||
## 解决方案
|
||
|
||
### 最终方案:平台差异化策略
|
||
|
||
```
|
||
┌─────────────┬────────────────────────────────────────────────┐
|
||
│ 平台 │ 策略 │
|
||
├─────────────┼────────────────────────────────────────────────┤
|
||
│ Windows │ Electron bundled Node + ELECTRON_RUN_AS_NODE=1 │
|
||
│ │ + windowsHide: true + 绕过 .cmd 文件 │
|
||
├─────────────┼────────────────────────────────────────────────┤
|
||
│ macOS │ 系统 node(从用户 shell PATH 解析) │
|
||
├─────────────┼────────────────────────────────────────────────┤
|
||
│ Linux │ 系统 node(从用户 shell PATH 解析) │
|
||
└─────────────┴────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 实现代码
|
||
|
||
#### 1. 核心工具模块 (`spawnNoWindow.ts`)
|
||
|
||
```typescript
|
||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||
import * as path from 'path';
|
||
import * as fs from 'fs';
|
||
|
||
/**
|
||
* 解析用户 shell PATH(macOS/Linux)
|
||
*
|
||
* 打包后的 Electron 应用不会继承用户的 shell profile,
|
||
* 所以 node/npm 等工具不会在 PATH 中,需要手动解析。
|
||
*/
|
||
function resolveUserShellPath(): string | null {
|
||
if (process.platform === 'win32') return null;
|
||
|
||
try {
|
||
const shell = process.env.SHELL || '/bin/bash';
|
||
// 使用 login shell (-il) 加载用户的 profile 文件
|
||
const result = execSync(`${shell} -ilc 'echo __PATH__=$PATH'`, {
|
||
encoding: 'utf-8',
|
||
timeout: 5000,
|
||
});
|
||
const match = result.match(/__PATH__=(.+)/);
|
||
return match ? match[1].trim() : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查找系统 node 可执行文件(macOS/Linux)
|
||
*
|
||
* 支持多种 node 安装方式:
|
||
* - Homebrew: /opt/homebrew/bin/node, /usr/local/bin/node
|
||
* - nvm: ~/.nvm/versions/node/...
|
||
* - volta: ~/.volta/bin/node
|
||
* - fnm: ~/.fnm/...
|
||
*/
|
||
function findSystemNode(): string {
|
||
if (process.platform === 'win32') {
|
||
return process.execPath; // Windows 使用 Electron bundled Node
|
||
}
|
||
|
||
const userPath = resolveUserShellPath();
|
||
|
||
if (userPath) {
|
||
// 在用户 PATH 中搜索 node
|
||
for (const dir of userPath.split(path.delimiter)) {
|
||
const nodePath = path.join(dir, 'node');
|
||
if (fs.existsSync(nodePath)) return nodePath;
|
||
}
|
||
}
|
||
|
||
// 回退:尝试常见安装路径
|
||
const home = process.env.HOME || '';
|
||
const commonPaths = [
|
||
'/usr/local/bin/node',
|
||
'/opt/homebrew/bin/node',
|
||
'/usr/bin/node',
|
||
path.join(home, '.nvm/versions/node/current/bin/node'),
|
||
path.join(home, '.volta/bin/node'),
|
||
];
|
||
|
||
for (const p of commonPaths) {
|
||
if (fs.existsSync(p)) return p;
|
||
}
|
||
|
||
return 'node'; // 最终回退
|
||
}
|
||
|
||
/**
|
||
* 解析 npm 包的 JS 入口文件
|
||
*
|
||
* 绕过 .cmd 文件,直接执行 JS 文件
|
||
*/
|
||
function resolveNpmPackageEntry(packageDir: string, binName?: string): string | null {
|
||
const pkgJsonPath = path.join(packageDir, 'package.json');
|
||
if (!fs.existsSync(pkgJsonPath)) return null;
|
||
|
||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
||
|
||
// 从 package.json 的 bin 字段获取入口
|
||
let binPath: string | undefined;
|
||
if (typeof pkg.bin === 'string') {
|
||
binPath = pkg.bin;
|
||
} else if (pkg.bin && typeof pkg.bin === 'object') {
|
||
const name = binName || pkg.name;
|
||
binPath = name ? pkg.bin[name] : Object.values(pkg.bin)[0] as string;
|
||
}
|
||
|
||
if (binPath) {
|
||
const entryPath = path.join(packageDir, binPath);
|
||
if (fs.existsSync(entryPath)) return entryPath;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 跨平台无窗口启动 JS 文件
|
||
*/
|
||
export function spawnJsFile(
|
||
jsFile: string,
|
||
args: string[] = [],
|
||
options: SpawnOptions = {},
|
||
): ChildProcess {
|
||
let node: string;
|
||
let env: Record<string, string | undefined>;
|
||
|
||
if (process.platform === 'win32') {
|
||
// Windows: 使用 Electron bundled Node
|
||
node = process.execPath;
|
||
env = {
|
||
...process.env,
|
||
...options.env,
|
||
ELECTRON_RUN_AS_NODE: '1', // 让 Electron 以 Node.js 模式运行
|
||
};
|
||
} else {
|
||
// macOS/Linux: 使用系统 node
|
||
node = findSystemNode();
|
||
env = {
|
||
...process.env,
|
||
...options.env,
|
||
PATH: resolveUserShellPath() || process.env.PATH,
|
||
};
|
||
}
|
||
|
||
return spawn(node, [jsFile, ...args], {
|
||
...options,
|
||
env,
|
||
windowsHide: true, // 防止 Windows 控制台窗口
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 启动 npm 包(自动解析入口)
|
||
*/
|
||
export function spawnNpmPackage(
|
||
packageDir: string,
|
||
args: string[] = [],
|
||
options: SpawnOptions = {},
|
||
binName?: string,
|
||
): ChildProcess | null {
|
||
const entryFile = resolveNpmPackageEntry(packageDir, binName);
|
||
if (!entryFile) return null;
|
||
|
||
return spawnJsFile(entryFile, args, options);
|
||
}
|
||
```
|
||
|
||
#### 2. 使用示例
|
||
|
||
```typescript
|
||
import { spawnJsFile, spawnNpmPackage } from './utils/spawnNoWindow';
|
||
|
||
// 启动 JS 文件
|
||
const child1 = spawnJsFile('/path/to/script.js', ['--port', '8080']);
|
||
|
||
// 启动 npm 包
|
||
const child2 = spawnNpmPackage(
|
||
'/path/to/node_modules/mcp-stdio-proxy',
|
||
['proxy', '--port', '9000'],
|
||
{ env: { CUSTOM_VAR: 'value' } },
|
||
'mcp-proxy' // bin 名称(可选)
|
||
);
|
||
|
||
// 处理输出
|
||
child2.stdout?.on('data', (data) => {
|
||
console.log('stdout:', data.toString());
|
||
});
|
||
|
||
child2.stderr?.on('data', (data) => {
|
||
console.error('stderr:', data.toString());
|
||
});
|
||
```
|
||
|
||
## 方案对比
|
||
|
||
| 方案 | Windows 弹窗 | macOS Dock | API 兼容 | 依赖 |
|
||
|------|:------------:|:----------:|:--------:|------|
|
||
| **系统 node + spawn** | ❌ CMD 弹窗 | ✅ 无 Dock | ✅ spawn | 系统安装 node |
|
||
| **Electron + ELECTRON_RUN_AS_NODE** | ✅ 无弹窗 | ❌ 有 Dock | ✅ spawn | 无 |
|
||
| **平台差异化(本方案)** | ✅ 无弹窗 | ✅ 无 Dock | ✅ spawn | macOS/Linux 需系统 node |
|
||
| **child_process.fork()** | ✅ 无弹窗 | ✅ 无 Dock | ⚠️ IPC | 无 |
|
||
| **utilityProcess.fork()** | ✅ 无弹窗 | ✅ 无 Dock | ⚠️ MessagePort | Electron 22+ |
|
||
|
||
### 为什么选择平台差异化方案
|
||
|
||
1. **保持 spawn API** - 支持 stdio 流式通信(ACP 协议需要 NDJSON)
|
||
2. **兼容原生模块** - fork() 在 Electron 中对原生模块有兼容问题
|
||
3. **无需修改 Info.plist** - 不影响主应用的 Dock 行为
|
||
4. **参考 LobsterAI** - 已经过生产环境验证
|
||
|
||
## 替代方案
|
||
|
||
### 1. Electron utilityProcess(官方推荐)
|
||
|
||
Electron 22+ 提供的官方 API,使用 Chromium Services API:
|
||
|
||
```typescript
|
||
const { utilityProcess } = require('electron');
|
||
|
||
const child = utilityProcess.fork('./worker.js', ['--arg'], {
|
||
stdio: 'pipe',
|
||
serviceName: 'MyService'
|
||
});
|
||
|
||
// 使用 MessagePort 通信
|
||
child.postMessage({ data: 'hello' });
|
||
child.on('message', (msg) => console.log(msg));
|
||
```
|
||
|
||
**限制**:
|
||
- 只能在 Electron 主进程使用
|
||
- 使用 MessagePort 而非 stdio
|
||
- 需要重构现有通信层
|
||
|
||
### 2. child_process.fork()
|
||
|
||
```typescript
|
||
const { fork } = require('child_process');
|
||
|
||
const child = fork('./worker.js', ['--arg']);
|
||
|
||
// 使用 IPC 通信
|
||
child.send({ data: 'hello' });
|
||
child.on('message', (msg) => console.log(msg));
|
||
```
|
||
|
||
**限制**:
|
||
- 使用 IPC 而非 stdio
|
||
- 在 Electron 中对原生模块有兼容问题
|
||
- 依赖 ELECTRON_RUN_AS_NODE(可能被 Fuse 禁用)
|
||
|
||
### 3. Info.plist 配置(仅 macOS)
|
||
|
||
对于纯后台应用,可以在打包时配置:
|
||
|
||
```json
|
||
// electron-builder 配置
|
||
{
|
||
"mac": {
|
||
"extendInfo": {
|
||
"LSUIElement": 1 // 不显示 Dock 图标
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**限制**:
|
||
- 只影响 macOS
|
||
- 会影响整个应用(包括主应用)
|
||
|
||
## 测试验证
|
||
|
||
### 测试用例
|
||
|
||
```typescript
|
||
describe('spawnNoWindow', () => {
|
||
describe('Windows 行为', () => {
|
||
it('应使用 ELECTRON_RUN_AS_NODE=1', () => {
|
||
// 模拟 Windows 平台
|
||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||
|
||
spawnJsFile('/path/to/script.js');
|
||
|
||
expect(callArgs[0]).toBe(process.execPath);
|
||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBe('1');
|
||
});
|
||
});
|
||
|
||
describe('macOS/Linux 行为', () => {
|
||
it('应使用系统 node,不设置 ELECTRON_RUN_AS_NODE', () => {
|
||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||
|
||
spawnJsFile('/path/to/script.js');
|
||
|
||
expect(callArgs[0]).toBe('/usr/local/bin/node'); // 系统 node
|
||
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBeUndefined();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 验证步骤
|
||
|
||
1. **Windows 验证**:
|
||
- 运行应用,触发子进程启动
|
||
- 确认无 CMD 窗口弹出
|
||
|
||
2. **macOS 验证**:
|
||
- 运行应用,触发子进程启动
|
||
- 确认 Dock 中无新图标出现
|
||
|
||
3. **Linux 验证**:
|
||
- 运行应用,触发子进程启动
|
||
- 确认无意外的任务栏图标
|
||
|
||
## 相关文件
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `src/main/services/utils/spawnNoWindow.ts` | 核心工具模块 |
|
||
| `src/main/services/utils/spawnNoWindow.test.ts` | 单元测试 |
|
||
| `src/main/services/packages/mcp.ts` | MCP Proxy 启动(使用此工具) |
|
||
|
||
---
|
||
|
||
## 内置 Node.js 24 和 Git 集成(2026-02-27)
|
||
|
||
### 背景
|
||
|
||
参考 [LobsterAI 方案](https://github.com/netease-youdao/LobsterAI):
|
||
|
||
- Electron 内置的 Node.js **不包含 npm/npx**
|
||
- Windows 用户通常没有预装 Node.js
|
||
- 需要集成独立的 Node.js 和 Git
|
||
|
||
### 集成方案
|
||
|
||
#### 1. 脚本准备
|
||
|
||
| 脚本 | 功能 |
|
||
|------|------|
|
||
| `scripts/prepare/prepare-node.js` | 下载 Node.js 24 到 resources/node/ |
|
||
| `scripts/prepare/prepare-git.js` | 下载 PortableGit 到 resources/git/(仅 Windows) |
|
||
|
||
#### 2. 打包配置
|
||
|
||
```json
|
||
// package.json
|
||
"extraResources": [
|
||
{ "from": "resources/node", "to": "node" },
|
||
{ "from": "resources/git", "to": "git" }
|
||
]
|
||
```
|
||
|
||
#### 3. PATH 优先级(Windows)
|
||
|
||
```
|
||
1. resources/node/bin ← 内置 Node.js 24(最高)
|
||
2. Electron 内置 Node
|
||
3. resources/git/bin ← 内置 Git
|
||
4. 应用内 node_modules
|
||
5. uv
|
||
6. Windows 系统目录(System32, Wbem, PowerShell)
|
||
7. 注册表最新 PATH ← 用户后安装的工具
|
||
```
|
||
|
||
#### 4. 环境变量
|
||
|
||
| 变量 | 说明 |
|
||
|------|------|
|
||
| `QIMINGCODE_NODE_DIR` | qimingcode-acp 使用 |
|
||
| `CLAUDE_CODE_NODE_DIR` | claude-code-acp-ts 使用 |
|
||
| `QIMINGCODE_GIT_BASH_PATH` | qimingcode-acp 使用 |
|
||
| `CLAUDE_CODE_GIT_BASH_PATH` | claude-code-acp-ts 使用 |
|
||
| `MSYS2_PATH_TYPE=inherit` | git-bash 正确继承 PATH |
|
||
| `ORIGINAL_PATH` | POSIX 格式 PATH |
|
||
|
||
#### 5. Windows 特殊优化
|
||
|
||
- **关键系统变量**:SystemRoot, windir, COMSPEC, SYSTEMDRIVE
|
||
- **系统目录 PATH**:System32, System32\Wbem, WindowsPowerShell, OpenSSH
|
||
- **注册表读取**:从注册表读取最新 PATH,解决后安装工具不在 PATH 问题
|
||
- **Electron Node Shim**:创建 node/npm 桥接脚本
|
||
|
||
### 相关文件
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `scripts/prepare/prepare-node.js` | Node.js 下载脚本 |
|
||
| `scripts/prepare/prepare-git.js` | Git 下载脚本 |
|
||
| `src/main/services/system/dependencies.ts` | 环境变量配置 |
|
||
| `src/main/services/engines/engineManager.ts` | 引擎启动(使用此工具) |
|
||
| `src/main/services/engines/acp/acpClient.ts` | ACP 客户端(使用此工具) |
|
||
|
||
## 参考资料
|
||
|
||
### 官方文档
|
||
|
||
- [Node.js child_process 文档](https://nodejs.org/api/child_process.html) - `windowsHide` 选项说明
|
||
- [Electron utilityProcess API](https://www.electronjs.org/docs/latest/api/utility-process) - Electron 22+ 推荐方案
|
||
- [Electron Dock API](https://www.electronjs.org/docs/latest/api/dock) - macOS Dock 控制
|
||
- [Electron Fuses - runAsNode](https://www.electronjs.org/docs/latest/tutorial/fuses) - ELECTRON_RUN_AS_NODE 安全控制
|
||
|
||
### GitHub Issues
|
||
|
||
- [electron/electron #2634](https://github.com/electron/electron/issues/2634) - Electron spawn 子进程问题
|
||
- [electron/electron #8727](https://github.com/electron/electron/issues/8727) - fork 和原生模块兼容问题
|
||
- [anthropics/claude-agent-sdk-python #480](https://github.com/anthropics/claude-agent-sdk-python/issues/480) - Windows subprocess 终端窗口隐藏
|
||
- [tauri-apps/tauri #4179](https://github.com/tauri-apps/tauri/discussions/4179) - Tauri 启动后隐藏 CMD 窗口
|
||
|
||
### 社区方案
|
||
|
||
- [LobsterAI coworkUtil.ts](https://github.com/LobsterAI/LobsterAI) - shell PATH 解析参考(本方案灵感来源)
|
||
- [cross-spawn](https://www.npmjs.com/package/cross-spawn) - 跨平台 spawn 封装(仍使用 .cmd 文件)
|
||
- [nano-spawn](https://www.npmjs.com/package/nano-spawn) - 现代 spawn 替代方案
|
||
|
||
## 更新历史
|
||
|
||
| 日期 | 版本 | 变更 |
|
||
|------|------|------|
|
||
| 2026-02-25 | 1.0 | 初始版本,记录完整解决方案 |
|
||
| 2026-02-25 | 1.1 | 添加社区讨论和 GitHub Issues 参考 |
|
||
|
||
---
|
||
|
||
*文档维护:Qiming Agent 开发团队*
|