Files
qiming/qiming-mcp-proxy/docs/analysis/NUWAX_AGENT_WINDOWS_CMD_FIX.md
2026-06-01 13:03:20 +08:00

19 KiB
Raw Blame History

nuwax-agent Windows CMD 窗口隐藏完整解决方案

问题总结

现象

在 Windows 上运行 nuwax-agent (Tauri 应用) 时,会弹出多个 CMD 命令行窗口,影响用户体验。

受影响的服务

所有通过 npm 全局安装的 Node.js 服务(以 .cmd 文件形式存在):

  1. mcp-proxy.cmd - MCP 代理服务

    C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
    
  2. nuwax-file-server.cmd - 文件服务器

    C:\Users\MECHREVO\.local\bin\nuwax-file-server.cmd
    
  3. 其他潜在的 npm 全局包

    • nuwaxcode
    • claude-code-acp-ts
    • 任何未来通过 npm install -g 安装的服务

根本原因

技术细节

Windows 上的 npm 全局包会生成批处理包装文件:

~/.local/bin/
├── package-name.cmd       # Windows 批处理文件
├── package-name           # Unix shell 脚本Windows 不使用)
└── package-name.ps1       # PowerShell 脚本(可选)

当 Rust 代码使用 std::process::Commandtokio::process::Command 启动这些 .cmd 文件时:

默认行为

Command::new("C:\\...\\mcp-proxy.cmd").spawn()?;
// ❌ 会弹出 CMD 窗口

需要显式设置

#[cfg(windows)]
{
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x08000000;
    cmd.creation_flags(CREATE_NO_WINDOW);
}
// ✅ 隐藏 CMD 窗口

层级关系说明

┌─────────────────────────────────────┐
│ nuwax-agent.exe (Tauri GUI 应用)   │
└─────────────────┬───────────────────┘
                  │
      ┌───────────┴───────────┐
      │                       │
      ▼                       ▼
┌─────────────────┐   ┌─────────────────┐
│ mcp-proxy.cmd   │   │ file-server.cmd │  ❌ 问题1: nuwax-agent 启动时未隐藏
└─────┬───────────┘   └─────────────────┘
      │
      ▼
┌─────────────────┐
│ MCP 服务子进程  │  ✅ 已修复 (mcp-proxy v0.1.39)
└─────────────────┘

结论

  • mcp-proxy v0.1.39 修复了mcp-proxy 启动 MCP 子进程时的 CMD 窗口
  • nuwax-agent 需要修复:启动 npm 全局包时的 CMD 窗口

解决方案

方案 1: 统一的进程启动工具函数 推荐

1.1 创建通用工具模块

文件: nuwax-agent-core/src/utils/process.rs (新建)

//! 进程启动工具函数
//! 
//! 提供跨平台的进程启动功能Windows 上自动隐藏 CMD 窗口

use std::process::Command as StdCommand;
use tokio::process::Command as TokioCommand;

/// 创建隐藏 CMD 窗口的同步 CommandWindows
/// 
/// # 用法
/// ```rust
/// let mut cmd = create_hidden_command("C:\\path\\to\\script.cmd");
/// cmd.env("PORT", "8080");
/// cmd.args(&["--production"]);
/// cmd.spawn()?;
/// ```
#[cfg(windows)]
pub fn create_hidden_command(program: impl AsRef<std::ffi::OsStr>) -> StdCommand {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x08000000;
    
    let mut cmd = StdCommand::new(program);
    
    // 注意:必须在所有 env/args 调用之后才能确保标志生效
    // 这里先设置,但调用者不应该再次修改
    cmd.creation_flags(CREATE_NO_WINDOW);
    
    cmd
}

#[cfg(not(windows))]
pub fn create_hidden_command(program: impl AsRef<std::ffi::OsStr>) -> StdCommand {
    StdCommand::new(program)
}

/// 创建隐藏 CMD 窗口的异步 CommandWindows
#[cfg(windows)]
pub fn create_hidden_tokio_command(program: impl AsRef<std::ffi::OsStr>) -> TokioCommand {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x08000000;
    
    let mut cmd = TokioCommand::new(program);
    cmd.creation_flags(CREATE_NO_WINDOW);
    
    cmd
}

#[cfg(not(windows))]
pub fn create_hidden_tokio_command(program: impl AsRef<std::ffi::OsStr>) -> TokioCommand {
    TokioCommand::new(program)
}

1.2 使用方式

在服务启动代码中

use crate::utils::process::create_hidden_tokio_command;

