feat: improve gateway compatibility and complete i18n cleanup

This commit is contained in:
晴天
2026-04-01 15:06:25 +08:00
parent 57b8b25946
commit b427a6b000
59 changed files with 6830 additions and 964 deletions

View File

@@ -1,10 +1,22 @@
/// Agent 管理命令 — 列表/改名直接读写 openclaw.json创建/删除走 CLI需要创建 workspace 等文件)
use crate::utils::openclaw_command_async;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
use std::fs;
use std::io::Write;
const AGENT_FILE_ALLOWLIST: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
];
/// Workspace 状态信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceStatus {
@@ -210,6 +222,200 @@ pub async fn list_agents() -> Result<Value, String> {
Ok(Value::Array(enriched))
}
#[tauri::command]
pub async fn get_agent_detail(id: String) -> Result<Value, String> {
let config_path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
let config: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let defaults = config
.get("agents")
.and_then(|a| a.get("defaults"))
.cloned()
.unwrap_or(Value::Null);
let bindings = config
.get("bindings")
.and_then(|b| b.as_array())
.cloned()
.unwrap_or_default();
let mut agent = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
.cloned()
})
.unwrap_or_else(|| json!({ "id": id.clone(), "default": id == "main" }));
let workspace = agent
.get("workspace")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| resolve_agent_workspace(&id, &config));
let agent_bindings: Vec<Value> = bindings
.into_iter()
.filter(|b| b.get("agentId").and_then(|v| v.as_str()).unwrap_or("main") == id)
.collect();
let is_default = agent
.get("default")
.and_then(|v| v.as_bool())
.unwrap_or(id == "main");
agent.as_object_mut().map(|obj| {
obj.insert("workspace".to_string(), Value::String(workspace));
obj.insert("bindings".to_string(), Value::Array(agent_bindings));
obj.insert("isDefault".to_string(), Value::Bool(is_default));
obj.insert("defaults".to_string(), defaults);
});
Ok(agent)
}
#[tauri::command]
pub async fn list_agent_files(id: String) -> Result<Value, String> {
let config = read_openclaw_config_value()?;
let agent_dir = resolve_agent_dir(&id, &config);
let files: Vec<Value> = AGENT_FILE_ALLOWLIST
.iter()
.map(|name| {
let path = agent_dir.join(name);
let meta = fs::metadata(&path).ok();
json!({
"name": name,
"desc": bootstrap_file_desc(name),
"exists": path.exists(),
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
"mtime": meta.and_then(|m| m.modified().ok()).and_then(|m| chrono::DateTime::<chrono::Utc>::from(m).to_rfc3339().into()),
"path": path.to_string_lossy().to_string(),
})
})
.collect();
Ok(Value::Array(files))
}
#[tauri::command]
pub async fn read_agent_file(id: String, name: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = read_openclaw_config_value()?;
let path = resolve_agent_dir(&id, &config).join(&name);
if !path.exists() {
return Ok(json!({ "exists": false, "content": "" }));
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取文件失败: {e}"))?;
Ok(json!({ "exists": true, "content": content }))
}
#[tauri::command]
pub async fn write_agent_file(id: String, name: String, content: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = read_openclaw_config_value()?;
let dir = resolve_agent_dir(&id, &config);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
fs::write(dir.join(&name), content).map_err(|e| format!("写入文件失败: {e}"))?;
Ok(json!({ "ok": true }))
}
#[tauri::command]
pub async fn update_agent_config(
app: tauri::AppHandle,
id: String,
config: Value,
) -> Result<Value, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let mut root: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
if root.get("agents").is_none() {
root.as_object_mut()
.ok_or("配置格式错误")?
.insert("agents".to_string(), json!({}));
}
if root["agents"].get("list").is_none() {
root["agents"]
.as_object_mut()
.ok_or("agents 格式错误")?
.insert("list".to_string(), json!([]));
}
let list = root["agents"]["list"]
.as_array_mut()
.ok_or("agents.list 格式错误")?;
let index = list
.iter()
.position(|agent| agent.get("id").and_then(|v| v.as_str()) == Some(id.as_str()));
let idx = match index {
Some(idx) => idx,
None if id == "main" => {
list.insert(0, json!({ "id": "main" }));
0
}
None => return Err(format!("Agent「{id}」不存在")),
};
let agent = list[idx].as_object_mut().ok_or("Agent 格式错误")?;
if let Some(identity) = config.get("identity").and_then(|v| v.as_object()) {
let identity_obj = agent.entry("identity".to_string()).or_insert_with(|| json!({}));
let identity_obj = identity_obj.as_object_mut().ok_or("identity 格式错误")?;
if let Some(name) = identity.get("name") {
if name.is_null() {
identity_obj.remove("name");
} else {
identity_obj.insert("name".to_string(), name.clone());
}
}
if let Some(emoji) = identity.get("emoji") {
if emoji.is_null() {
identity_obj.remove("emoji");
} else {
identity_obj.insert("emoji".to_string(), emoji.clone());
}
}
}
if let Some(model) = config.get("model") {
if model.is_null() {
agent.remove("model");
} else {
agent.insert("model".to_string(), model.clone());
}
}
if let Some(thinking) = config.get("thinkingDefault") {
if thinking.is_null() {
agent.remove("thinkingDefault");
} else {
agent.insert("thinkingDefault".to_string(), thinking.clone());
}
}
if let Some(skills) = config.get("skills") {
if skills.is_null() {
agent.remove("skills");
} else {
agent.insert("skills".to_string(), skills.clone());
}
}
if let Some(tools) = config.get("tools") {
if tools.is_null() {
agent.remove("tools");
} else {
agent.insert("tools".to_string(), tools.clone());
}
}
let json_text = serde_json::to_string_pretty(&root).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json_text).map_err(|e| format!("写入配置失败: {e}"))?;
let _ = super::config::do_reload_gateway(&app).await;
Ok(json!({ "ok": true }))
}
/// 创建新 agent优先走 CLI失败则直接写 openclaw.json 兜底)
#[tauri::command]
pub async fn add_agent(
@@ -596,3 +802,81 @@ pub async fn update_agent_model(
Ok("已更新".into())
}
fn read_openclaw_config_value() -> Result<Value, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))
}
fn resolve_agent_workspace(id: &str, config: &Value) -> String {
config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
.and_then(|a| a.get("workspace"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir()
.join("workspace")
.to_string_lossy()
.to_string()
} else {
super::openclaw_dir()
.join("agents")
.join(id)
.join("workspace")
.to_string_lossy()
.to_string()
}
})
}
fn resolve_agent_dir(id: &str, config: &Value) -> std::path::PathBuf {
let custom_dir = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
.and_then(|a| a.get("agentDir"))
.and_then(|v| v.as_str())
.map(|s| std::path::PathBuf::from(s))
});
custom_dir.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir()
} else {
super::openclaw_dir().join("agents").join(id)
}
})
}
fn ensure_allowed_agent_file(name: &str) -> Result<(), String> {
if AGENT_FILE_ALLOWLIST.contains(&name) {
Ok(())
} else {
Err("不允许访问此文件".to_string())
}
}
fn bootstrap_file_desc(name: &str) -> &'static str {
match name {
"AGENTS.md" => "Agent 规则",
"SOUL.md" => "灵魂/人格",
"TOOLS.md" => "工具白名单",
"IDENTITY.md" => "身份信息",
"USER.md" => "用户上下文",
"HEARTBEAT.md" => "心跳指令",
"BOOTSTRAP.md" => "初始化引导",
"MEMORY.md" => "记忆存储",
_ => "",
}
}

