Files
clawpanel/src-tauri/src/commands/diagnose.rs
晴天 11cd6218dc feat(diagnose): detect and inform about @homebridge/ciao cmd popup bug (#250)
* feat(diagnose): detect and inform about @homebridge/ciao cmd popup bug

On Windows, OpenClaw's transitive dependency @homebridge/ciao (<=1.3.6)
calls child_process.exec('arp -a ...') every 15-30 seconds without
passing windowsHide:true, causing a cmd.exe popup to flash.

This is an upstream library bug:
- Issue: homebridge/ciao#64
- PR:    homebridge/ciao#65 (open, not merged)

ClawPanel deliberately chooses 'detect and inform' rather than silently
patching the user's node_modules. We respect the user's control over
their own machine.

Changes:
- src-tauri/src/commands/diagnose.rs: new check_ciao_windowshide_bug
  command; scans openclaw's @homebridge/ciao/lib/NetworkManager.js and
  reports whether the buggy exec pattern is present
- src-tauri/src/lib.rs: register the new command
- scripts/dev-api.js: Web-mode stub (returns affected:false since the
  bug does not manifest off-Windows)
- src/lib/tauri-api.js: add api.checkCiaoWindowsHideBug
- src/lib/ciao-bug-warning.js: new module with toast + modal flow,
  version-scoped dismiss (localStorage)
- src/locales/modules/ciaoBug.js: translations in 5 primary languages
- src/locales/index.js: register the ciaoBug module
- src/main.js: call checker 3s after splash hides

Non-Windows users see nothing; Windows users see a single warning toast
(version-dismissible) linking to three fix paths: wait for upstream,
apply patch-package, or edit NetworkManager.js manually.

* fix(diagnose): gate helper with cfg(windows), drop unneeded return

CI failures on Linux + macOS:
- openclaw_module_root was dead code when target_os != windows
  since the only caller is the #[cfg(target_os = "windows")] block
  inside check_ciao_windowshide_bug
- Explicit `return CiaoCheckResult {...};` in the non-Windows branch
  triggered clippy::needless_return

Fix:
- Add #[cfg(target_os = "windows")] to openclaw_module_root so it
  is not compiled on other platforms
- Convert the non-Windows early exit to a tail expression
2026-04-24 19:36:20 +08:00

517 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Gateway 连接诊断命令
///
/// 执行一系列检查步骤,返回结构化诊断结果,帮助用户定位连接问题。
use serde::Serialize;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnoseStep {
pub name: String,
pub ok: bool,
pub message: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnoseEnv {
pub openclaw_dir: String,
pub config_exists: bool,
pub port: u16,
pub auth_mode: String,
pub device_key_exists: bool,
pub gateway_owner: Option<String>,
pub err_log_excerpt: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnoseResult {
pub steps: Vec<DiagnoseStep>,
pub env: DiagnoseEnv,
pub overall_ok: bool,
pub summary: String,
}
fn step_timer() -> Instant {
Instant::now()
}
fn finish_step(name: &str, ok: bool, message: &str, start: Instant) -> DiagnoseStep {
DiagnoseStep {
name: name.to_string(),
ok,
message: message.to_string(),
duration_ms: start.elapsed().as_millis() as u64,
}
}
/// 读取环境信息
fn collect_env() -> DiagnoseEnv {
let openclaw_dir = crate::commands::openclaw_dir();
let config_path = openclaw_dir.join("openclaw.json");
let config_exists = config_path.exists();
let port = crate::commands::gateway_listen_port();
// 认证模式
let auth_mode = if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
let auth = val.get("gateway").and_then(|g| g.get("auth"));
if let Some(auth) = auth {
if auth
.get("token")
.and_then(|t| t.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
{
"token".to_string()
} else if auth
.get("password")
.and_then(|p| p.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
{
"password".to_string()
} else {
"none".to_string()
}
} else {
"none".to_string()
}
} else {
"config_parse_error".to_string()
}
} else {
"config_missing".to_string()
};
// 设备密钥
let device_key_path = openclaw_dir.join("clawpanel-device-key.json");
let device_key_exists = device_key_path.exists();
// Gateway owner
let owner_path = openclaw_dir.join("gateway-owner.json");
let gateway_owner = std::fs::read_to_string(&owner_path).ok();
// 错误日志
let err_log_path = openclaw_dir.join("logs").join("gateway.err.log");
let err_log_excerpt = if let Ok(bytes) = std::fs::read(&err_log_path) {
let max = 2048;
let tail = if bytes.len() > max {
&bytes[bytes.len() - max..]
} else {
&bytes[..]
};
String::from_utf8_lossy(tail).to_string()
} else {
String::new()
};
DiagnoseEnv {
openclaw_dir: openclaw_dir.display().to_string(),
config_exists,
port,
auth_mode,
device_key_exists,
gateway_owner,
err_log_excerpt,
}
}
/// TCP 端口探测
async fn check_tcp_port(port: u16) -> DiagnoseStep {
let t = step_timer();
let addr = format!("127.0.0.1:{port}");
match tokio::net::TcpStream::connect(&addr).await {
Ok(_) => finish_step("tcp_port", true, &format!("端口 {port} 可达"), t),
Err(e) => finish_step("tcp_port", false, &format!("端口 {port} 不可达: {e}"), t),
}
}
/// 检查配置文件
fn check_config() -> DiagnoseStep {
let t = step_timer();
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
if !config_path.exists() {
return finish_step("config", false, "openclaw.json 不存在", t);
}
match std::fs::read_to_string(&config_path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(val) => {
if val.get("gateway").is_some() {
finish_step("config", true, "配置文件有效,含 gateway 配置", t)
} else {
finish_step("config", false, "配置文件缺少 gateway 段", t)
}
}
Err(e) => finish_step("config", false, &format!("JSON 解析失败: {e}"), t),
},
Err(e) => finish_step("config", false, &format!("读取失败: {e}"), t),
}
}
/// 检查设备密钥
fn check_device_key() -> DiagnoseStep {
let t = step_timer();
let key_path = crate::commands::openclaw_dir().join("clawpanel-device-key.json");
if key_path.exists() {
match std::fs::read_to_string(&key_path) {
Ok(content) => {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if val.get("deviceId").is_some() && val.get("publicKey").is_some() {
finish_step("device_key", true, "设备密钥有效", t)
} else {
finish_step("device_key", false, "设备密钥文件缺少必要字段", t)
}
} else {
finish_step("device_key", false, "设备密钥文件 JSON 无效", t)
}
}
Err(e) => finish_step("device_key", false, &format!("读取失败: {e}"), t),
}
} else {
finish_step(
"device_key",
false,
"设备密钥不存在(将在首次连接时自动生成)",
t,
)
}
}
/// 检查 allowedOrigins 配置
fn check_allowed_origins() -> DiagnoseStep {
let t = step_timer();
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
match std::fs::read_to_string(&config_path) {
Ok(content) => {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
let origins = val
.get("gateway")
.and_then(|g| g.get("controlUi"))
.and_then(|c| c.get("allowedOrigins"))
.and_then(|o| o.as_array());
match origins {
Some(arr) if !arr.is_empty() => {
let list: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
let has_tauri = list.iter().any(|o| {
o.contains("tauri://") || o.contains("https://tauri.localhost")
});
if has_tauri {
finish_step(
"allowed_origins",
true,
&format!("allowedOrigins 包含 Tauri origin: {:?}", list),
t,
)
} else {
finish_step(
"allowed_origins",
false,
&format!("allowedOrigins 缺少 Tauri origin: {:?}", list),
t,
)
}
}
Some(_) => finish_step("allowed_origins", false, "allowedOrigins 为空数组", t),
None => finish_step(
"allowed_origins",
false,
"未配置 allowedOriginsautoPair 会自动修复)",
t,
),
}
} else {
finish_step("allowed_origins", false, "配置文件解析失败", t)
}
}
Err(_) => finish_step("allowed_origins", false, "配置文件不可读", t),
}
}
/// HTTP /health 探测(尝试性,上游可能未暴露)
async fn check_http_health(port: u16) -> DiagnoseStep {
let t = step_timer();
let url = format!("http://127.0.0.1:{port}/health");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build();
match client {
Ok(c) => match c.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
finish_step(
"http_health",
true,
&format!("HTTP /health 返回 {status}"),
t,
)
} else {
finish_step(
"http_health",
false,
&format!("HTTP /health 返回 {status}"),
t,
)
}
}
Err(e) => finish_step(
"http_health",
false,
&format!("HTTP /health 请求失败: {e}"),
t,
),
},
Err(e) => finish_step(
"http_health",
false,
&format!("HTTP client 创建失败: {e}"),
t,
),
}
}
/// 检查 Gateway 错误日志
fn check_error_log() -> DiagnoseStep {
let t = step_timer();
let log_path = crate::commands::openclaw_dir()
.join("logs")
.join("gateway.err.log");
if !log_path.exists() {
return finish_step("err_log", true, "无错误日志(正常)", t);
}
match std::fs::metadata(&log_path) {
Ok(meta) => {
let size = meta.len();
if size == 0 {
finish_step("err_log", true, "错误日志为空(正常)", t)
} else {
// 读最后 1KB 看有没有关键错误
let content = std::fs::read(&log_path).unwrap_or_default();
let tail = if content.len() > 1024 {
&content[content.len() - 1024..]
} else {
&content[..]
};
let text = String::from_utf8_lossy(tail).to_lowercase();
let has_fatal = text.contains("fatal")
|| text.contains("eaddrinuse")
|| text.contains("config invalid");
if has_fatal {
finish_step(
"err_log",
false,
&format!("错误日志含关键错误 ({size} bytes)"),
t,
)
} else {
finish_step(
"err_log",
true,
&format!("错误日志存在但无致命错误 ({size} bytes)"),
t,
)
}
}
}
Err(e) => finish_step("err_log", false, &format!("无法读取日志: {e}"), t),
}
}
#[tauri::command]
pub async fn diagnose_gateway_connection() -> DiagnoseResult {
let env = collect_env();
let port = env.port;
let mut steps = Vec::new();
// 1. 配置文件检查
steps.push(check_config());
// 2. 设备密钥检查
steps.push(check_device_key());
// 3. allowedOrigins 检查
steps.push(check_allowed_origins());
// 4. TCP 端口探测
steps.push(check_tcp_port(port).await);
// 5. HTTP /health 探测
steps.push(check_http_health(port).await);
// 6. 错误日志检查
steps.push(check_error_log());
let overall_ok = steps.iter().all(|s| s.ok);
let failed: Vec<&str> = steps
.iter()
.filter(|s| !s.ok)
.map(|s| s.name.as_str())
.collect();
let summary = if overall_ok {
"所有检查项通过".to_string()
} else {
format!("以下检查未通过: {}", failed.join(", "))
};
DiagnoseResult {
steps,
env,
overall_ok,
summary,
}
}
// =============================================================================
// @homebridge/ciao Windows cmd popup bug detection
//
// Upstream issue: https://github.com/homebridge/ciao/issues/64
// Upstream PR: https://github.com/homebridge/ciao/pull/65 (still open)
//
// Symptom on Windows: every 15-30s a cmd.exe / conhost.exe window flashes while
// Gateway is running. Root cause is @homebridge/ciao < 1.3.7 calling
// `child_process.exec("arp -a ...", callback)` without `{ windowsHide: true }`.
//
// This is NOT a ClawPanel bug — we only expose a detection command so the
// dashboard can surface a clear, actionable hint to users rather than silently
// inheriting third-party noise.
// =============================================================================
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CiaoCheckResult {
/// Whether the bug is affecting the current installation
pub affected: bool,
/// Platform quick-check (non-Windows installations can never be affected)
pub platform: String,
/// Detected @homebridge/ciao version if the package is installed
pub version: Option<String>,
/// Absolute path to NetworkManager.js (when detected)
pub network_manager_path: Option<String>,
/// Human-readable detail for the UI
pub detail: String,
}
/// Resolve the openclaw CLI module root — directory containing the installed
/// package's `package.json`. Returns None when the CLI cannot be located.
/// Only compiled on Windows since ciao bug detection is Windows-only.
#[cfg(target_os = "windows")]
fn openclaw_module_root() -> Option<std::path::PathBuf> {
let cli = crate::utils::resolve_openclaw_cli_path()?;
let cli_path = std::path::PathBuf::from(&cli);
// The CLI entrypoint is typically `<module_root>/dist/entry.js` or
// similar. Walk up until we find a `package.json`, stopping at the
// nearest node_modules boundary.
let mut current = cli_path.parent()?.to_path_buf();
for _ in 0..6 {
if current.join("package.json").is_file() {
return Some(current);
}
current = current.parent()?.to_path_buf();
}
None
}
/// Check the `@homebridge/ciao` package bundled with openclaw. Only runs on
/// Windows since the bug does not manifest on other platforms.
#[tauri::command]
pub fn check_ciao_windowshide_bug() -> CiaoCheckResult {
let platform = std::env::consts::OS.to_string();
#[cfg(not(target_os = "windows"))]
{
CiaoCheckResult {
affected: false,
platform,
version: None,
network_manager_path: None,
detail: "Non-Windows platform — bug does not manifest here.".into(),
}
}
#[cfg(target_os = "windows")]
{
let Some(root) = openclaw_module_root() else {
return CiaoCheckResult {
affected: false,
platform,
version: None,
network_manager_path: None,
detail: "openclaw CLI not installed; nothing to check.".into(),
};
};
let ciao_dir = root.join("node_modules").join("@homebridge").join("ciao");
if !ciao_dir.is_dir() {
return CiaoCheckResult {
affected: false,
platform,
version: None,
network_manager_path: None,
detail: "@homebridge/ciao not found in openclaw dependencies.".into(),
};
}
// Read version for reporting only — we do not key off it to avoid
// lying to the user if someone backports the fix without bumping.
let version = std::fs::read_to_string(ciao_dir.join("package.json"))
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get("version").and_then(|s| s.as_str()).map(String::from));
let nm_path = ciao_dir.join("lib").join("NetworkManager.js");
if !nm_path.is_file() {
return CiaoCheckResult {
affected: false,
platform,
version,
network_manager_path: None,
detail: "NetworkManager.js not found; skipping scan.".into(),
};
}
let content = match std::fs::read_to_string(&nm_path) {
Ok(text) => text,
Err(err) => {
return CiaoCheckResult {
affected: false,
platform,
version,
network_manager_path: Some(nm_path.to_string_lossy().to_string()),
detail: format!("Unable to read NetworkManager.js: {err}"),
};
}
};
// Detection heuristic: look for the Windows ARP call and check whether
// the third argument is an options object or the callback. A fixed
// version uses exec("arp -a ...", { windowsHide: true }, callback).
// The buggy version uses exec("arp -a ...", (error, stdout) => ...).
let affected = content.lines().any(|line| {
let trimmed = line.trim_start();
trimmed.contains(".exec(\"arp -a")
&& !trimmed.contains("windowsHide")
&& !trimmed.contains("windows_hide")
});
let detail = if affected {
"Detected @homebridge/ciao without windowsHide option — cmd.exe will flash every 15-30s while Gateway runs. See upstream issues #64 / #65."
.into()
} else {
"No buggy @homebridge/ciao pattern detected.".into()
};
CiaoCheckResult {
affected,
platform,
version,
network_manager_path: Some(nm_path.to_string_lossy().to_string()),
detail,
}
}
}