Files
qiming/qimingclaw/crates/agent-electron-client/docs/reviews/electron-spawn-no-window-solution.md

18 KiB
Raw Blame History

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: truecmd.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 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 child_process 文档

注意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 PATHmacOS/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+

为什么选择平台差异化方案

  1. 保持 spawn API - 支持 stdio 流式通信ACP 协议需要 NDJSON
  2. 兼容原生模块 - fork() 在 Electron 中对原生模块有兼容问题
  3. 无需修改 Info.plist - 不影响主应用的 Dock 行为
  4. 参考 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();
    });
  });
});

验证步骤

  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 方案

  • 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
  • 系统目录 PATHSystem32, 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 客户端(使用此工具)

参考资料

官方文档

GitHub Issues

社区方案

更新历史

日期 版本 变更
2026-02-25 1.0 初始版本,记录完整解决方案
2026-02-25 1.1 添加社区讨论和 GitHub Issues 参考

文档维护Qiming Agent 开发团队