feat: new pages + dashboard enhancements + backend improvements

New pages:
- Plugin Hub: grid cards, search, install/toggle/enable plugins
- Route Map: SVG visualization of channels→agents bindings with legends
- Diagnose: gateway connectivity diagnosis with step-by-step checks

Dashboard enhancements:
- WebSocket status indicator (connected/handshaking/reconnecting/disconnected)
- Connected channels overview with platform icons
- Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps
- Channels data loading in dashboard secondary fetch

Splash screen:
- Multi-stage boot detection (JS not loaded vs boot slow vs timeout)
- 15s: WebView2/resource load failure
- 20s: "initializing..." hint with elapsed counter
- 90s: true timeout error

Backend (Rust):
- diagnose.rs: gateway connectivity diagnosis command
- messaging.rs: plugin management commands
- service.rs: improvements
- lib.rs: register new commands

Frontend libs:
- feature-gates.js: feature flag system
- ws-client.js: reconnect state tracking
- tauri-api.js: new API bindings
- model-presets.js: provider fixes
- Remove gateway-guardian-policy.js (unused)

Dev API (scripts/dev-api.js):
- list_all_plugins, toggle_plugin, install_plugin handlers
- probe_gateway_port, diagnose_gateway_connection handlers

i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules
CSS: plugin-hub cards, route-map SVG styles
This commit is contained in:
晴天
2026-04-11 00:44:06 +08:00
parent c1fb674c44
commit 70d768be17
27 changed files with 2337 additions and 187 deletions

View File

