提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

View File

@@ -0,0 +1,661 @@
# 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::Command` 或 `tokio::process::Command` 启动这些 `.cmd` 文件时:
**默认行为**
```rust
Command::new("C:\\...\\mcp-proxy.cmd").spawn()?;
// ❌ 会弹出 CMD 窗口
```
**需要显式设置**
```rust
#[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` (新建)
```rust
//! 进程启动工具函数
//!
//! 提供跨平台的进程启动功能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 使用方式
**在服务启动代码中**
```rust
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`
```rust
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)
}
```
#### 使用示例
```rust
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`
```rust
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` 模块**
```bash
# 在 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` 中导出**
```rust
// src/lib.rs
pub mod utils;
```
### 阶段 2: 修改服务启动代码 (2 小时)
4. **FileServer 服务**
- 文件: `src/service/file_server.rs`
- 找到: `Command::new(...)` 或 `TokioCommand::new(...)`
- 替换为: `start_service_with_logging(...)`
5. **McpProxy 服务**
- 文件: `src/service/mcp_proxy.rs`
- 同样的修改
6. **其他 `.cmd` 服务**
- 搜索所有 `Command::new` 并检查是否是 `.cmd` 文件
- 统一使用工具函数
### 阶段 3: Node.js 检测改进 (1 小时)
7. **改进 Node.js 检测**
- 文件: `src/dependency/node.rs`
- 添加 `is_nodejs_available()` 函数
- 修改 `ensure_npm_package()` 添加前置检查
### 阶段 4: 测试验证 (1 小时)
8. **Windows 测试**
- 编译 nuwax-agent
- 启动应用
- 验证:
- ✅ 无 CMD 窗口弹出
- ✅ 日志中能看到服务的 stdout/stderr
- ✅ mcp-proxy 健康检查成功
- ✅ Node.js 不会重复安装
9. **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
- `CREATE_NO_WINDOW (0x08000000)`: 隐藏控制台窗口
- 文档: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
### Rust 标准库
- `std::os::windows::process::CommandExt`: https://doc.rust-lang.org/std/os/windows/process/trait.CommandExt.html
### 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 版本
**风险**: 低(只影响进程启动方式,不改变业务逻辑)