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>
This commit is contained in:
SEVENTEEN-TAN
2026-03-30 22:50:11 +08:00
committed by GitHub
parent 61bfd56865
commit 87d7c227d8
8 changed files with 253 additions and 81 deletions

View File

@@ -192,7 +192,7 @@ fn standalone_archive_ext() -> &'static str {
}
/// standalone 安装目录
fn standalone_install_dir() -> Option<PathBuf> {
pub(crate) fn standalone_install_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
// Inno Setup PrivilegesRequired=lowest 默认安装到 %LOCALAPPDATA%\Programs
@@ -207,7 +207,7 @@ fn standalone_install_dir() -> Option<PathBuf> {
}
/// 所有可能的 standalone 安装位置(用于检测和卸载)
fn all_standalone_dirs() -> Vec<PathBuf> {
pub(crate) fn all_standalone_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
@@ -1194,7 +1194,22 @@ async fn get_local_version() -> Option<String> {
#[cfg(target_os = "windows")]
{
// 优先从活跃 CLI 路径读取版本(与 macOS 逻辑一致)
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
let cli_pb = PathBuf::from(&cli_path);
let resolved = std::fs::canonicalize(&cli_pb).unwrap_or_else(|_| cli_pb.clone());
if let Some(ver) = read_version_from_installation(&resolved)
.or_else(|| read_version_from_installation(&cli_pb))
{
return Some(ver);
}
}
for sa_dir in all_standalone_dirs() {
// 仅当 CLI 二进制实际存在时才读取版本,避免残留文件误判为已安装
if !sa_dir.join("openclaw.cmd").exists() {
continue;
}
let version_file = sa_dir.join("VERSION");
if let Ok(content) = fs::read_to_string(&version_file) {
for line in content.lines() {
@@ -1222,21 +1237,87 @@ async fn get_local_version() -> Option<String> {
}
if let Ok(appdata) = std::env::var("APPDATA") {
let cli_is_zh = crate::utils::resolve_openclaw_cli_path()
.map(|p| crate::utils::classify_cli_source(&p) == "npm-zh")
.unwrap_or(false);
let pkgs: &[&str] = if cli_is_zh {
&["@qingchencloud/openclaw-zh", "openclaw"]
let npm_bin = PathBuf::from(&appdata).join("npm");
let shim_path = npm_bin.join("openclaw.cmd");
// 仅当 npm 全局 CLI shim 存在时才读取版本
if !shim_path.exists() {
// npm 全局无 CLI shim跳过
} else {
&["openclaw", "@qingchencloud/openclaw-zh"]
};
for pkg in pkgs {
let pkg_json = PathBuf::from(&appdata)
.join("npm")
.join("node_modules")
.join(pkg)
.join("package.json");
if let Ok(content) = fs::read_to_string(&pkg_json) {
// 读 .cmd 内容判断活跃包,而非依赖 classify_cli_source路径无法区分
let is_zh = detect_source_from_cmd_shim(&shim_path)
.map(|s| s == "chinese")
.unwrap_or(false);
let pkgs: &[&str] = if is_zh {
&["@qingchencloud/openclaw-zh", "openclaw"]
} else {
&["openclaw", "@qingchencloud/openclaw-zh"]
};
for pkg in pkgs {
let pkg_json = npm_bin.join("node_modules").join(pkg).join("package.json");
if let Ok(content) = fs::read_to_string(&pkg_json) {
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);
}
}
}
}
}
}
// Linux: 参照 macOS/Windows 实现,完整检测链
#[cfg(target_os = "linux")]
{
// 1. 活跃 CLI 优先
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
let cli_pb = PathBuf::from(&cli_path);
let resolved = std::fs::canonicalize(&cli_pb).unwrap_or_else(|_| cli_pb.clone());
if let Some(ver) = read_version_from_installation(&resolved)
.or_else(|| read_version_from_installation(&cli_pb))
{
return Some(ver);
}
}
// 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);
}
}
}
// 3. symlink -> package.json
if let Ok(target) = fs::read_link("/usr/local/bin/openclaw") {
let pkg_json = PathBuf::from("/usr/local/bin")
.join(&target)
.parent()
.map(|p| p.join("package.json"));
if let Some(ref pkg_path) = pkg_json {
if let Ok(content) = fs::read_to_string(pkg_path) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
@@ -1298,9 +1379,27 @@ async fn get_latest_version_for(source: &str) -> Option<String> {
.map(String::from)
}
/// 从 Windows .cmd shim 文件内容判断实际关联的 npm 包来源
/// npm 生成的 shim 末尾引用实际 JS 入口,据此区分官方版与汉化版
#[cfg(target_os = "windows")]
fn detect_source_from_cmd_shim(cmd_path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(cmd_path).ok()?;
let lower = content.to_lowercase();
// 汉化版标记:@qingchencloud 或 openclaw-zh
if lower.contains("openclaw-zh") || lower.contains("@qingchencloud") {
return Some("chinese".into());
}
// 确认是 npm shim含 node_modules 引用)→ 官方版
if lower.contains("node_modules") {
return Some("official".into());
}
// standalone 的 .cmd 可能不含 node_modules自定义脚本由 classify 处理
None
}
/// 检测当前安装的是官方版还是汉化版
/// macOS: 优先检查 homebrew symlinkfallback 到 npm list
/// Windows: 优先检查 npm 全局目录下的 package.json避免调用 npm list 阻塞
/// macOS: 优先检查 symlink 指向的实际路径
/// Windows: 读取 .cmd shim 内容判断实际关联的包
/// Linux: 直接用 npm list
fn detect_installed_source() -> String {
// macOS: 检查 openclaw bin 的 symlink 指向
@@ -1332,49 +1431,73 @@ fn detect_installed_source() -> String {
return "chinese".into();
}
}
"official".into()
"unknown".into()
}
// Windows: 优先通过 CLI 路径判断fallback 到文件系统检测
// Windows: 通过活跃 CLI 的 .cmd shim 内容判断来源
// npm 生成的 .cmd shim 最后一行包含实际 JS 入口路径,例如:
// "%dp0%\node_modules\openclaw\bin\openclaw.js" → 官方版
// "%dp0%\node_modules\@qingchencloud\openclaw-zh\..." → 汉化版
// 读取内容即可一锤定音,不依赖文件系统扫描(避免残留目录误判)
#[cfg(target_os = "windows")]
{
// 优先通过实际 CLI 路径判断
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
let source = crate::utils::classify_cli_source(&cli_path);
if source == "npm-zh" {
// 路径本身能确定的情况standalone 目录、npm-zh 路径含 openclaw-zh
if source == "npm-zh" || source == "standalone" {
return "chinese".into();
}
if source == "standalone" {
return "official".into();
}
// npm-official/npm-global: Windows .cmd shim 路径不含包名,需继续检查文件系统
}
// 检查所有可能的 standalone 安装目录
for sa_dir in all_standalone_dirs() {
let sa_zh = sa_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh");
if sa_zh.exists() {
return "chinese".into();
// npm-official / npm-global / unknown: 路径不含包名,读 .cmd 内容判断
if let Some(shim_source) = detect_source_from_cmd_shim(std::path::Path::new(&cli_path))
{
return shim_source;
}
}
// 检查 npm 全局目录
if let Some(appdata) = std::env::var_os("APPDATA") {
let zh_dir = PathBuf::from(&appdata)
.join("npm")
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh");
if zh_dir.exists() {
return "chinese".into();
// 无活跃 CLI 时的兜底:仅检查 npm 全局目录中实际存在的 shim
if let Ok(appdata) = std::env::var("APPDATA") {
let shim = PathBuf::from(&appdata).join("npm").join("openclaw.cmd");
if let Some(s) = detect_source_from_cmd_shim(&shim) {
return s;
}
}
// 默认返回官方版
"official".into()
// 确实无法判断
"unknown".into()
}
// 所有平台通用: npm list 检测
// Linux: 参照 macOS 实现,完整检测
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
// 1. 活跃 CLI 路径分类(与 macOS 一致)
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
let resolved = std::fs::canonicalize(&cli_path)
.ok()
.unwrap_or_else(|| PathBuf::from(&cli_path));
let source = crate::utils::classify_cli_source(&resolved.to_string_lossy());
if source == "npm-zh" || source == "standalone" {
return "chinese".into();
}
if source == "npm-official" || source == "npm-global" {
return "official".into();
}
}
// 2. 检查 symlink 指向(/usr/local/bin/openclaw, ~/bin/openclaw
let home = dirs::home_dir().unwrap_or_default();
for link in &[
PathBuf::from("/usr/local/bin/openclaw"),
home.join("bin").join("openclaw"),
] {
if let Ok(target) = std::fs::read_link(link) {
if target.to_string_lossy().contains("openclaw-zh") {
return "chinese".into();
}
return "official".into();
}
}
// 3. standalone 目录检测
for sa_dir in all_standalone_dirs() {
if sa_dir.join("openclaw").exists() || sa_dir.join("VERSION").exists() {
return "chinese".into();
}
}
// 4. npm list 兜底
if let Ok(o) = npm_command()
.args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"])
.output()
@@ -1383,7 +1506,7 @@ fn detect_installed_source() -> String {
return "chinese".into();
}
}
"official".into()
"unknown".into()
}
}
@@ -1391,14 +1514,23 @@ fn detect_installed_source() -> String {
pub async fn get_version_info() -> Result<VersionInfo, String> {
let current = get_local_version().await;
let mut source = detect_installed_source();
// 兜底:版本号含 -zh 则一定是汉化版(文件系统检测可能误判)
// 兜底:版本号含 -zh 则一定是汉化版
if let Some(ref ver) = current {
if ver.contains("-zh") && source != "chinese" {
source = "chinese".to_string();
}
}
let latest = get_latest_version_for(&source).await;
let recommended = recommended_version_for(&source);
// unknown 来源不查询 latest/recommended无法确定对应哪个 npm 包)
let latest = if source == "unknown" {
None
} else {
get_latest_version_for(&source).await
};
let recommended = if source == "unknown" {
None
} else {
recommended_version_for(&source)
};
let update_available = match (&current, &recommended) {
(Some(c), Some(r)) => recommended_is_newer(r, c),
(None, Some(_)) => true,
@@ -1546,8 +1678,16 @@ fn read_version_from_installation(cli_path: &std::path::Path) -> Option<String>
}
}
}
// 根据 CLI 路径判断来源,决定 package.json 检查顺序
// 避免残留的另一来源包被优先读取
let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy());
let pkg_names: &[&str] = if cli_source == "npm-zh" || cli_source == "standalone" {
&["@qingchencloud/openclaw-zh", "openclaw"]
} else {
&["openclaw", "@qingchencloud/openclaw-zh"]
};
// 尝试从 package.json 读取
for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] {
for pkg_name in pkg_names {
let pkg_json = dir.join("node_modules").join(pkg_name).join("package.json");
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Some(ver) = serde_json::from_str::<serde_json::Value>(&content)
@@ -1560,7 +1700,7 @@ fn read_version_from_installation(cli_path: &std::path::Path) -> Option<String>
}
// npm shim 情况:向上查找 node_modules
if let Some(parent) = dir.parent() {
for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] {
for pkg_name in pkg_names {
let pkg_json = parent
.join("node_modules")
.join(pkg_name)
@@ -2010,6 +2150,14 @@ async fn try_standalone_install(
])
.creation_flags(0x08000000)
.status();
// 同步更新当前进程的 PATH 环境变量,使后续 resolve_openclaw_cli_path()
// 和 build_enhanced_path() 能立即发现 standalone 安装的 CLI
// 无需重启应用(注册表写入仅对新进程生效)
// SAFETY: 在 Tauri 命令处理器中单次调用,此时无其他线程并发读写 PATH。
// enhanced_path 使用独立的 RwLock 缓存,不受影响。
unsafe {
std::env::set_var("PATH", format!("{};{}", current_path, install_str));
}
let _ = app.emit("upgrade-log", format!("已添加到 PATH: {install_str}"));
}
}
@@ -2621,6 +2769,18 @@ async fn upgrade_openclaw_inner(
if need_uninstall_old {
let _ = app.emit("upgrade-log", format!("清理旧版本 ({old_pkg})..."));
let _ = npm_command().args(["uninstall", "-g", old_pkg]).output();
// 清理 standalone 安装目录(不论从 standalone 切走还是切到 standalone
// npm 路径已经安装了新 CLIstandalone 残留会干扰源检测)
for sa_dir in all_standalone_dirs() {
if sa_dir.exists() {
let _ = app.emit(
"upgrade-log",
format!("清理 standalone 残留: {}", sa_dir.display()),
);
let _ = std::fs::remove_dir_all(&sa_dir);
}
}
}
// 切换源后重装 Gateway 服务

View File

@@ -258,6 +258,10 @@ fn build_enhanced_path() -> String {
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");
@@ -326,6 +330,10 @@ fn build_enhanced_path() -> String {
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")
@@ -539,6 +547,14 @@ fn build_enhanced_path() -> String {
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));

View File

@@ -329,10 +329,11 @@ mod platform {
fn common_cli_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".openclaw-bin").join("openclaw"));
// standalone 安装目录(集中管理,避免多处硬编码)
for sa_dir in crate::commands::config::all_standalone_dirs() {
candidates.push(sa_dir.join("openclaw"));
}
candidates.push(PathBuf::from("/opt/openclaw/openclaw"));
// Homebrew 路径(非 standalone保留
candidates.push(PathBuf::from("/opt/homebrew/bin/openclaw"));
candidates.push(PathBuf::from("/usr/local/bin/openclaw"));
candidates
@@ -849,23 +850,9 @@ mod platform {
fn candidate_cli_paths() -> Vec<PathBuf> {
let mut candidates = Vec::new();
// standalone 安装目录(优先检测,覆盖所有可能位置
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
// Inno Setup PrivilegesRequired=lowest 默认路径
candidates.push(
Path::new(&localappdata)
.join("Programs")
.join("OpenClaw")
.join("openclaw.cmd"),
);
candidates.push(
Path::new(&localappdata)
.join("OpenClaw")
.join("openclaw.cmd"),
);
}
if let Ok(pf) = env::var("ProgramFiles") {
candidates.push(Path::new(&pf).join("OpenClaw").join("openclaw.cmd"));
// standalone 安装目录(集中管理,避免多处硬编码
for sa_dir in crate::commands::config::all_standalone_dirs() {
candidates.push(sa_dir.join("openclaw.cmd"));
}
if let Ok(appdata) = env::var("APPDATA") {
@@ -1224,6 +1211,10 @@ mod platform {
.join("openclaw"),
);
}
// standalone 安装目录(集中管理,避免多处硬编码)
for sa_dir in crate::commands::config::all_standalone_dirs() {
candidates.push(sa_dir.join("openclaw"));
}
candidates.push(PathBuf::from("/usr/local/bin/openclaw"));
candidates.push(PathBuf::from("/usr/bin/openclaw"));
for segment in crate::commands::enhanced_path().split(':') {

View File

@@ -38,11 +38,14 @@ fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
#[cfg(not(target_os = "windows"))]
fn common_non_windows_cli_candidates() -> Vec<std::path::PathBuf> {
let mut candidates = Vec::new();
// standalone 安装目录(集中管理,避免多处硬编码)
for sa_dir in crate::commands::config::all_standalone_dirs() {
candidates.push(sa_dir.join("openclaw"));
}
// 其他标准路径
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".openclaw-bin").join("openclaw"));
candidates.push(home.join(".local").join("bin").join("openclaw"));
}
candidates.push(std::path::PathBuf::from("/opt/openclaw/openclaw"));
candidates.push(std::path::PathBuf::from("/opt/homebrew/bin/openclaw"));
candidates.push(std::path::PathBuf::from("/usr/local/bin/openclaw"));
candidates.push(std::path::PathBuf::from("/usr/bin/openclaw"));