mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 18:10:41 +08:00
* fix: Windows 版本检测错误——优先从活跃 CLI 读取版本,修复源判断和包检查顺序 - get_local_version() Windows 块新增活跃 CLI 优先检测(与 macOS 一致), 避免残留的 standalone 汉化版目录被优先扫描到 - detect_installed_source() 修复 standalone 被错误映射为 official(应为 chinese) - read_version_from_installation() 根据 classify_cli_source 动态决定 package.json 检查顺序,避免硬编码汉化版优先导致官方版用户看到错误版本 🤖 Generated with [Qoder][https://qoder.com] * fix: Windows 版本检测忽略残留文件,仅当 CLI 二进制存在时才读取版本 standalone 目录和 npm 全局目录中可能存在卸载后的残留 node_modules/ package.json,导致面板误判 OpenClaw 已安装并显示错误版本号。 现在在读取版本前先检查 openclaw.cmd 是否实际存在。 🤖 Generated with [Qoder][https://qoder.com] * fix: 重构版本源检测逻辑,修复跨源切换后显示旧源的问题 - 新增 detect_source_from_cmd_shim() 通过读取 Windows .cmd shim 内容判断 npm 包归属,替代不可靠的文件系统残留目录扫描 - 重写 detect_installed_source() Windows 检测块,优先使用 shim 内容信号 - upgrade_openclaw_inner() 跨源切换时清理 standalone 安装目录 - get_local_version() npm 段改用 shim 内容判断活跃包 - build_enhanced_path() 三平台添加 standalone 安装目录,避免 dashboard 超时 - 所有平台 fallback 从 "official" 改为 "unknown",支持非面板安装场景 - 前端 dashboard/about 支持 official/chinese/unknown 三源显示 - 新增 unknownSource i18n key(中/英/繁/日/韩) 🤖 Generated with [Qoder][https://qoder.com] * fix: 移除 config.rs 末尾多余的闭合括号,修复编译错误 🤖 Generated with [Qoder][https://qoder.com] * fix: 移除 config.rs 末尾重复的 configure_git_https 和 invalidate_path_cache 定义 🤖 Generated with [Qoder][https://qoder.com] * refactor: standalone 目录集中化、unsafe set_var 适配、Linux 平台检测补全 - 提升 all_standalone_dirs() / standalone_install_dir() 为 pub(crate), 作为全局唯一的 standalone 路径来源 - build_enhanced_path() 三平台块改为调用 config::all_standalone_dirs() - service.rs 三平台 candidate 函数改为调用 super::config::all_standalone_dirs() - utils.rs common_non_windows_cli_candidates() 改为调用集中函数 - std::env::set_var 包裹 unsafe 块,附 SAFETY 注释,适配 Rust 1.83+ - 补全 Linux detect_installed_source(): CLI 路径分类 -> symlink -> standalone -> npm list - 补全 Linux get_local_version(): 活跃 CLI -> standalone VERSION -> symlink package.json 🤖 Generated with [Qoder][https://qoder.com] * fix: service.rs 模块路径修正 super::config -> crate::commands::config mod platform 内部 super 指向 service 模块而非 commands, 需要完整路径 crate::commands::config 才能访问 all_standalone_dirs() 🤖 Generated with [Qoder][https://qoder.com] --------- Co-authored-by: SEVENTEEN-TAN <SEVENTEEN-TAN@users.noreply.github.com>
596 lines
24 KiB
Rust
596 lines
24 KiB
Rust
use std::net::IpAddr;
|
||
use std::path::PathBuf;
|
||
use std::sync::RwLock;
|
||
use std::time::Duration;
|
||
|
||
/// 缓存 gateway 端口,避免频繁读文件(5秒有效期)
|
||
static GATEWAY_PORT_CACHE: std::sync::LazyLock<std::sync::Mutex<(u16, std::time::Instant)>> =
|
||
std::sync::LazyLock::new(|| {
|
||
std::sync::Mutex::new((18789, std::time::Instant::now() - Duration::from_secs(60)))
|
||
});
|
||
|
||
pub mod agent;
|
||
pub mod assistant;
|
||
pub mod config;
|
||
pub mod device;
|
||
pub mod extensions;
|
||
pub mod logs;
|
||
pub mod memory;
|
||
pub mod messaging;
|
||
pub mod pairing;
|
||
pub mod service;
|
||
pub mod skills;
|
||
pub mod update;
|
||
|
||
/// 默认 OpenClaw 配置目录(ClawPanel 自身配置始终在此)
|
||
fn default_openclaw_dir() -> PathBuf {
|
||
dirs::home_dir().unwrap_or_default().join(".openclaw")
|
||
}
|
||
|
||
/// 获取 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
default_openclaw_dir()
|
||
}
|
||
|
||
/// Gateway 监听端口:读取 `openclaw.json` 的 `gateway.port`,缺省 **18789**。
|
||
/// 与面板「Gateway 配置」、服务状态检测(netstat / TCP / launchctl 兜底)共用同一来源,
|
||
/// 并尊重 `clawpanel.json` 中的 `openclawDir` 自定义配置目录。
|
||
pub fn gateway_listen_port() -> u16 {
|
||
// 5秒内返回缓存值,避免服务状态检测时频繁读文件
|
||
if let Ok(cache) = GATEWAY_PORT_CACHE.lock() {
|
||
if cache.1.elapsed() < Duration::from_secs(5) {
|
||
return cache.0;
|
||
}
|
||
}
|
||
let port = read_gateway_port_from_config();
|
||
if let Ok(mut cache) = GATEWAY_PORT_CACHE.lock() {
|
||
*cache = (port, std::time::Instant::now());
|
||
}
|
||
port
|
||
}
|
||
|
||
fn read_gateway_port_from_config() -> u16 {
|
||
let config_path = openclaw_dir().join("openclaw.json");
|
||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
if let Some(port) = val
|
||
.get("gateway")
|
||
.and_then(|g| g.get("port"))
|
||
.and_then(|p| p.as_u64())
|
||
{
|
||
if port > 0 && port < 65536 {
|
||
return port as u16;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
18789
|
||
}
|
||
|
||
fn panel_config_path() -> PathBuf {
|
||
// ClawPanel 自身配置始终在默认目录,不随 openclawDir 变化
|
||
default_openclaw_dir().join("clawpanel.json")
|
||
}
|
||
|
||
pub fn read_panel_config_value() -> Option<serde_json::Value> {
|
||
std::fs::read_to_string(panel_config_path())
|
||
.ok()
|
||
.and_then(|content| serde_json::from_str(&content).ok())
|
||
}
|
||
|
||
pub fn configured_proxy_url() -> Option<String> {
|
||
let value = read_panel_config_value()?;
|
||
let raw = value
|
||
.get("networkProxy")
|
||
.and_then(|entry| {
|
||
if let Some(obj) = entry.as_object() {
|
||
obj.get("url").and_then(|v| v.as_str())
|
||
} else {
|
||
entry.as_str()
|
||
}
|
||
})?
|
||
.trim()
|
||
.to_string();
|
||
if raw.is_empty() {
|
||
None
|
||
} else {
|
||
Some(raw)
|
||
}
|
||
}
|
||
|
||
fn should_bypass_proxy_host(host: &str) -> bool {
|
||
let lower = host.trim().to_ascii_lowercase();
|
||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||
return true;
|
||
}
|
||
if let Ok(ip) = lower.parse::<IpAddr>() {
|
||
return match ip {
|
||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
|
||
IpAddr::V6(v6) => {
|
||
v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local()
|
||
}
|
||
};
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 构建 HTTP 客户端,use_proxy=true 时走用户配置的代理
|
||
pub fn build_http_client(
|
||
timeout: Duration,
|
||
user_agent: Option<&str>,
|
||
) -> Result<reqwest::Client, String> {
|
||
build_http_client_opt(timeout, user_agent, true)
|
||
}
|
||
|
||
/// 构建模型请求用的 HTTP 客户端
|
||
/// 默认不走代理;用户在面板设置中开启 proxyModelRequests 后才走代理
|
||
pub fn build_http_client_no_proxy(
|
||
timeout: Duration,
|
||
user_agent: Option<&str>,
|
||
) -> Result<reqwest::Client, String> {
|
||
let use_proxy = read_panel_config_value()
|
||
.and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool())
|
||
.unwrap_or(false);
|
||
build_http_client_opt(timeout, user_agent, use_proxy)
|
||
}
|
||
|
||
fn build_http_client_opt(
|
||
timeout: Duration,
|
||
user_agent: Option<&str>,
|
||
use_proxy: bool,
|
||
) -> Result<reqwest::Client, String> {
|
||
let mut builder = reqwest::Client::builder().timeout(timeout).gzip(true);
|
||
if let Some(ua) = user_agent {
|
||
builder = builder.user_agent(ua);
|
||
}
|
||
if use_proxy {
|
||
if let Some(proxy_url) = configured_proxy_url() {
|
||
let proxy_value = proxy_url.clone();
|
||
builder = builder.proxy(reqwest::Proxy::custom(move |url| {
|
||
let host = url.host_str().unwrap_or("");
|
||
if should_bypass_proxy_host(host) {
|
||
None
|
||
} else {
|
||
Some(proxy_value.clone())
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
builder.build().map_err(|e| e.to_string())
|
||
}
|
||
|
||
pub fn apply_proxy_env(cmd: &mut std::process::Command) {
|
||
if let Some(proxy_url) = configured_proxy_url() {
|
||
cmd.env("HTTP_PROXY", &proxy_url)
|
||
.env("HTTPS_PROXY", &proxy_url)
|
||
.env("http_proxy", &proxy_url)
|
||
.env("https_proxy", &proxy_url)
|
||
.env("NO_PROXY", "localhost,127.0.0.1,::1")
|
||
.env("no_proxy", "localhost,127.0.0.1,::1");
|
||
}
|
||
}
|
||
|
||
pub fn apply_proxy_env_tokio(cmd: &mut tokio::process::Command) {
|
||
if let Some(proxy_url) = configured_proxy_url() {
|
||
cmd.env("HTTP_PROXY", &proxy_url)
|
||
.env("HTTPS_PROXY", &proxy_url)
|
||
.env("http_proxy", &proxy_url)
|
||
.env("https_proxy", &proxy_url)
|
||
.env("NO_PROXY", "localhost,127.0.0.1,::1")
|
||
.env("no_proxy", "localhost,127.0.0.1,::1");
|
||
}
|
||
}
|
||
|
||
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
|
||
/// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存
|
||
static ENHANCED_PATH_CACHE: RwLock<Option<String>> = RwLock::new(None);
|
||
|
||
/// Tauri 应用启动时 PATH 可能不完整:
|
||
/// - macOS 从 Finder 启动时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
|
||
/// - Windows 上安装 Node.js 到非默认路径、或安装后未重启进程
|
||
///
|
||
/// 补充 Node.js / npm 常见安装路径
|
||
pub fn enhanced_path() -> String {
|
||
// 先尝试读缓存
|
||
if let Ok(guard) = ENHANCED_PATH_CACHE.read() {
|
||
if let Some(ref cached) = *guard {
|
||
return cached.clone();
|
||
}
|
||
}
|
||
// 缓存为空,重新构建
|
||
let path = build_enhanced_path();
|
||
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
|
||
*guard = Some(path.clone());
|
||
}
|
||
path
|
||
}
|
||
|
||
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效(无需重启应用)
|
||
pub fn refresh_enhanced_path() {
|
||
let new_path = build_enhanced_path();
|
||
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
|
||
*guard = Some(new_path);
|
||
}
|
||
}
|
||
|
||
fn build_enhanced_path() -> String {
|
||
let current = std::env::var("PATH").unwrap_or_default();
|
||
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();
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
// 版本管理器路径优先于系统路径,确保 nvm/volta/fnm 管理的 Node.js 版本被优先检测到
|
||
let mut extra: Vec<String> = vec![
|
||
format!("{}/.nvm/current/bin", home.display()),
|
||
format!("{}/.volta/bin", home.display()),
|
||
format!("{}/.nodenv/shims", home.display()),
|
||
format!("{}/n/bin", home.display()),
|
||
format!("{}/.npm-global/bin", home.display()),
|
||
"/usr/local/bin".into(),
|
||
"/opt/homebrew/bin".into(),
|
||
];
|
||
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
|
||
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
|
||
extra.push(format!("{}/bin", prefix));
|
||
}
|
||
// standalone 安装目录(集中管理,避免多处硬编码)
|
||
for sa_dir in config::all_standalone_dirs() {
|
||
extra.push(sa_dir.to_string_lossy().into_owned());
|
||
}
|
||
// 扫描 nvm 实际安装的版本目录(兼容无 current 符号链接的情况)
|
||
// 按版本号倒序排列,确保最新版优先(修复 #143:v20 排在 v24 前面)
|
||
let nvm_versions = home.join(".nvm/versions/node");
|
||
if nvm_versions.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(&nvm_versions) {
|
||
let mut dirs: Vec<_> = entries
|
||
.flatten()
|
||
.filter(|e| e.path().join("bin").is_dir())
|
||
.collect();
|
||
dirs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||
for entry in dirs {
|
||
extra.push(entry.path().join("bin").to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
// fnm: 扫描 $FNM_DIR 或默认 ~/.local/share/fnm 下的版本目录
|
||
let fnm_dir = std::env::var("FNM_DIR")
|
||
.ok()
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| home.join(".local/share/fnm"));
|
||
let fnm_versions = fnm_dir.join("node-versions");
|
||
if fnm_versions.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(&fnm_versions) {
|
||
let mut dirs: Vec<_> = entries
|
||
.flatten()
|
||
.filter(|e| e.path().join("installation/bin").is_dir())
|
||
.collect();
|
||
dirs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||
for entry in dirs {
|
||
extra.push(
|
||
entry
|
||
.path()
|
||
.join("installation/bin")
|
||
.to_string_lossy()
|
||
.to_string(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let mut parts: Vec<&str> = vec![];
|
||
if let Some(ref cp) = custom_path {
|
||
parts.push(cp.as_str());
|
||
}
|
||
parts.extend(extra.iter().map(|s| s.as_str()));
|
||
if !current.is_empty() {
|
||
parts.push(¤t);
|
||
}
|
||
parts.join(":")
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// 版本管理器路径优先于系统路径,确保 nvm/volta/fnm 管理的 Node.js 版本被优先检测到
|
||
let mut extra: Vec<String> = vec![
|
||
format!("{}/.nvm/current/bin", home.display()),
|
||
format!("{}/.volta/bin", home.display()),
|
||
format!("{}/.nodenv/shims", home.display()),
|
||
format!("{}/n/bin", home.display()),
|
||
format!("{}/.npm-global/bin", home.display()),
|
||
format!("{}/.local/bin", home.display()),
|
||
"/usr/local/bin".into(),
|
||
"/usr/bin".into(),
|
||
"/snap/bin".into(),
|
||
];
|
||
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
|
||
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
|
||
extra.push(format!("{}/bin", prefix));
|
||
}
|
||
// standalone 安装目录(集中管理,避免多处硬编码)
|
||
for sa_dir in config::all_standalone_dirs() {
|
||
extra.push(sa_dir.to_string_lossy().into_owned());
|
||
}
|
||
// NVM_DIR 环境变量(用户可能自定义了 nvm 安装目录)
|
||
// 按版本号倒序排列,确保最新版优先(修复 #143:v20 排在 v24 前面)
|
||
let nvm_dir = std::env::var("NVM_DIR")
|
||
.ok()
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| home.join(".nvm"));
|
||
let nvm_versions = nvm_dir.join("versions/node");
|
||
if nvm_versions.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(&nvm_versions) {
|
||
let mut dirs: Vec<_> = entries
|
||
.flatten()
|
||
.filter(|e| e.path().join("bin").is_dir())
|
||
.collect();
|
||
dirs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||
for entry in dirs {
|
||
extra.push(entry.path().join("bin").to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
// fnm: 扫描 $FNM_DIR 或默认 ~/.local/share/fnm 下的版本目录
|
||
let fnm_dir = std::env::var("FNM_DIR")
|
||
.ok()
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| home.join(".local/share/fnm"));
|
||
let fnm_versions = fnm_dir.join("node-versions");
|
||
if fnm_versions.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(&fnm_versions) {
|
||
let mut dirs: Vec<_> = entries
|
||
.flatten()
|
||
.filter(|e| e.path().join("installation/bin").is_dir())
|
||
.collect();
|
||
dirs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||
for entry in dirs {
|
||
extra.push(
|
||
entry
|
||
.path()
|
||
.join("installation/bin")
|
||
.to_string_lossy()
|
||
.to_string(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// nodesource / 手动安装的 Node.js 可能在 /usr/local/lib/nodejs/ 下
|
||
let nodejs_lib = std::path::Path::new("/usr/local/lib/nodejs");
|
||
if nodejs_lib.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(nodejs_lib) {
|
||
for entry in entries.flatten() {
|
||
let bin = entry.path().join("bin");
|
||
if bin.is_dir() {
|
||
extra.push(bin.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut parts: Vec<&str> = vec![];
|
||
if let Some(ref cp) = custom_path {
|
||
parts.push(cp.as_str());
|
||
}
|
||
parts.extend(extra.iter().map(|s| s.as_str()));
|
||
if !current.is_empty() {
|
||
parts.push(¤t);
|
||
}
|
||
parts.join(":")
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
|
||
let pf86 =
|
||
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
|
||
let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||
let appdata = std::env::var("APPDATA").unwrap_or_default();
|
||
|
||
// 版本管理器路径优先,确保 nvm/volta/fnm 管理的 Node.js 被优先检测到
|
||
let mut extra: Vec<String> = vec![];
|
||
|
||
// 1. NVM_SYMLINK(nvm-windows 活跃版本符号链接,如 D:\nodejs)—— 最高优先级
|
||
// 增强:尝试解析符号链接目标
|
||
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
|
||
let symlink_path = std::path::Path::new(&nvm_symlink);
|
||
if symlink_path.is_dir() {
|
||
extra.push(nvm_symlink.clone());
|
||
}
|
||
// 如果是符号链接,尝试读取其实际指向的目标
|
||
#[cfg(target_os = "windows")]
|
||
if symlink_path.is_symlink() {
|
||
if let Ok(target) = std::fs::read_link(symlink_path) {
|
||
if target.is_dir() {
|
||
extra.push(target.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. NVM_HOME(用户自定义 nvm 安装目录)
|
||
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
|
||
let nvm_path = std::path::Path::new(&nvm_home);
|
||
if nvm_path.is_dir() {
|
||
// 扫描所有已安装的版本目录
|
||
if let Ok(entries) = std::fs::read_dir(nvm_path) {
|
||
for entry in entries.flatten() {
|
||
let p = entry.path();
|
||
if p.is_dir() && p.join("node.exe").exists() {
|
||
extra.push(p.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
// 尝试从 settings.json 读取当前激活版本
|
||
let settings_path = nvm_path.join("settings.json");
|
||
if settings_path.exists() {
|
||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
// settings.json 中有 "path" 字段指向当前版本
|
||
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
|
||
{
|
||
let version_path = nvm_path.join(current_version);
|
||
if version_path.is_dir() {
|
||
// 将当前激活版本移到更高优先级
|
||
let version_bin = version_path.to_string_lossy().to_string();
|
||
if !extra.contains(&version_bin) {
|
||
extra.insert(0, version_bin);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. %APPDATA%\nvm(nvm-windows 默认安装目录)
|
||
if !appdata.is_empty() {
|
||
let nvm_dir = std::path::Path::new(&appdata).join("nvm");
|
||
if nvm_dir.is_dir() {
|
||
// 扫描所有已安装的版本
|
||
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
|
||
for entry in entries.flatten() {
|
||
let p = entry.path();
|
||
if p.is_dir() && p.join("node.exe").exists() {
|
||
extra.push(p.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
}
|
||
// 尝试从 settings.json 读取当前激活版本
|
||
let settings_path = nvm_dir.join("settings.json");
|
||
if settings_path.exists() {
|
||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
|
||
{
|
||
let version_path = nvm_dir.join(current_version);
|
||
if version_path.is_dir() {
|
||
let version_bin = version_path.to_string_lossy().to_string();
|
||
if !extra.contains(&version_bin) {
|
||
extra.insert(0, version_bin);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. volta
|
||
extra.push(format!(r"{}\.volta\bin", home.display()));
|
||
// volta 的活跃版本
|
||
let volta_bin = std::path::Path::new(&home).join(".volta/bin");
|
||
if volta_bin.is_dir() && !extra.contains(&volta_bin.to_string_lossy().to_string()) {
|
||
extra.insert(0, volta_bin.to_string_lossy().to_string());
|
||
}
|
||
|
||
// 5. fnm
|
||
if !localappdata.is_empty() {
|
||
extra.push(format!(r"{}\fnm_multishells", localappdata));
|
||
}
|
||
let fnm_base = std::env::var("FNM_DIR")
|
||
.ok()
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(&appdata).join("fnm"));
|
||
let fnm_versions = fnm_base.join("node-versions");
|
||
if fnm_versions.is_dir() {
|
||
// 尝试找到 fnm 的当前活跃版本
|
||
let fnm_current = fnm_base.join("current");
|
||
if fnm_current.is_dir() {
|
||
let current_inst = fnm_current.join("installation");
|
||
if current_inst.is_dir()
|
||
&& current_inst.join("node.exe").exists()
|
||
&& !extra.contains(¤t_inst.to_string_lossy().to_string())
|
||
{
|
||
extra.insert(0, current_inst.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
// 扫描所有版本
|
||
if let Ok(entries) = std::fs::read_dir(&fnm_versions) {
|
||
for entry in entries.flatten() {
|
||
let inst = entry.path().join("installation");
|
||
if inst.is_dir() && inst.join("node.exe").exists() {
|
||
let inst_str = inst.to_string_lossy().to_string();
|
||
if !extra.contains(&inst_str) {
|
||
extra.push(inst_str);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. npm 全局(openclaw.cmd 通常在这里)
|
||
if !appdata.is_empty() {
|
||
extra.push(format!(r"{}\npm", appdata));
|
||
}
|
||
|
||
// 6.5 standalone 安装目录(集中管理,避免多处硬编码)
|
||
// standalone 安装后通过注册表写入用户 PATH,但当前进程的 PATH 环境变量不会
|
||
// 实时更新,需要显式添加到 enhanced_path 以确保 resolve_openclaw_cli_path()
|
||
// 能找到 standalone 安装的 openclaw.cmd
|
||
for sa_dir in config::all_standalone_dirs() {
|
||
extra.push(sa_dir.to_string_lossy().into_owned());
|
||
}
|
||
|
||
// 7. 系统默认 Node.js 安装路径(优先级最低)
|
||
extra.push(format!(r"{}\nodejs", pf));
|
||
extra.push(format!(r"{}\nodejs", pf86));
|
||
if !localappdata.is_empty() {
|
||
extra.push(format!(r"{}\Programs\nodejs", localappdata));
|
||
}
|
||
|
||
// 8. 扫描常见盘符下的 Node 安装(用户可能装在 D:\、F:\ 等)
|
||
for drive in &["C", "D", "E", "F"] {
|
||
extra.push(format!(r"{}:\nodejs", drive));
|
||
extra.push(format!(r"{}:\Node", drive));
|
||
extra.push(format!(r"{}:\Program Files\nodejs", drive));
|
||
// 常见 AI/Dev 工具目录
|
||
extra.push(format!(r"{}:\AI\Node", drive));
|
||
extra.push(format!(r"{}:\AI\nodejs", drive));
|
||
extra.push(format!(r"{}:\Dev\nodejs", drive));
|
||
extra.push(format!(r"{}:\Tools\nodejs", drive));
|
||
}
|
||
|
||
let mut parts: Vec<&str> = vec![];
|
||
// 用户自定义路径优先级最高
|
||
if let Some(ref cp) = custom_path {
|
||
parts.push(cp.as_str());
|
||
}
|
||
// 然后是默认扫描到的路径(去重)
|
||
let mut seen = std::collections::HashSet::new();
|
||
for p in &extra {
|
||
if std::path::Path::new(p).exists() && seen.insert(p.clone()) {
|
||
parts.push(p.as_str());
|
||
}
|
||
}
|
||
// 最后是系统 PATH
|
||
if !current.is_empty() {
|
||
parts.push(¤t);
|
||
}
|
||
parts.join(";")
|
||
}
|
||
}
|