mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-18 20:07:35 +08:00
Multi-OpenClaw Detection & Binding: - Add resolve_openclaw_cli_path() and classify_cli_source() in utils.rs - Support openclawCliPath binding in clawpanel.json (user selects CLI) - VersionInfo now includes cli_path, cli_source, all_installations - scan_all_installations() detects all OpenClaw installs on system - Dashboard shows CLI source label + multi-install warning - Settings page: CLI binding UI with auto-detect and manual selection - dev-api.js synced with cli_path/cli_source fields for Web mode i18n Infrastructure: - Create src/lib/i18n.js core module (t(), setLang(), initI18n()) - Create src/locales/zh-CN.json and src/locales/en.json - Sidebar fully i18n-ized (nav labels, sections, instance switcher) - Dashboard stat cards fully i18n-ized - Settings page: language switcher UI (live reload) - initI18n() called in main.js on startup
558 lines
22 KiB
Rust
558 lines
22 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));
|
||
}
|
||
// 扫描 nvm 实际安装的版本目录(兼容无 current 符号链接的情况)
|
||
let nvm_versions = home.join(".nvm/versions/node");
|
||
if nvm_versions.is_dir() {
|
||
if let Ok(entries) = std::fs::read_dir(&nvm_versions) {
|
||
for entry in entries.flatten() {
|
||
let bin = entry.path().join("bin");
|
||
if bin.is_dir() {
|
||
extra.push(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) {
|
||
for entry in entries.flatten() {
|
||
let bin = entry.path().join("installation/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 = "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));
|
||
}
|
||
// NVM_DIR 环境变量(用户可能自定义了 nvm 安装目录)
|
||
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) {
|
||
for entry in entries.flatten() {
|
||
let bin = entry.path().join("bin");
|
||
if bin.is_dir() {
|
||
extra.push(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) {
|
||
for entry in entries.flatten() {
|
||
let bin = entry.path().join("installation/bin");
|
||
if bin.is_dir() {
|
||
extra.push(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));
|
||
}
|
||
|
||
// 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(";")
|
||
}
|
||
}
|