@@ -88,8 +88,14 @@ mod hex {
}
/// 生成 Gateway connect 帧(含 Ed25519 签名)
/// gateway_token: token 模式认证凭据(可为空)
/// gateway_password: password 模式认证凭据(可为空,新增)
#[tauri::command]
pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Value, String> {
pub fn create_connect_frame(
nonce: String,
gateway_token: String,
gateway_password: Option<String>,
) -> Result<Value, String> {
let (device_id, pub_b64, signing_key) = get_or_create_key()?;
let signed_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -99,17 +105,34 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
let platform = std::env::consts::OS; // "windows" | "macos" | "linux"
let device_family = "desktop";
// v3 签名 payload 中 token 字段:优先 token其次 password最后空串
let auth_secret = if !gateway_token.is_empty() {
&gateway_token
} else {
gateway_password.as_deref().unwrap_or("")
};
let scopes_str = SCOPES.join(",");
// v3 格式v3|deviceId|clientId|clientMode|role|scopes|signedAt|token|nonce|platform|deviceFamily
// 使用 openclaw-control-ui + ui 模式,使 Gateway 识别为 Control UI 客户端,
// 本地连接时触发静默自动配对shouldAllowSilentLocalPairing = true
let payload_str = format!(
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{gateway_token}|{nonce}|{platform}|{device_family}"
"v3|{device_id}|openclaw-control-ui|ui|operator|{scopes_str}|{signed_at}|{auth_secret}|{nonce}|{platform}|{device_family}"
);
let signature = signing_key.sign(payload_str.as_bytes());
let sig_b64 = base64_url_encode(&signature.to_bytes());
// 构建 auth 对象:根据有无 token/password 选择填充字段
let password = gateway_password.unwrap_or_default();
let auth = if !gateway_token.is_empty() {
serde_json::json!({ "token": gateway_token })
} else if !password.is_empty() {
serde_json::json!({ "password": password })
} else {
serde_json::json!({})
};
let frame = serde_json::json!({
"type": "req",
"id": format!("connect-{:08x}-{:04x}", signed_at as u32, rand::random::<u16>()),
@@ -127,7 +150,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
"role": "operator",
"scopes": SCOPES,
"caps": ["tool-events"],
"auth": { "token": gateway_token },
"auth": auth,
"device": {
"id": device_id,
"publicKey": pub_b64,

View File

@@ -0,0 +1,290 @@
/// 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,
}
}

View File

@@ -2099,6 +2099,152 @@ pub async fn get_channel_plugin_status(plugin_id: String) -> Result<Value, Strin
}))
}
#[tauri::command]
pub async fn list_all_plugins() -> Result<Value, String> {
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let entries = cfg
.pointer("/plugins/entries")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let allow_arr = cfg
.pointer("/plugins/allow")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let ext_dir = super::openclaw_dir().join("extensions");
let mut plugins: Vec<Value> = Vec::new();
let mut seen = std::collections::HashSet::new();
// Scan extensions directory
if ext_dir.is_dir() {
if let Ok(rd) = std::fs::read_dir(&ext_dir) {
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') { continue; }
let p = entry.path();
if !p.is_dir() { continue; }
let has_marker = p.join("package.json").is_file()
|| p.join("plugin.ts").is_file()
|| p.join("index.js").is_file();
if !has_marker { continue; }
let plugin_id = name.clone();
seen.insert(plugin_id.clone());
let entry_cfg = entries.get(&plugin_id);
let enabled = entry_cfg
.and_then(|e| e.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(&plugin_id));
let builtin = is_plugin_builtin(&plugin_id);
// Try to read version from package.json
let version = std::fs::read_to_string(p.join("package.json"))
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
let description = std::fs::read_to_string(p.join("package.json"))
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| v.get("description").and_then(|v| v.as_str().map(String::from)));
plugins.push(json!({
"id": plugin_id,
"installed": true,
"builtin": builtin,
"enabled": enabled,
"allowed": allowed,
"version": version,
"description": description,
"config": entry_cfg.and_then(|e| e.get("config")),
}));
}
}
}
// Also include entries from config that might not be in extensions dir (built-in)
for (pid, entry_val) in &entries {
if seen.contains(pid.as_str()) { continue; }
seen.insert(pid.clone());
let enabled = entry_val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(pid.as_str()));
let builtin = is_plugin_builtin(pid);
plugins.push(json!({
"id": pid,
"installed": builtin,
"builtin": builtin,
"enabled": enabled,
"allowed": allowed,
"version": null,
"description": null,
"config": entry_val.get("config"),
}));
}
plugins.sort_by(|a, b| {
let ae = a.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
let be = b.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
be.cmp(&ae).then_with(|| {
let an = a.get("id").and_then(|v| v.as_str()).unwrap_or("");
let bn = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
an.cmp(bn)
})
});
Ok(json!({ "plugins": plugins }))
}
#[tauri::command]
pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, String> {
let plugin_id = plugin_id.trim();
if plugin_id.is_empty() {
return Err("plugin_id 不能为空".into());
}
let config_path = super::openclaw_dir().join("openclaw.json");
let mut cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
if enabled {
ensure_plugin_allowed(&mut cfg, plugin_id)?;
} else {
disable_legacy_plugin(&mut cfg, plugin_id);
}
let content = serde_json::to_string_pretty(&cfg).map_err(|e| format!("序列化失败: {e}"))?;
std::fs::write(&config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
Ok(json!({ "ok": true, "enabled": enabled, "pluginId": plugin_id }))
}
#[tauri::command]
pub async fn install_plugin(package_name: String) -> Result<Value, String> {
let package_name = package_name.trim().to_string();
if package_name.is_empty() {
return Err("包名不能为空".into());
}
let cli = crate::utils::resolve_openclaw_cli_path()
.ok_or_else(|| "找不到 OpenClaw CLI请先安装".to_string())?;
let output = std::process::Command::new(&cli)
.args(["plugins", "install", &package_name])
.current_dir(dirs::home_dir().unwrap_or_default())
.output()
.map_err(|e| format!("执行 openclaw plugins install 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("安装失败: {}{}", stdout, stderr));
}
Ok(json!({ "ok": true, "output": format!("{}{}", stdout, stderr).trim().to_string() }))
}
// ── Slack / Matrix / Discord 凭证校验 ─────────────────────
async fn verify_slack(

View File

@@ -17,6 +17,7 @@ pub mod agent;
pub mod assistant;
pub mod config;
pub mod device;
pub mod diagnose;
pub mod extensions;
pub mod logs;
pub mod memory;

View File

@@ -277,6 +277,85 @@ fn looks_like_gateway_config_mismatch(reason: &str) -> bool {
|| (has_newer_version && mentions_doctor_fix)
}
/// 直接修复 openclaw.json 中 plugins.entries.*.config 的多余属性
/// 当 `openclaw doctor --fix` 无法修复时作为二级回退
fn try_direct_config_strip() -> Result<bool, String> {
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
let raw = std::fs::read_to_string(&config_path)
.map_err(|e| format!("读取配置文件失败: {e}"))?;
let mut doc: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("解析配置文件失败: {e}"))?;
// 从错误日志中提取哪些 plugin entry 有 additional properties
let err_log = read_gateway_error_log_excerpt(8192).to_lowercase();
let mut changed = false;
// 匹配形如 "plugins.entries.XXX.config: invalid config" 的模式
if let Some(entries) = doc
.pointer_mut("/plugins/entries")
.and_then(|v| v.as_object_mut())
{
let entry_names: Vec<String> = entries.keys().cloned().collect();
for name in &entry_names {
let pattern = format!("plugins.entries.{}.config", name).to_lowercase();
if err_log.contains(&pattern) {
if let Some(entry) = entries.get_mut(name) {
if let Some(obj) = entry.as_object_mut() {
if obj.contains_key("config") {
guardian_log(&format!(
"直接修复: 清空 plugins.entries.{name}.config含多余属性"
));
obj.remove("config");
changed = true;
}
}
}
}
}
}
// 通用回退:如果错误日志提到 additional properties 但没匹配到具体 entry
// 清空所有 plugin entry 的 config
if !changed
&& (err_log.contains("additional properties") || err_log.contains("additional property"))
{
if let Some(entries) = doc
.pointer_mut("/plugins/entries")
.and_then(|v| v.as_object_mut())
{
let entry_names: Vec<String> = entries.keys().cloned().collect();
for name in &entry_names {
if let Some(entry) = entries.get_mut(name) {
if let Some(obj) = entry.as_object_mut() {
if obj.contains_key("config") {
let config = obj.get("config").unwrap();
if config.is_object()
&& config.as_object().map(|m| !m.is_empty()).unwrap_or(false)
{
guardian_log(&format!(
"直接修复(通用回退): 清空 plugins.entries.{name}.config"
));
obj.remove("config");
changed = true;
}
}
}
}
}
}
}
if changed {
let formatted = serde_json::to_string_pretty(&doc)
.map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(&config_path, formatted)
.map_err(|e| format!("写入配置文件失败: {e}"))?;
guardian_log("直接修复: 已写回 openclaw.json");
}
Ok(changed)
}
static GUARDIAN_STATE: OnceLock<Arc<Mutex<GuardianRuntimeState>>> = OnceLock::new();
static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false);
static GATEWAY_CONFIG_AUTO_FIX_STATE: OnceLock<Arc<Mutex<GatewayConfigAutoFixState>>> =
@@ -623,6 +702,47 @@ async fn start_service_impl_internal(
Ok(())
}
Err(retry_err) => {
// 二级回退doctor --fix 没解决问题,尝试直接修改 JSON
if looks_like_gateway_config_mismatch(&retry_err) {
guardian_log("doctor --fix 后仍失败,尝试直接修复 openclaw.json");
match try_direct_config_strip() {
Ok(true) => {
emit_guardian_event(
app,
"auto_fix_retry",
"已直接修复配置文件,正在再次重试启动 Gateway…",
);
#[cfg(target_os = "windows")]
{
platform::cleanup_zombie_gateway_processes();
}
tokio::time::sleep(Duration::from_millis(500)).await;
match start_service_impl_internal_once(label).await {
Ok(()) => {
emit_guardian_event(
app,
"auto_fix_success",
"已直接修复配置并成功启动 Gateway。",
);
return Ok(());
}
Err(e) => {
emit_guardian_event(
app,
"auto_fix_failure",
format!("直接修复后仍启动失败:{e}"),
);
}
}
}
Ok(false) => {
guardian_log("直接修复未找到可清理的配置项");
}
Err(e) => {
guardian_log(&format!("直接修复失败: {e}"));
}
}
}
emit_guardian_event(
app,
"auto_fix_failure",
@@ -631,7 +751,7 @@ async fn start_service_impl_internal(
),
);
Err(format!(
"{retry_err}\n(已自动执行 openclaw doctor --fix 并重试启动 Gateway"
"{retry_err}\n(已自动执行 openclaw doctor --fix + 直接修复并重试启动 Gateway"
))
}
}
@@ -1033,23 +1153,47 @@ mod platform {
/// 记录当前活跃的 Gateway 子进程(用于 stop 时精确 kill
static ACTIVE_GATEWAY_CHILD: Mutex<Option<u32>> = Mutex::new(None);
/// 清理残留的僵尸 Gateway 进程(启动时调用,防止 Windows 重启后多进程堆积
pub(crate) fn cleanup_zombie_gateway_processes() {
let port = crate::commands::gateway_listen_port();
/// 检查 Gateway 端口是否有响应(阻塞式 HTTP /health3s 超时
fn is_gateway_port_responsive(port: u16) -> bool {
use std::io::{Read, Write as IoWrite};
use std::net::TcpStream;
let addr = format!("127.0.0.1:{port}");
let mut stream = match TcpStream::connect_timeout(
&addr.parse().unwrap(),
Duration::from_secs(3),
) {
Ok(s) => s,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(3)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let req = format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\n\r\n");
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
let mut buf = [0u8; 256];
match stream.read(&mut buf) {
Ok(n) if n > 0 => {
let resp = String::from_utf8_lossy(&buf[..n]);
resp.contains("200") || resp.contains("OK")
}
_ => false,
}
}
// netstat 找到端口 18789 的所有监听进程 PID
/// netstat 输出中提取监听指定端口的所有 PID
fn find_listening_pids(port: u16) -> Vec<u32> {
let output = match StdCommand::new("netstat")
.args(["-ano", "-p", "TCP"])
.creation_flags(CREATE_NO_WINDOW)
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
Err(_) => return,
Err(_) => return vec![],
};
let mut pids = vec![];
for line in output.lines() {
let line = line.trim();
// 匹配 TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING <PID>
if !line.contains(&format!(":{port}")) || !line.contains("LISTENING") {
continue;
}
@@ -1057,28 +1201,52 @@ mod platform {
if parts.len() < 5 {
continue;
}
let pid_str = parts.last().unwrap();
let pid = match pid_str.parse::<u32>() {
Ok(p) => p,
Err(_) => continue,
};
if let Ok(pid) = parts.last().unwrap().parse::<u32>() {
if pid > 0 && !pids.contains(&pid) {
pids.push(pid);
}
}
}
pids
}
/// 清理残留的僵尸 Gateway 进程(启动时调用,防止 Windows 重启后多进程堆积)
/// 增强:检测端口占用但 /health 无响应的僵尸进程,强制杀掉
pub(crate) fn cleanup_zombie_gateway_processes() {
let port = crate::commands::gateway_listen_port();
let pids = find_listening_pids(port);
if pids.is_empty() {
return;
}
// 先检查 /health 是否有响应 —— 如果端口有进程但无响应,说明是僵尸
let responsive = is_gateway_port_responsive(port);
for pid in &pids {
let pid = *pid;
// 验证这个 PID 的命令行是否确实是 Gateway
if let Some(cmdline) = read_process_command_line(pid) {
let cmdline_lower = cmdline.to_lowercase();
// 只要包含 openclaw 且包含 gateway 就认为是 Gateway 进程
// 排除纯 node.exe可能是其他应用
if cmdline_lower.contains("openclaw") && cmdline_lower.contains("gateway") {
// 只杀我们自己的 PID不杀记录中的"已知好进程"
let our_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
if Some(pid) != our_pid {
let is_gateway = cmdline_lower.contains("openclaw") && cmdline_lower.contains("gateway");
let our_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
if is_gateway {
if !responsive {
// /health 无响应 → 僵尸进程,无条件杀掉(包括"已知好进程"
super::guardian_log(&format!(
"检测到僵尸 Gateway 进程 (PID {pid}):端口 {port} 占用但 /health 无响应,强制终止"
));
kill_process_tree(pid);
} else if Some(pid) != our_pid {
// /health 有响应但不是我们启动的 → 旧进程残留
super::guardian_log(&format!(
"清理残留 Gateway 进程 (PID {pid}):非当前实例"
));
kill_process_tree(pid);
}
}
} else {
// 读不到命令行时,不做假设,避免误杀其他进程
continue;
}
// 读不到命令行时,不做假设,避免误杀其他进程
}
}
@@ -1941,3 +2109,14 @@ pub async fn claim_gateway() -> Result<(), String> {
write_gateway_owner(pid)?;
Ok(())
}
/// 轻量 TCP 端口探测:检测 Gateway 端口是否可连通(用于 WS 连接前的就绪等待)
#[tauri::command]
pub async fn probe_gateway_port() -> bool {
let port = crate::commands::gateway_listen_port();
let addr = format!("127.0.0.1:{port}");
match tokio::net::TcpStream::connect(&addr).await {
Ok(_) => true,
Err(_) => false,
}
}

View File

@@ -4,8 +4,8 @@ mod tray;
mod utils;
use commands::{
agent, assistant, config, device, extensions, logs, memory, messaging, pairing, service,
skills, update,
agent, assistant, config, device, diagnose, extensions, logs, memory, messaging, pairing,
service, skills, update,
};
pub fn run() {
@@ -125,7 +125,10 @@ pub fn run() {
service::stop_service,
service::restart_service,
service::claim_gateway,
service::probe_gateway_port,
service::guardian_status,
// 诊断
diagnose::diagnose_gateway_connection,
// 日志
logs::read_log_tail,
logs::search_log,
@@ -183,6 +186,9 @@ pub fn run() {
messaging::repair_qqbot_channel_setup,
messaging::list_configured_platforms,
messaging::get_channel_plugin_status,
messaging::list_all_plugins,
messaging::toggle_plugin,
messaging::install_plugin,
messaging::install_channel_plugin,
messaging::install_qqbot_plugin,
messaging::run_channel_action,