Files
clawpanel/src-tauri/src/commands/mod.rs
SEVENTEEN-TAN 87d7c227d8 fix: 重构版本源检测逻辑 + standalone 目录集中化 + Linux 平台检测补全 (#161)
* 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>
2026-03-30 22:50:11 +08:00

596 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 符号链接的情况)
// 按版本号倒序排列,确保最新版优先(修复 #143v20 排在 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(&current);
}
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 安装目录)
// 按版本号倒序排列,确保最新版优先(修复 #143v20 排在 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(&current);
}
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_SYMLINKnvm-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%\nvmnvm-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(&current_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(&current);
}
parts.join(";")
}
}