18 KiB
18 KiB
Electron 子进程跨平台无窗口启动方案
本文档记录了 Qiming Agent Electron 客户端在子进程启动时的跨平台兼容性问题及解决方案。
问题描述
在 Electron 应用中使用 child_process.spawn() 启动 Node.js 子进程时,会遇到以下平台特定问题:
| 平台 | 问题 | 影响 |
|---|---|---|
| Windows | CMD 控制台窗口闪烁/弹出 | 用户体验差,看起来像恶意软件 |
| macOS | 子进程出现在 Dock 栏并跳动 | 用户体验差,显示为独立应用 |
| Linux | 可能显示在任务栏 | 取决于桌面环境 |
问题根因
Windows CMD 弹窗
- npm 安装的包在
node_modules/.bin/目录下生成.cmd和.ps1脚本 - 这些
.cmd文件会调用cmd.exe执行 - 即使设置了
windowsHide: true,cmd.exe可能忽略父进程的窗口标志 - 结果:控制台窗口短暂闪烁或持续显示
macOS Dock 图标
- 使用
spawn(process.execPath, ...)+ELECTRON_RUN_AS_NODE=1启动子进程 process.execPath指向 Electron 可执行文件- macOS 将其识别为新的应用实例
- 即使以 Node.js 模式运行,仍会出现在 Dock 中
社区讨论
这是一个广泛存在的社区问题,多个项目和 issue 都有相关讨论:
相关 GitHub Issues
| Issue | 项目 | 描述 |
|---|---|---|
| #480 | claude-agent-sdk-python | Windows subprocess 终端窗口隐藏 |
| #2634 | Electron | Electron spawn 子进程问题 |
| #4179 | Tauri | 启动后隐藏 CMD 窗口 |
Node.js 官方文档
windowsHide <boolean>- Hide the subprocess console window that would normally be created on Windows systems. Default: false
注意: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)
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. 使用示例
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+ |
为什么选择平台差异化方案
- 保持 spawn API - 支持 stdio 流式通信(ACP 协议需要 NDJSON)
- 兼容原生模块 - fork() 在 Electron 中对原生模块有兼容问题
- 无需修改 Info.plist - 不影响主应用的 Dock 行为
- 参考 LobsterAI - 已经过生产环境验证
替代方案
1. Electron utilityProcess(官方推荐)
Electron 22+ 提供的官方 API,使用 Chromium Services API:
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()
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)
对于纯后台应用,可以在打包时配置:
// electron-builder 配置
{
"mac": {
"extendInfo": {
"LSUIElement": 1 // 不显示 Dock 图标
}
}
}
限制:
- 只影响 macOS
- 会影响整个应用(包括主应用)
测试验证
测试用例
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();
});
});
});
验证步骤
-
Windows 验证:
- 运行应用,触发子进程启动
- 确认无 CMD 窗口弹出
-
macOS 验证:
- 运行应用,触发子进程启动
- 确认 Dock 中无新图标出现
-
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 方案:
- 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. 打包配置
// 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 文档 -
windowsHide选项说明 - Electron utilityProcess API - Electron 22+ 推荐方案
- Electron Dock API - macOS Dock 控制
- Electron Fuses - runAsNode - ELECTRON_RUN_AS_NODE 安全控制
GitHub Issues
- electron/electron #2634 - Electron spawn 子进程问题
- electron/electron #8727 - fork 和原生模块兼容问题
- anthropics/claude-agent-sdk-python #480 - Windows subprocess 终端窗口隐藏
- tauri-apps/tauri #4179 - Tauri 启动后隐藏 CMD 窗口
社区方案
- LobsterAI coworkUtil.ts - shell PATH 解析参考(本方案灵感来源)
- cross-spawn - 跨平台 spawn 封装(仍使用 .cmd 文件)
- nano-spawn - 现代 spawn 替代方案
更新历史
| 日期 | 版本 | 变更 |
|---|---|---|
| 2026-02-25 | 1.0 | 初始版本,记录完整解决方案 |
| 2026-02-25 | 1.1 | 添加社区讨论和 GitHub Issues 参考 |
文档维护:Qiming Agent 开发团队