View File

@@ -380,8 +380,7 @@ fn pre_install_cleanup() {
// 2. 清理 npm 全局 bin 目录下的 openclaw 残留文件Windows EEXIST 根因)
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_bin = std::path::Path::new(&appdata).join("npm");
if let Some(npm_bin) = npm_global_bin_dir() {
for name in &["openclaw", "openclaw.cmd", "openclaw.ps1"] {
let p = npm_bin.join(name);
if p.exists() {
@@ -418,7 +417,7 @@ pub fn read_openclaw_config() -> Result<Value, String> {
v
}
Err(e) => {
// JSON 解析失败,尝试自动修复常见错误
// JSON 解析失败,尝试自动修复
let fixed_content = fix_common_json_errors(&content);
if let Ok(v) = serde_json::from_str(&fixed_content) {
eprintln!("自动修复了配置文件的 JSON 语法错误");
@@ -706,49 +705,47 @@ pub fn validate_openclaw_config() -> Result<Value, String> {
// 尝试解析 JSON
let config: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Ok(v) => {
// BOM 被剥离过,静默写回干净文件
if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
let _ = fs::write(&path, &content);
}
v
}
Err(e) => {
// JSON 解析失败,尝试自动修复
let fixed_content = fix_common_json_errors(&content);
match serde_json::from_str::<Value>(&fixed_content) {
Ok(_v) => {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 有语法错误,但已自动修复 (行: {}, 列: {})", e.line(), e.column()),
"auto_fixed": true,
"warnings": [
"配置文件存在 JSON 语法错误,已自动修复",
"建议:检查配置文件是否有尾随逗号或注释"
]
}));
}
Err(_) => {
// 自动修复失败,检查备份
let bak = super::openclaw_dir().join("openclaw.json.bak");
if bak.exists() {
if let Ok(bak_content) = fs::read_to_string(&bak) {
if serde_json::from_str::<Value>(&bak_content).is_ok() {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}), 建议从备份恢复", e.line(), e.column()),
"backup_exists": true,
"warnings": [
"配置文件损坏,建议使用备份恢复",
"备份文件openclaw.json.bak"
]
}));
}
if let Ok(v) = serde_json::from_str(&fixed_content) {
eprintln!("自动修复了配置文件的 JSON 语法错误");
// 写回修复后的配置
let _ = fs::write(&path, &fixed_content);
v
} else {
// 自动修复失败,尝试从备份恢复
let bak = super::openclaw_dir().join("openclaw.json.bak");
if bak.exists() {
if let Ok(bak_content) = fs::read_to_string(&bak) {
if serde_json::from_str::<Value>(&bak_content).is_ok() {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}), 建议从备份恢复", e.line(), e.column()),
"backup_exists": true,
"warnings": [
"配置文件损坏,建议使用备份恢复",
"备份文件openclaw.json.bak"
]
}));
}
}
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}): {}", e.line(), e.column(), e),
"warnings": [
"配置文件严重损坏且无有效备份",
"建议:手动检查或重新创建配置文件"
]
}));
}
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}): {}", e.line(), e.column(), e),
"warnings": [
"配置文件严重损坏且无有效备份",
"建议:手动检查或重新创建配置文件"
]
}));
}
}
};
@@ -872,7 +869,7 @@ pub fn validate_openclaw_config() -> Result<Value, String> {
}
/// 将 openclaw.json 的 models.providers 完整同步到每个 agent 的 models.json
/// 包括:同步 baseUrl/apiKey/api、删除已移除的 provider、删除已移除的 model
/// 包括:同步 baseUrl/apiKey/api + 清理已删除的 models
/// 确保 Gateway 运行时不会引用 openclaw.json 中已不存在的模型
fn sync_providers_to_agent_models(config: &Value) {
let src_providers = config
@@ -1236,8 +1233,7 @@ async fn get_local_version() -> Option<String> {
}
}
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_bin = PathBuf::from(&appdata).join("npm");
if let Some(npm_bin) = npm_global_bin_dir() {
let shim_path = npm_bin.join("openclaw.cmd");
// 仅当 npm 全局 CLI shim 存在时才读取版本
if !shim_path.exists() {
@@ -1282,32 +1278,8 @@ async fn get_local_version() -> Option<String> {
}
// 2. standalone 目录
for sa_dir in all_standalone_dirs() {
if !sa_dir.join("openclaw").exists() {
continue;
}
let version_file = sa_dir.join("VERSION");
if let Ok(content) = fs::read_to_string(&version_file) {
for line in content.lines() {
if let Some(ver) = line.strip_prefix("openclaw_version=") {
let ver = ver.trim();
if !ver.is_empty() {
return Some(ver.to_string());
}
}
}
}
let sa_pkg = sa_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh")
.join("package.json");
if let Ok(content) = fs::read_to_string(&sa_pkg) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
if sa_dir.join("openclaw").exists() || sa_dir.join("VERSION").exists() {
return Some("unknown".to_string());
}
}
// 3. symlink -> package.json
@@ -1453,8 +1425,8 @@ fn detect_installed_source() -> String {
}
}
// 无活跃 CLI 时的兜底:仅检查 npm 全局目录中实际存在的 shim
if let Ok(appdata) = std::env::var("APPDATA") {
let shim = PathBuf::from(&appdata).join("npm").join("openclaw.cmd");
if let Some(npm_bin) = npm_global_bin_dir() {
let shim = npm_bin.join("openclaw.cmd");
if let Some(s) = detect_source_from_cmd_shim(&shim) {
return s;
}
@@ -1575,6 +1547,31 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
})
}
fn scan_cli_identity(cli_path: &std::path::Path) -> String {
let mut identity_path = cli_path.to_path_buf();
#[cfg(target_os = "windows")]
{
let file_name = cli_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(file_name.as_str(), "openclaw" | "openclaw.exe" | "openclaw.ps1") {
let cmd_path = cli_path.with_file_name("openclaw.cmd");
if cmd_path.exists() {
identity_path = cmd_path;
}
}
}
identity_path
.canonicalize()
.unwrap_or(identity_path)
.to_string_lossy()
.to_lowercase()
}
/// 扫描系统中所有可检测到的 OpenClaw 安装
fn scan_all_installations(
active_path: &Option<String>,
@@ -1582,33 +1579,28 @@ fn scan_all_installations(
use crate::models::types::OpenClawInstallation;
let mut results: Vec<OpenClawInstallation> = Vec::new();
let mut seen = std::collections::HashSet::new();
let active_identity = active_path
.as_ref()
.map(|path| scan_cli_identity(std::path::Path::new(path)));
let mut try_add = |path: std::path::PathBuf| {
if !path.exists() {
return;
}
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.clone())
.to_string_lossy()
.to_string();
if seen.contains(&canonical) {
if crate::utils::is_rejected_cli_path(&path.to_string_lossy()) {
return;
}
seen.insert(canonical.clone());
let identity = scan_cli_identity(&path);
if seen.contains(&identity) {
return;
}
seen.insert(identity.clone());
let path_str = path.to_string_lossy().to_string();
let source = crate::utils::classify_cli_source(&path_str);
let version = read_version_from_installation(&path);
let is_active = active_path
let is_active = active_identity
.as_ref()
.map(|a| {
let a_canon = std::path::Path::new(a)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(a))
.to_string_lossy()
.to_string();
a_canon == canonical
})
.map(|active| active == &identity)
.unwrap_or(false);
results.push(OpenClawInstallation {
path: path_str,
@@ -1621,12 +1613,22 @@ fn scan_all_installations(
// standalone 安装目录
for sa_dir in all_standalone_dirs() {
#[cfg(target_os = "windows")]
try_add(sa_dir.join("openclaw.cmd"));
{
try_add(sa_dir.join("openclaw.cmd"));
try_add(sa_dir.join("openclaw.exe"));
}
#[cfg(not(target_os = "windows"))]
try_add(sa_dir.join("openclaw"));
{
try_add(sa_dir.join("openclaw"));
}
}
for configured in super::openclaw_search_paths() {
if let Some(resolved) = resolve_openclaw_cli_input_path(&configured) {
try_add(resolved);
}
}
// npm 全局目录
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
@@ -1635,10 +1637,106 @@ fn scan_all_installations(
.join("npm")
.join("openclaw.cmd"),
);
try_add(
std::path::PathBuf::from(&appdata)
.join("npm")
.join("openclaw"),
);
}
if let Some(prefix) = super::windows_npm_global_prefix() {
let prefix_path = std::path::PathBuf::from(prefix);
try_add(prefix_path.join("openclaw.cmd"));
try_add(prefix_path.join("openclaw.exe"));
try_add(prefix_path.join("openclaw"));
}
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
try_add(
std::path::PathBuf::from(&localappdata)
.join("Programs")
.join("nodejs")
.join("openclaw.cmd"),
);
}
if let Ok(program_files) = std::env::var("ProgramFiles") {
try_add(
std::path::PathBuf::from(&program_files)
.join("nodejs")
.join("openclaw.cmd"),
);
try_add(
std::path::PathBuf::from(&program_files)
.join("OpenClaw")
.join("openclaw.cmd"),
);
}
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
try_add(
std::path::PathBuf::from(&program_files_x86)
.join("nodejs")
.join("openclaw.cmd"),
);
}
if let Ok(profile) = std::env::var("USERPROFILE") {
try_add(
std::path::PathBuf::from(&profile)
.join(".openclaw-bin")
.join("openclaw.cmd"),
);
}
for drive in ["C", "D", "E", "F", "G"] {
try_add(std::path::PathBuf::from(format!(
r"{}:\OpenClaw\openclaw.cmd",
drive
)));
try_add(std::path::PathBuf::from(format!(
r"{}:\AI\OpenClaw\openclaw.cmd",
drive
)));
}
let mut where_cmd = Command::new("where");
where_cmd.arg("openclaw");
where_cmd.creation_flags(0x08000000);
if let Ok(output) = where_cmd.output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
try_add(std::path::PathBuf::from(trimmed));
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = dirs::home_dir() {
try_add(home.join(".npm-global").join("bin").join("openclaw"));
try_add(home.join(".local").join("bin").join("openclaw"));
try_add(home.join(".nvm").join("current").join("bin").join("openclaw"));
try_add(home.join(".volta").join("bin").join("openclaw"));
try_add(home.join(".fnm").join("current").join("bin").join("openclaw"));
try_add(home.join("bin").join("openclaw"));
}
try_add(std::path::PathBuf::from("/opt/openclaw/openclaw"));
try_add(std::path::PathBuf::from("/opt/homebrew/bin/openclaw"));
try_add(std::path::PathBuf::from("/usr/local/bin/openclaw"));
try_add(std::path::PathBuf::from("/usr/bin/openclaw"));
try_add(std::path::PathBuf::from("/snap/bin/openclaw"));
if let Ok(output) = Command::new("which").args(["-a", "openclaw"]).output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
try_add(std::path::PathBuf::from(trimmed));
}
}
}
}
// PATH 中找到的所有 openclaw
let enhanced = super::enhanced_path();
#[cfg(target_os = "windows")]
let sep = ';';
@@ -1660,9 +1758,119 @@ fn scan_all_installations(
}
}
results.sort_by(|a, b| {
b.active
.cmp(&a.active)
.then_with(|| a.source.cmp(&b.source))
.then_with(|| a.path.cmp(&b.path))
});
results
}
pub(crate) fn resolve_openclaw_cli_input_path(
cli_path: &std::path::Path,
) -> Option<std::path::PathBuf> {
if cli_path.as_os_str().is_empty() {
return None;
}
let input = cli_path.to_path_buf();
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if input.is_dir() {
#[cfg(target_os = "windows")]
{
candidates.push(input.join("openclaw.cmd"));
candidates.push(input.join("openclaw.exe"));
candidates.push(input.join("openclaw"));
}
#[cfg(not(target_os = "windows"))]
{
candidates.push(input.join("openclaw"));
}
} else {
candidates.push(input);
}
candidates.into_iter().find(|candidate| {
candidate.exists() && !crate::utils::is_rejected_cli_path(&candidate.to_string_lossy())
})
}
pub(crate) fn resolve_openclaw_cli_input(cli_path: &str) -> Option<std::path::PathBuf> {
let raw = cli_path.trim();
if raw.is_empty() {
return None;
}
resolve_openclaw_cli_input_path(std::path::Path::new(raw))
}
#[tauri::command]
pub fn scan_openclaw_paths() -> Result<Vec<crate::models::types::OpenClawInstallation>, String> {
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let active_path = crate::utils::resolve_openclaw_cli_path();
Ok(scan_all_installations(&active_path))
}
#[tauri::command]
pub fn check_openclaw_at_path(cli_path: String) -> Result<Value, String> {
let mut result = serde_json::Map::new();
if let Some(resolved) = resolve_openclaw_cli_input(&cli_path) {
let path_str = resolved.to_string_lossy().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("path".into(), Value::String(path_str.clone()));
result.insert(
"source".into(),
Value::String(crate::utils::classify_cli_source(&path_str)),
);
if let Some(version) = read_version_from_installation(&resolved) {
result.insert("version".into(), Value::String(version));
} else {
result.insert("version".into(), Value::Null);
}
} else {
result.insert("installed".into(), Value::Bool(false));
result.insert("path".into(), Value::Null);
result.insert("source".into(), Value::Null);
result.insert("version".into(), Value::Null);
}
Ok(Value::Object(result))
}
fn find_git_path() -> Option<String> {
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("where");
cmd.arg("git");
cmd.creation_flags(0x08000000);
if let Ok(output) = cmd.output() {
if output.status.success() {
if let Some(first_line) = String::from_utf8_lossy(&output.stdout).lines().next() {
let path = first_line.trim().to_string();
if !path.is_empty() && std::path::Path::new(&path).exists() {
return Some(path);
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(output) = Command::new("which").arg("git").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && std::path::Path::new(&path).exists() {
return Some(path);
}
}
}
}
None
}
/// 从安装路径附近读取版本信息
fn read_version_from_installation(cli_path: &std::path::Path) -> Option<String> {
// 尝试从同目录的 VERSION 文件读取
@@ -1864,9 +2072,13 @@ fn r2_platform_key() -> &'static str {
fn npm_global_modules_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
super::windows_npm_global_prefix()
.map(|prefix| PathBuf::from(prefix).join("node_modules"))
.or_else(|| {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
})
}
#[cfg(target_os = "macos")]
{
@@ -1902,9 +2114,9 @@ fn npm_global_modules_dir() -> Option<PathBuf> {
fn npm_global_bin_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm"))
super::windows_npm_global_prefix()
.map(PathBuf::from)
.or_else(|| std::env::var("APPDATA").ok().map(|a| PathBuf::from(a).join("npm")))
}
#[cfg(target_os = "macos")]
{
@@ -3334,6 +3546,9 @@ pub fn scan_node_paths() -> Result<Value, String> {
if !appdata.is_empty() {
candidates.push((format!(r"{}\npm", appdata), "NPM_GLOBAL".to_string()));
}
if let Some(prefix) = super::windows_npm_global_prefix() {
candidates.push((prefix, "NPM_GLOBAL".to_string()));
}
// 系统默认
candidates.push((format!(r"{}\nodejs", pf), "SYSTEM".to_string()));
@@ -3467,7 +3682,10 @@ fn is_nvm_active_version(nvm_dir: &str, version_dir: &std::path::Path) -> bool {
/// 保存用户自定义的 Node.js 路径到 ~/.openclaw/clawpanel.json
#[tauri::command]
pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
let config_path = super::openclaw_dir().join("clawpanel.json");
let config_path = super::panel_config_path();
if let Some(parent) = config_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut config: serde_json::Map<String, Value> = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
@@ -3496,7 +3714,10 @@ pub fn write_env_file(path: String, config: String) -> Result<(), String> {
// 安全限制:只允许写入 ~/.openclaw/ 目录下的文件
let openclaw_base = super::openclaw_dir();
if !expanded.starts_with(&openclaw_base) {
return Err("只允许写入 ~/.openclaw/ 目录下的文件".to_string());
return Err(format!(
"只允许写入 {} 目录下的文件",
openclaw_base.display()
));
}
if let Some(parent) = expanded.parent() {
@@ -4290,7 +4511,7 @@ pub fn get_openclaw_dir() -> Result<Value, String> {
let resolved = super::openclaw_dir();
let is_custom = super::read_panel_config_value()
.and_then(|v| v.get("openclawDir")?.as_str().map(String::from))
.map(|s| !s.is_empty())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let config_exists = resolved.join("openclaw.json").exists();
Ok(json!({
@@ -4380,6 +4601,7 @@ pub fn set_npm_registry(registry: String) -> Result<(), String> {
#[tauri::command]
pub fn check_git() -> Result<Value, String> {
let mut result = serde_json::Map::new();
let git_path = find_git_path();
let mut cmd = Command::new("git");
cmd.arg("--version");
#[cfg(target_os = "windows")]
@@ -4389,10 +4611,17 @@ pub fn check_git() -> Result<Value, String> {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
result.insert(
"path".into(),
git_path
.map(Value::String)
.unwrap_or(Value::Null),
);
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("path".into(), Value::Null);
}
}
Ok(Value::Object(result))

View File

@@ -120,6 +120,128 @@ fn put_csv_array_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str)
}
}
fn normalize_binding_match_value(value: &Value) -> Option<Value> {
match value {
Value::Null => None,
Value::String(s) => Some(Value::String(s.trim().to_string())),
Value::Array(items) => {
let mut normalized: Vec<Value> = items
.iter()
.filter_map(normalize_binding_match_value)
.collect();
if normalized.iter().all(|item| item.as_str().is_some()) {
normalized.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
}
Some(Value::Array(normalized))
}
Value::Object(map) => {
let mut result = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
let Some(item) = map.get(key) else {
continue;
};
if key == "peer" {
if let Some(peer_id) = item.as_str().map(str::trim).filter(|s| !s.is_empty()) {
result.insert("peer".into(), json!({ "kind": "direct", "id": peer_id }));
} else if let Some(peer_obj) = item.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
result.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
continue;
}
let Some(normalized) = normalize_binding_match_value(item) else {
continue;
};
if key == "accountId"
&& normalized.as_str().map(|s| s.is_empty()).unwrap_or(false)
{
continue;
}
if normalized.as_str().map(|s| s.is_empty()).unwrap_or(false) {
continue;
}
result.insert(key.clone(), normalized);
}
Some(Value::Object(result))
}
_ => Some(value.clone()),
}
}
fn build_binding_match(channel: &str, account_id: Option<&str>, binding_config: &Value) -> Value {
let mut match_config = Map::new();
match_config.insert("channel".into(), Value::String(channel.to_string()));
if let Some(acct) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("accountId".into(), Value::String(acct.to_string()));
}
if let Some(config_obj) = binding_config.as_object() {
for (k, v) in config_obj {
if k == "peer" {
if let Some(peer_str) = v.as_str().map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("peer".into(), json!({ "kind": "direct", "id": peer_str }));
} else if let Some(peer_obj) = v.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
match_config.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
} else if k != "accountId" && k != "channel" && !v.is_null() {
match_config.insert(k.clone(), v.clone());
}
}
}
normalize_binding_match_value(&Value::Object(match_config))
.unwrap_or_else(|| Value::Object(Map::new()))
}
fn binding_identity_matches(binding: &Value, agent_id: &str, target_match: &Value) -> bool {
let binding_agent = binding
.get("agentId")
.and_then(|v| v.as_str())
.unwrap_or("main");
if binding_agent != agent_id {
return false;
}
let existing_match = normalize_binding_match_value(binding.get("match").unwrap_or(&Value::Null))
.unwrap_or_else(|| Value::Object(Map::new()));
let expected_match = normalize_binding_match_value(target_match)
.unwrap_or_else(|| Value::Object(Map::new()));
existing_match == expected_match
}
fn gateway_auth_mode(cfg: &Value) -> Option<&str> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
@@ -3237,81 +3359,19 @@ pub async fn save_agent_binding(
serde_json::Value::String(agent_id.clone()),
);
// 构建 match 配置
let mut match_config = serde_json::Map::new();
match_config.insert(
"channel".to_string(),
serde_json::Value::String(channel.clone()),
);
if let Some(ref acct) = account_id {
if !acct.is_empty() {
match_config.insert(
"accountId".to_string(),
serde_json::Value::String(acct.clone()),
);
}
}
let target_match = build_binding_match(&channel, account_id.as_deref(), &binding_config);
// 合并用户提供的配置到 match 中
if let Some(config_obj) = binding_config.as_object() {
for (k, v) in config_obj {
if k == "peer" {
// peer 写入 match.peerOpenClaw schema 要求)
if let Some(peer_str) = v.as_str().filter(|s| !s.is_empty()) {
match_config.insert(
"peer".to_string(),
serde_json::json!({ "kind": "direct", "id": peer_str }),
);
} else if let Some(peer_obj) = v.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
if let Some(id_val) = id {
match_config.insert(
"peer".to_string(),
serde_json::json!({ "kind": kind, "id": id_val }),
);
}
}
} else if k == "accountId" || k == "channel" {
// 这两个已有专门逻辑处理,跳过
} else {
match_config.insert(k.clone(), v.clone());
}
}
}
new_binding.insert("match".to_string(), serde_json::Value::Object(match_config));
new_binding.insert("match".to_string(), target_match.clone());
// 先转换为 Value避免在循环中移动
let binding_value = serde_json::Value::Object(new_binding);
// 检查是否已存在相同 agentId + channel + accountId 的绑定,如有则更新
let mut found = false;
for binding in bindings_arr.iter_mut() {
if let (Some(existing_agent), Some(existing_channel), Some(existing_match)) = (
binding.get("agentId").and_then(|v| v.as_str()),
binding
.get("match")
.and_then(|m| m.get("channel"))
.and_then(|v| v.as_str()),
binding.get("match"),
) {
if existing_agent == agent_id && existing_channel == channel {
// 检查 accountId 是否匹配
let existing_account = existing_match.get("accountId").and_then(|v| v.as_str());
if existing_account == account_id.as_deref() {
*binding = binding_value.clone();
found = true;
break;
}
}
if binding_identity_matches(binding, &agent_id, &target_match) {
*binding = binding_value.clone();
found = true;
break;
}
}
@@ -3343,62 +3403,22 @@ pub async fn delete_agent_binding(
agent_id: String,
channel: String,
account_id: Option<String>,
binding_config: Option<serde_json::Value>,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let target_match = build_binding_match(
&channel,
account_id.as_deref(),
binding_config.as_ref().unwrap_or(&Value::Null),
);
let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) else {
return Ok(serde_json::json!({ "ok": true }));
};
let original_len = bindings.len();
bindings.retain(|b| {
// 检查是否是该 agent 的绑定
if b.get("agentId")
.and_then(|v| v.as_str())
.map(|id| id != agent_id)
.unwrap_or(true)
{
return true; // 保留非该 agent 的绑定
}
// 检查 channel 是否匹配
let match_obj = match b.get("match").and_then(|m| m.as_object()) {
Some(m) => m,
None => return true, // 保留无效格式
};
let binding_channel = match_obj.get("channel").and_then(|v| v.as_str());
if binding_channel != Some(&channel) {
return true; // 保留不匹配 channel 的绑定
}
let binding_acct = match_obj
.get("accountId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
match account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(acct) => {
if binding_acct != Some(acct) {
return true;
}
}
None => {
// 未指定 account只删默认绑定无 accountId 或空)
if binding_acct.is_some() {
return true;
}
}
}
false // 删除这个绑定
});
bindings.retain(|b| !binding_identity_matches(b, &agent_id, &target_match));
let removed = original_len - bindings.len();
if removed == 0 {

View File

@@ -1,5 +1,9 @@
use std::net::IpAddr;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use std::process::Command;
use std::sync::RwLock;
use std::time::Duration;
@@ -27,20 +31,55 @@ fn default_openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
fn normalize_custom_openclaw_dir(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let expanded = if let Some(rest) = trimmed
.strip_prefix("~/")
.or_else(|| trimmed.strip_prefix("~\\"))
{
dirs::home_dir().unwrap_or_default().join(rest)
} else {
PathBuf::from(trimmed)
};
if expanded.is_absolute() {
Some(expanded)
} else {
std::env::current_dir().ok().map(|cwd| cwd.join(expanded))
}
}
pub fn openclaw_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let Some(value) = read_panel_config_value() else {
return paths;
};
let Some(entries) = value.get("openclawSearchPaths").and_then(|v| v.as_array()) else {
return paths;
};
for raw in entries.iter().filter_map(|v| v.as_str()) {
if let Some(path) = normalize_custom_openclaw_dir(raw) {
if !paths.iter().any(|p| p == &path) {
paths.push(path);
}
}
}
paths
}
/// 获取 OpenClaw 配置目录
/// 优先使用 clawpanel.json 中的 openclawDir 自定义路径,不存在则回退默认 ~/.openclaw
pub fn openclaw_dir() -> PathBuf {
// 直接读 clawpanel.json始终在默认目录下避免循环依赖
let config_path = default_openclaw_dir().join("clawpanel.json");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(custom) = v.get("openclawDir").and_then(|d| d.as_str()) {
let p = PathBuf::from(custom);
if !custom.is_empty() && p.exists() {
return p;
}
}
}
if let Some(custom) = read_panel_config_value()
.and_then(|v| v.get("openclawDir")?.as_str().map(String::from))
.and_then(|v| normalize_custom_openclaw_dir(&v))
{
return custom;
}
default_openclaw_dir()
}
@@ -85,6 +124,31 @@ fn panel_config_path() -> PathBuf {
default_openclaw_dir().join("clawpanel.json")
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_npm_global_prefix() -> Option<String> {
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
let trimmed = prefix.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut cmd = Command::new("cmd");
cmd.args(["/d", "/s", "/c", "npm config get prefix"]);
cmd.creation_flags(CREATE_NO_WINDOW);
if let Ok(output) = cmd.output() {
if output.status.success() {
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() && prefix.to_lowercase() != "undefined" {
return Some(prefix);
}
}
}
None
}
pub fn read_panel_config_value() -> Option<serde_json::Value> {
std::fs::read_to_string(panel_config_path())
.ok()
@@ -231,16 +295,8 @@ fn build_enhanced_path() -> String {
let home = dirs::home_dir().unwrap_or_default();
// 读取用户保存的自定义 Node.js 路径
let custom_path = openclaw_dir()
.join("clawpanel.json")
.exists()
.then(|| {
std::fs::read_to_string(openclaw_dir().join("clawpanel.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("nodePath")?.as_str().map(String::from))
})
.flatten();
let custom_path = read_panel_config_value()
.and_then(|v| v.get("nodePath")?.as_str().map(String::from));
#[cfg(target_os = "macos")]
{
@@ -254,6 +310,18 @@ fn build_enhanced_path() -> String {
"/usr/local/bin".into(),
"/opt/homebrew/bin".into(),
];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
extra.push(format!("{}/bin", prefix));
@@ -326,6 +394,18 @@ fn build_enhanced_path() -> String {
"/usr/bin".into(),
"/snap/bin".into(),
];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
extra.push(format!("{}/bin", prefix));
@@ -411,6 +491,19 @@ fn build_enhanced_path() -> String {
// 版本管理器路径优先,确保 nvm/volta/fnm 管理的 Node.js 被优先检测到
let mut extra: Vec<String> = vec![];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// 1. NVM_SYMLINKnvm-windows 活跃版本符号链接,如 D:\nodejs—— 最高优先级
// 增强:尝试解析符号链接目标
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
@@ -546,6 +639,15 @@ fn build_enhanced_path() -> String {
if !appdata.is_empty() {
extra.push(format!(r"{}\npm", appdata));
}
if let Some(prefix) = windows_npm_global_prefix() {
let prefix_path = std::path::Path::new(&prefix);
if prefix_path.is_dir() {
let prefix_str = prefix_path.to_string_lossy().to_string();
if !extra.contains(&prefix_str) {
extra.push(prefix_str);
}
}
}
// 6.5 standalone 安装目录(集中管理,避免多处硬编码)
// standalone 安装后通过注册表写入用户 PATH但当前进程的 PATH 环境变量不会

View File

@@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::models::types::ServiceStatus;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tauri::Emitter;
/// OpenClaw 官方服务的友好名称映射
@@ -57,6 +57,161 @@ struct GuardianEventPayload {
message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GatewayOwnerRecord {
pid: Option<u32>,
port: u16,
cli_path: Option<String>,
openclaw_dir: String,
started_at: String,
started_by: String,
}
fn normalize_owned_path(path: impl AsRef<std::path::Path>) -> String {
let path_ref = path.as_ref();
path_ref
.canonicalize()
.unwrap_or_else(|_| path_ref.to_path_buf())
.to_string_lossy()
.to_string()
}
fn gateway_owner_path() -> std::path::PathBuf {
crate::commands::openclaw_dir().join("gateway-owner.json")
}
fn current_gateway_owner_signature() -> (u16, String, Option<String>) {
let openclaw_dir = normalize_owned_path(crate::commands::openclaw_dir());
let cli_path = crate::utils::resolve_openclaw_cli_path()
.map(|p| normalize_owned_path(std::path::PathBuf::from(p)));
(crate::commands::gateway_listen_port(), openclaw_dir, cli_path)
}
fn read_gateway_owner() -> Option<GatewayOwnerRecord> {
let content = std::fs::read_to_string(gateway_owner_path()).ok()?;
serde_json::from_str(&content).ok()
}
fn write_gateway_owner(pid: Option<u32>) -> Result<(), String> {
let owner_path = gateway_owner_path();
if let Some(parent) = owner_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Gateway owner 目录失败: {e}"))?;
}
let (port, openclaw_dir, cli_path) = current_gateway_owner_signature();
let record = GatewayOwnerRecord {
pid,
port,
cli_path,
openclaw_dir,
started_at: chrono::Local::now().to_rfc3339(),
started_by: "clawpanel".into(),
};
let content = serde_json::to_string_pretty(&record)
.map_err(|e| format!("序列化 Gateway owner 失败: {e}"))?;
std::fs::write(owner_path, content).map_err(|e| format!("写入 Gateway owner 失败: {e}"))
}
fn clear_gateway_owner() {
let _ = std::fs::remove_file(gateway_owner_path());
}
fn is_current_gateway_owner(owner: &GatewayOwnerRecord, pid: Option<u32>) -> bool {
if owner.started_by != "clawpanel" {
return false;
}
let (port, openclaw_dir, cli_path) = current_gateway_owner_signature();
if owner.port != port {
return false;
}
if normalize_owned_path(&owner.openclaw_dir) != openclaw_dir {
return false;
}
let owner_cli_path = owner.cli_path.as_ref().map(normalize_owned_path);
match (owner_cli_path.as_deref(), cli_path.as_deref()) {
(Some(owner_cli), Some(current_cli)) if owner_cli == current_cli => {}
_ => return false,
}
if let (Some(owner_pid), Some(current_pid)) = (owner.pid, pid) {
if owner_pid != current_pid {
return false;
}
}
true
}
fn is_gateway_owned_by_current_instance(pid: Option<u32>) -> bool {
read_gateway_owner()
.as_ref()
.map(|owner| is_current_gateway_owner(owner, pid))
.unwrap_or(false)
}
fn foreign_gateway_error(pid: Option<u32>) -> String {
let pid_suffix = pid
.map(|value| format!(" (PID: {value})"))
.unwrap_or_default();
format!(
"检测到端口 {} 上已有其他 OpenClaw Gateway 正在运行{},且不属于当前面板实例。为避免误接管,请先关闭该实例,或将当前 CLI/目录绑定到它对应的安装。",
crate::commands::gateway_listen_port(),
pid_suffix
)
}
fn ensure_owned_gateway_or_err(pid: Option<u32>) -> Result<(), String> {
if is_gateway_owned_by_current_instance(pid) {
Ok(())
} else {
Err(foreign_gateway_error(pid))
}
}
async fn current_gateway_runtime(label: &str) -> (bool, Option<u32>) {
#[cfg(target_os = "windows")]
{
platform::check_service_status(0, label)
}
#[cfg(target_os = "macos")]
{
platform::check_service_status(0, label)
}
#[cfg(target_os = "linux")]
{
platform::check_service_status(0, label).await
}
}
async fn wait_for_gateway_running(label: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let (running, pid) = current_gateway_runtime(label).await;
if running {
write_gateway_owner(pid)?;
return Ok(());
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Err(format!(
"Gateway 启动超时,请查看 {}",
crate::commands::openclaw_dir()
.join("logs")
.join("gateway.err.log")
.display()
))
}
async fn wait_for_gateway_stopped(label: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let (running, _) = current_gateway_runtime(label).await;
if !running {
clear_gateway_owner();
return Ok(());
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Err("Gateway 停止超时,请手动检查进程".into())
}
static GUARDIAN_STATE: OnceLock<Arc<Mutex<GuardianRuntimeState>>> = OnceLock::new();
static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false);
@@ -262,34 +417,30 @@ async fn guardian_tick(app: &tauri::AppHandle) {
async fn start_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::start_service_impl(label)
platform::start_service_impl(label)?;
}
#[cfg(not(target_os = "macos"))]
{
platform::start_service_impl(label).await
platform::start_service_impl(label).await?;
}
wait_for_gateway_running(label, Duration::from_secs(15)).await
}
async fn stop_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::stop_service_impl(label)
platform::stop_service_impl(label)?;
}
#[cfg(not(target_os = "macos"))]
{
platform::stop_service_impl(label).await
platform::stop_service_impl(label).await?;
}
wait_for_gateway_stopped(label, Duration::from_secs(10)).await
}
async fn restart_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::restart_service_impl(label)
}
#[cfg(not(target_os = "macos"))]
{
platform::restart_service_impl(label).await
}
stop_service_impl_internal(label).await?;
start_service_impl_internal(label).await
}
pub fn start_backend_guardian(app: tauri::AppHandle) {
@@ -427,8 +578,6 @@ mod platform {
}
}
let enhanced = crate::commands::enhanced_path();
let log_dir = crate::commands::openclaw_dir().join("logs");
fs::create_dir_all(&log_dir).ok();
@@ -444,13 +593,11 @@ mod platform {
.open(log_dir.join("gateway.err.log"))
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
let mut cmd = Command::new("openclaw");
let mut cmd = crate::utils::openclaw_command();
cmd.arg("gateway")
.env("PATH", &enhanced)
.stdin(std::process::Stdio::null())
.stdout(stdout_log)
.stderr(stderr_log);
crate::commands::apply_proxy_env(&mut cmd);
cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。".to_string()
@@ -478,7 +625,10 @@ mod platform {
}
}
Err("Gateway 启动超时,请查看 ~/.openclaw/logs/gateway.err.log".into())
Err(format!(
"Gateway 启动超时,请查看 {}",
log_dir.join("gateway.err.log").display()
))
}
pub fn start_service_impl(label: &str) -> Result<(), String> {
@@ -568,6 +718,7 @@ mod platform {
Ok(())
}
#[allow(dead_code)]
pub fn restart_service_impl(label: &str) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(label);
@@ -892,6 +1043,12 @@ mod platform {
}
fn check_cli_installed_inner() -> bool {
if let Some(path) = crate::utils::resolve_openclaw_cli_path() {
if Path::new(&path).exists() {
return true;
}
}
// 方式1: 检查常见文件路径(零进程,最快)
for path in candidate_cli_paths() {
if path.exists() {
@@ -1020,17 +1177,14 @@ mod platform {
));
}
let enhanced = crate::commands::enhanced_path();
let (stdout_log, stderr_log) = create_gateway_log_files()?;
let mut cmd = std::process::Command::new("cmd");
cmd.args(["/c", "openclaw", "gateway"])
.env("PATH", &enhanced)
let mut cmd = crate::utils::openclaw_command();
cmd.arg("gateway")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(stdout_log)
.stderr(stderr_log);
crate::commands::apply_proxy_env(&mut cmd);
// 记录 spawn 前的已知 PID
let before_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
@@ -1143,6 +1297,7 @@ mod platform {
}
}
#[allow(dead_code)]
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
stop_service_impl(_label).await?;
start_service_impl(_label).await
@@ -1403,13 +1558,20 @@ mod platform {
}
}
Err("Gateway 启动超时,请查看 ~/.openclaw/logs/gateway.err.log".into())
Err(format!(
"Gateway 启动超时,请查看 {}",
crate::commands::openclaw_dir()
.join("logs")
.join("gateway.err.log")
.display()
))
}
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
gateway_command("stop").await
}
#[allow(dead_code)]
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
gateway_command("restart").await
}
@@ -1449,17 +1611,23 @@ pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
let mut results = Vec::new();
for label in labels.iter().map(String::as_str) {
// Windows 使用 platform::check_service_status含真实 PID 检测)
#[cfg(target_os = "windows")]
let (running, pid) = platform::check_service_status(_uid, label);
#[cfg(not(target_os = "windows"))]
let (running, pid) = check_tcp_service_status(_uid, label);
let (running, pid) = current_gateway_runtime(label).await;
let owned_by_current_instance = running && is_gateway_owned_by_current_instance(pid);
let ownership = if !running {
Some("stopped".to_string())
} else if owned_by_current_instance {
Some("owned".to_string())
} else {
Some("foreign".to_string())
};
results.push(ServiceStatus {
label: label.to_string(),
pid,
running,
description: desc_map.get(label).unwrap_or(&"").to_string(),
cli_installed,
ownership,
owned_by_current_instance: Some(owned_by_current_instance),
});
}
@@ -1468,18 +1636,33 @@ pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
#[tauri::command]
pub async fn start_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
write_gateway_owner(pid)?;
guardian_mark_manual_start();
return Ok(());
}
guardian_mark_manual_start();
start_service_impl_internal(&label).await
}
#[tauri::command]
pub async fn stop_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
}
guardian_mark_manual_stop();
stop_service_impl_internal(&label).await
}
#[tauri::command]
pub async fn restart_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
}
guardian_pause("manual restart");
guardian_mark_manual_start();
let result = restart_service_impl_internal(&label).await;