// FileServer 启动
pub async fn start_file_server(config: &FileServerConfig) -> Result<Child> {
    let cmd_path = "C:\\Users\\...\\nuwax-file-server.cmd";
    
    let mut cmd = create_hidden_tokio_command(cmd_path);
    cmd.env("PORT", &config.port.to_string());
    cmd.env("NODE_ENV", "production");
    cmd.args(&["--production"]);
    
    // 启动进程CMD 窗口已自动隐藏)
    let child = cmd.spawn()?;
    
    Ok(child)
}

// McpProxy 启动
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Child> {
    let cmd_path = "C:\\Users\\...\\mcp-proxy.cmd";
    
    let mut cmd = create_hidden_tokio_command(cmd_path);
    cmd.env("MCP_PORT", &config.port.to_string());
    
    let child = cmd.spawn()?;
    
    Ok(child)
}

方案 2: 增强版 - 带日志捕获 强烈推荐

由于 CMD 窗口隐藏后无法看到错误信息,需要捕获子进程的 stdout/stderr

文件: nuwax-agent-core/src/utils/process.rs

use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command as TokioCommand};
use std::process::Stdio;
use anyhow::Result;

/// 启动服务并捕获输出日志
/// 
/// # 特性
/// - Windows: 自动隐藏 CMD 窗口
/// - 捕获 stdout/stderr 并记录到日志
/// - 返回子进程句柄
/// 
/// # 参数
/// - `cmd_path`: 可执行文件路径(如 `C:\...\mcp-proxy.cmd`
/// - `service_name`: 服务名称(用于日志标识)
/// - `env_vars`: 环境变量 `Vec<(String, String)>`
/// - `args`: 命令行参数 `Vec<String>`
pub async fn start_service_with_logging(
    cmd_path: impl AsRef<std::ffi::OsStr>,
    service_name: &str,
    env_vars: Option<Vec<(String, String)>>,
    args: Option<Vec<String>>,
) -> Result<Child> {
    #[cfg(windows)]
    use std::os::windows::process::CommandExt;
    
    let mut cmd = TokioCommand::new(&cmd_path);
    
    // 设置环境变量
    if let Some(envs) = env_vars {
        for (key, value) in envs {
            cmd.env(key, value);
        }
    }
    
    // 设置参数
    if let Some(args) = args {
        cmd.args(&args);
    }
    
    // 捕获 stdout/stderr
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());
    
    // Windows: 隐藏 CMD 窗口(必须在最后设置)
    #[cfg(windows)]
    {
        const CREATE_NO_WINDOW: u32 = 0x08000000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    
    let mut child = cmd.spawn()
        .map_err(|e| anyhow::anyhow!("Failed to spawn {}: {}", service_name, e))?;
    
    // 异步读取并记录 stdout
    if let Some(stdout) = child.stdout.take() {
        let service_name = service_name.to_string();
        tokio::spawn(async move {
            let reader = BufReader::new(stdout);
            let mut lines = reader.lines();
            while let Ok(Some(line)) = lines.next_line().await {
                tracing::info!("[{} stdout] {}", service_name, line);
            }
        });
    }
    
    // 异步读取并记录 stderr
    if let Some(stderr) = child.stderr.take() {
        let service_name = service_name.to_string();
        tokio::spawn(async move {
            let reader = BufReader::new(stderr);
            let mut lines = reader.lines();
            while let Ok(Some(line)) = lines.next_line().await {
                tracing::error!("[{} stderr] {}", service_name, line);
            }
        });
    }
    
    tracing::info!("[{}] 进程已启动", service_name);
    
    Ok(child)
}

使用示例

use crate::utils::process::start_service_with_logging;

// FileServer 启动
pub async fn start_file_server(config: &FileServerConfig) -> Result<Child> {
    let cmd_path = resolve_npm_bin("nuwax-file-server").await?;
    
    let env_vars = vec![
        ("PORT".to_string(), config.port.to_string()),
        ("NODE_ENV".to_string(), "production".to_string()),
        ("LOG_LEVEL".to_string(), "info".to_string()),
    ];
    
    let args = vec!["--production".to_string()];
    
    let child = start_service_with_logging(
        &cmd_path,
        "FileServer",
        Some(env_vars),
        Some(args),
    ).await?;
    
    // 健康检查
    wait_for_port_ready(config.port, Duration::from_secs(10)).await?;
    
    Ok(child)
}

// McpProxy 启动
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Child> {
    let cmd_path = resolve_npm_bin("mcp-proxy").await?;
    
    let env_vars = vec![
        ("MCP_PORT".to_string(), config.port.to_string()),
    ];
    
    let child = start_service_with_logging(
        &cmd_path,
        "McpProxy",
        Some(env_vars),
        None,
    ).await?;
    
    // 健康检查
    let health_url = format!("http://127.0.0.1:{}/mcp", config.port);
    wait_for_http_ready(&health_url, Duration::from_secs(15)).await?;
    
    Ok(child)
}

