mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-08 00:59:57 +08:00
feat: improve gateway compatibility and complete i18n cleanup
This commit is contained in:
@@ -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" => "记忆存储",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.peer(OpenClaw 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 {
|
||||
|
||||
@@ -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_SYMLINK(nvm-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 环境变量不会
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user