方案 3: Node.js 检测逻辑改进 必须

解决日志中出现的 Node.js 重复安装问题。

文件: nuwax-agent-core/src/dependency/node.rs

use std::process::Stdio;
use tokio::time::Duration;
use anyhow::{Result, Context};

/// 检查 Node.js 是否真正可用
pub async fn is_nodejs_available() -> bool {
    #[cfg(windows)]
    let node_cmd = "node.exe";
    #[cfg(not(windows))]
    let node_cmd = "node";
    
    match tokio::process::Command::new(node_cmd)
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .await
    {
        Ok(status) if status.success() => {
            tracing::info!("Node.js is available in PATH");
            true
        }
        Ok(status) => {
            tracing::warn!("Node.js command exists but failed: {:?}", status);
            false
        }
        Err(e) => {
            tracing::debug!("Node.js not found in PATH: {}", e);
            false
        }
    }
}

/// 获取 Node.js 版本
pub async fn get_nodejs_version() -> Result<String> {
    #[cfg(windows)]
    let node_cmd = "node.exe";
    #[cfg(not(windows))]
    let node_cmd = "node";
    
    let output = tokio::process::Command::new(node_cmd)
        .arg("--version")
        .output()
        .await
        .context("Failed to get Node.js version")?;
    
    if !output.status.success() {
        anyhow::bail!("Node.js version check failed");
    }
    
    let version = String::from_utf8(output.stdout)
        .context("Invalid UTF-8 in Node.js version")?
        .trim()
        .to_string();
    
    Ok(version)
}

/// 检查 npm 包是否已全局安装
pub async fn is_npm_package_installed(package_name: &str) -> Result<bool> {
    if !is_nodejs_available().await {
        return Ok(false);
    }
    
    #[cfg(windows)]
    let npm_cmd = "npm.cmd";
    #[cfg(not(windows))]
    let npm_cmd = "npm";
    
    let output = tokio::process::Command::new(npm_cmd)
        .args(&["list", "-g", package_name, "--depth=0"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .await?;
    
    Ok(output.success())
}

/// 确保 npm 包已安装(带完整的前置检查)
pub async fn ensure_npm_package(package_name: &str) -> Result<()> {
    tracing::info!("Ensuring npm package: {}", package_name);
    
    // 1. 检查 Node.js 是否可用
    if !is_nodejs_available().await {
        tracing::warn!("Node.js not available, attempting to install...");
        
        // 尝试安装 Node.js
        install_nodejs().await
            .context("Failed to install Node.js")?;
        
        // 再次验证
        if !is_nodejs_available().await {
            anyhow::bail!("Node.js installation succeeded but still not available");
        }
        
        // 显示安装的版本
        if let Ok(version) = get_nodejs_version().await {
            tracing::info!("Node.js {} is now available", version);
        }
    }
    
    // 2. 检查包是否已安装
    if is_npm_package_installed(package_name).await? {
        tracing::info!("{} is already installed (global)", package_name);
        return Ok(());
    }
    
    // 3. 安装包(带重试限制)
    let max_retries = 3;
    for attempt in 1..=max_retries {
        tracing::info!(
            "Installing {} (attempt {}/{})",
            package_name, attempt, max_retries
        );
        
        match install_npm_package_global(package_name).await {
            Ok(_) => {
                tracing::info!("{} installed successfully", package_name);
                
                // 验证安装
                tokio::time::sleep(Duration::from_secs(1)).await;
                if is_npm_package_installed(package_name).await? {
                    return Ok(());
                } else {
                    tracing::warn!("{} installed but verification failed", package_name);
                }
            }
            Err(e) if attempt < max_retries => {
                tracing::warn!(
                    "Install attempt {}/{} failed: {}", 
                    attempt, max_retries, e
                );
                tokio::time::sleep(Duration::from_secs(2)).await;
            }
            Err(e) => {
                anyhow::bail!(
                    "Failed to install {} after {} attempts: {}", 
                    package_name, max_retries, e
                );
            }
        }
    }
    
    anyhow::bail!("Failed to verify {} installation", package_name)
}

/// 安装 npm 全局包
async fn install_npm_package_global(package_name: &str) -> Result<()> {
    #[cfg(windows)]
    let npm_cmd = "npm.cmd";
    #[cfg(not(windows))]
    let npm_cmd = "npm";
    
    let output = tokio::process::Command::new(npm_cmd)
        .args(&["install", "-g", package_name])
        .output()
        .await
        .context("Failed to run npm install")?;
    
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("npm install failed: {}", stderr);
    }
    
    Ok(())
}

实施步骤

阶段 1: 核心工具函数 (1 小时)

  1. 创建 utils/process.rs 模块

    # 在 nuwax-agent-core 中
    mkdir -p src/utils
    touch src/utils/process.rs
    touch src/utils/mod.rs
    
  2. 实现 start_service_with_logging 函数

    • 复制上面的代码
    • 添加到 src/utils/process.rs
  3. lib.rs 中导出

    // src/lib.rs
    pub mod utils;
    

阶段 2: 修改服务启动代码 (2 小时)

  1. FileServer 服务

    • 文件: src/service/file_server.rs
    • 找到: Command::new(...)TokioCommand::new(...)
    • 替换为: start_service_with_logging(...)
  2. McpProxy 服务

    • 文件: src/service/mcp_proxy.rs
    • 同样的修改
  3. 其他 .cmd 服务

    • 搜索所有 Command::new 并检查是否是 .cmd 文件
    • 统一使用工具函数

阶段 3: Node.js 检测改进 (1 小时)

  1. 改进 Node.js 检测
    • 文件: src/dependency/node.rs
    • 添加 is_nodejs_available() 函数
    • 修改 ensure_npm_package() 添加前置检查

阶段 4: 测试验证 (1 小时)

  1. Windows 测试

    • 编译 nuwax-agent
    • 启动应用
    • 验证:
      • 无 CMD 窗口弹出
      • 日志中能看到服务的 stdout/stderr
      • mcp-proxy 健康检查成功
      • Node.js 不会重复安装
  2. macOS/Linux 测试

    • 确保修改不影响其他平台

预期效果

修复前

用户启动 nuwax-agent
  ↓
[弹窗1] CMD 窗口 - nuwax-file-server.cmd
[弹窗2] CMD 窗口 - mcp-proxy.cmd
[弹窗3] CMD 窗口 - 其他服务...
  ↓
用户体验差 😢

修复后

用户启动 nuwax-agent
  ↓
所有服务静默启动(无 CMD 窗口)
  ↓
日志文件中可以看到所有服务输出
  ↓
用户体验好 ✅

日志示例

修复后的日志

2026-02-12T06:27:06 INFO [FileServer] 进程已启动
2026-02-12T06:27:07 INFO [FileServer stdout] 服务运行在: http://localhost:60000
2026-02-12T06:27:08 INFO [FileServer stdout] 环境: production
2026-02-12T06:27:09 INFO [McpProxy] 进程已启动
2026-02-12T06:27:10 INFO [McpProxy stdout] MCP Proxy listening on 127.0.0.1:18099
2026-02-12T06:27:11 INFO [McpProxy stdout] Health check: OK

如果服务启动失败

2026-02-12T06:27:10 INFO [McpProxy] 进程已启动
2026-02-12T06:27:10 ERROR [McpProxy stderr] Error: Cannot find module 'express'
2026-02-12T06:27:10 ERROR [McpProxy stderr]     at Function.Module._resolveFilename (node:internal/modules/cjs/loader:1048:15)
2026-02-12T06:27:11 ERROR [McpProxy] 健康检查失败: Connection refused

→ 现在可以清楚地看到错误原因!


参考资料

Windows Process Creation Flags

Rust 标准库

mcp-proxy v0.1.39 修复

  • Commit: aa42573 - fix: hide CMD windows on Windows platform for Tauri integration
  • 文件: mcp-streamable-proxy/src/server_builder.rs:230-241

常见问题

Q1: 为什么不能在创建 Command 时就设置 CREATE_NO_WINDOW

A: 某些 Rust 库(如 tokio::process::Command在内部可能会重新配置命令参数导致早期设置的标志被覆盖。最佳实践是在所有配置env, args, stdin/stdout/stderr之后最后设置。

Q2: 隐藏 CMD 窗口后如何调试启动失败?

A: 使用方案 2 的 start_service_with_logging 函数,它会捕获 stdout/stderr 并记录到日志文件中。

Q3: 是否需要在 macOS/Linux 上做特殊处理?

A: 不需要。macOS/Linux 上启动 shell 脚本不会弹窗口,create_hidden_command 函数在非 Windows 平台上只是简单的 Command::new()

Q4: 如果用户已经安装了 Node.js还会触发自动安装吗

A: 修复后不会。改进的 is_nodejs_available() 函数会先检查 node --version 是否成功,只有真正不可用时才会触发安装。


总结

问题 现状 修复后
CMD 窗口弹出 多个窗口弹出 完全隐藏
mcp-proxy 启动失败 15s 超时,看不到错误 日志中能看到详细错误
Node.js 重复安装 已安装仍尝试安装 10 次 正确检测,不重复安装
调试困难 窗口隐藏后无法调试 完整的 stdout/stderr 日志

实施时间: 约 5 小时(包括测试)
影响范围: nuwax-agent Windows 版本
风险: 低(只影响进程启动方式,不改变业务逻辑)