fix(setup): 修复 Node.js/Git/OpenClaw 装完不识别(需重启客户端)

用户反馈 0.13.3 版本有三个共性 bug:
1. 手动装 Node.js → 装完 panel 不识别,必须重启客户端
2. 一键装 Git → 装完 panel 不识别,必须重启客户端
3. 一键装 OpenClaw → 装完 panel 不识别,必须重启客户端

## 根因

Tauri 进程的 `std::env::var("PATH")` 是启动时快照,不会随系统 PATH 更
新。`enhanced_path()` 虽然扫描了常见安装目录,但**不存在的目录不会被
加入缓存**(line 828 `std::path::Path::new(p).exists()` 过滤)。装完
新程序后,新路径不在 enhanced_path 缓存里,CLI 检测又依赖子进程的
PATH,导致「找不到刚装的二进制」。

三个 bug 各有其子因:

### Bug 1: Git 检测 `find_git_path` 不用 enhanced_path
对比 `find_node_path` 显式 `cmd.env("PATH", enhanced_path)`,
`find_git_path` 里的 `where git` / `which git` 子进程继承的是 Tauri
启动时快照的老 PATH。即使 `refresh_enhanced_path()` 刷了缓存,子进
程也看不到新路径。`check_git` 后续调 `Command::new("git")` 同理,且
拿到 `git_path` 绝对路径后也没用。

### Bug 2: `auto_install_git` 三平台都不刷缓存
winget/xcode-select/apt 安装成功后直接 return,没有
`refresh_enhanced_path()` / `invalidate_cli_detection_cache()`。前端
`runDetect` 虽然会调 `invalidatePathCache`,但 Bug 1 让刷缓存也白刷。

### Bug 3: `upgrade_openclaw` npm 首装分支不刷缓存
npm 分支里只有 `if need_uninstall_old`(切换源场景)分支末尾刷了
PATH 缓存。**首次安装** `need_uninstall_old == false`,if 块整个跳
过,函数直接返回,CLI 检测缓存(60s TTL)和 PATH 缓存都是旧的。前
端 `setTimeout(reload, 1500)` 触发 SPA 重建,但 Tauri 进程没重启,
缓存没刷 → `is_cli_installed()` 返回 false。

### Bug 4: 手动装 Node.js 没有 hook 点
用户点「下载 Node.js」跳浏览器,装完回到 panel,panel 不知道用户装
了。虽然顶部有「重新检测」按钮,但 UX 上容易错过。`runDetect` 里虽
然会 `await api.invalidatePathCache()`,但需要用户主动触发。

## 修复

### Rust 端

1. **`find_git_path`**:子进程 `where`/`which` 显式 `env("PATH",
   enhanced_path)`,对齐 `find_node_path` 的做法。
2. **`check_git`**:优先用 `find_git_path` 返回的绝对路径执行
   `--version`;fallback 到 `"git"` 时也注入 enhanced_path 到子进程
   PATH,确保刚装完的场景能识别。
3. **`auto_install_git`**:winget/xcode-select/apt 三个平台的成功分
   支都调 `super::refresh_enhanced_path()` +
   `invalidate_cli_detection_cache()`。
4. **`upgrade_openclaw`**:npm 分支末尾(if need_uninstall_old 块之
   后、`get_local_version` 之前)无条件刷缓存,覆盖首装场景。切换源
   场景虽然前面已刷过一次,这里重刷无害(几十 ms 文件系统扫描开销
   可接受)。

### 前端

5. **`setup.js::render`**:注册 `visibilitychange` + window `focus`
   监听器,用户从浏览器装完 Node.js 切回 panel 时自动 `runDetect`。
   handler 自带 guard(`page.isConnected` 检查),页面切走后监听器
   自动卸载,防止泄漏。加 3 秒节流,避免 focus + visibilitychange
   同时连发触发重复检测。

## 验证

- `cargo fmt --check` 通过
- `cargo clippy --all-targets -- -D warnings` 通过
- `npm run build` 通过(setup chunk 未变,setup.js 新增 ~22 行)
- 本地需要用户验证:
  - [ ] 手动装完 Node.js → 切回 panel 自动识别
  - [ ] 一键装 Git → 装完立刻识别(无需重启)
  - [ ] 一键装 OpenClaw(首次 npm 安装)→ 装完立刻识别
  - [ ] 一键装 OpenClaw(切换源)→ 装完立刻识别(原本就工作,不回归)

Refs: 0.13.3 用户实测反馈
This commit is contained in:
晴天
2026-04-20 11:57:08 +08:00
parent dfb81066b4
commit bf9cb52e25
2 changed files with 58 additions and 3 deletions

View File

@@ -2607,11 +2607,15 @@ pub fn check_openclaw_at_path(cli_path: String) -> Result<Value, String> {
}
fn find_git_path() -> Option<String> {
// #Compat-4: 必须把子进程 PATH 替换成 enhanced_path否则继承的是 Tauri 启动时快照,
// 用户新装的 git 不在快照里,`where git` / `which git` 就找不到。对齐 find_node_path 的做法。
let enhanced = super::enhanced_path();
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("where");
cmd.arg("git");
cmd.creation_flags(0x08000000);
cmd.env("PATH", &enhanced);
if let Ok(output) = cmd.output() {
if output.status.success() {
if let Some(first_line) = String::from_utf8_lossy(&output.stdout).lines().next() {
@@ -2626,7 +2630,10 @@ fn find_git_path() -> Option<String> {
#[cfg(not(target_os = "windows"))]
{
if let Ok(output) = Command::new("which").arg("git").output() {
let mut cmd = Command::new("which");
cmd.arg("git");
cmd.env("PATH", &enhanced);
if let Ok(output) = cmd.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() {
@@ -3975,6 +3982,14 @@ async fn upgrade_openclaw_inner(
}
}
// #Compat-4: npm 首次安装场景下,前面 `if need_uninstall_old` 块被跳过,
// PATH 缓存和 CLI 检测缓存都是装 openclaw 之前的旧快照。必须在这里统一刷新一次,
// 否则前端 `check_installation`/`get_services_status` 拿到的仍是「CLI 未安装」
// —— 用户反馈「一键装完日志显示成功,但面板不识别,重启客户端才能用」。
// 切换源场景前面已刷过,这里重刷无害(几十 ms 扫描开销可接受)。
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
let msg = format!("✅ 安装完成,当前版本: {new_ver}");
let _ = app.emit("upgrade-log", &msg);
@@ -5788,8 +5803,12 @@ pub fn check_git() -> Result<Value, String> {
} else {
find_git_path()
};
let mut cmd = Command::new(&git);
// #Compat-4: 优先用 find_git_path 拿到的绝对路径执行 --version避免依赖子进程 PATH
// 回退到 "git" 时也把 enhanced_path 注入子进程 PATH让刚装完 git 的场景立即可识别。
let exec = git_path.as_deref().unwrap_or(&git);
let mut cmd = Command::new(exec);
cmd.arg("--version");
cmd.env("PATH", super::enhanced_path());
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
match cmd.output() {
@@ -6028,6 +6047,10 @@ pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
.map_err(|e| format!("等待 winget 完成失败: {e}"))?;
if status.success() {
let _ = app.emit("upgrade-log", "Git 安装成功!");
// #Compat-4: 刷新 PATH 缓存,使 check_git 能立即检测到新装的 git
// 避免用户反馈「装完不识别,重启客户端才能用」
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
return Ok("Git 已通过 winget 安装".to_string());
}
Err("winget 安装 Git 失败,请手动下载安装: https://git-scm.com/downloads".to_string())
@@ -6045,6 +6068,9 @@ pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
if status.success() {
let _ = app.emit("upgrade-log", "Git 安装已触发,请在弹出的窗口中确认安装。");
// #Compat-4: 刷新缓存(即便是"触发"而非同步完成,下次检测时缓存也已清)
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
return Ok("已触发 xcode-select 安装,请在弹窗中确认".to_string());
}
Err(
@@ -6130,6 +6156,9 @@ pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
if status.success() {
let _ = app.emit("upgrade-log", "Git 安装成功!");
// #Compat-4: 刷新 PATH 缓存,使 check_git 立即识别新装的 git
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
return Ok("Git 已安装".to_string());
}
Err("Git 安装失败,请手动执行: sudo apt install git".to_string())

View File

@@ -108,6 +108,28 @@ export async function render() {
`
page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page))
// #Compat-4: 用户在浏览器里手动装完 Node.js 后切回 panel或用户装完 Git/OpenClaw
// 后 app 失焦又重新获得焦点时,自动重新检测,避免「装完不识别」。
// handler 自带 guardpage 从 DOM 移除后自动卸载监听器,防止跨页面泄漏。
// 同时监听 visibilitychangetab 切换)和 window focus桌面端窗口激活兜底不同平台行为。
let _lastRedetectAt = 0
const onVisibilityChange = () => {
if (!page.isConnected) {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('focus', onVisibilityChange)
return
}
if (document.visibilityState !== 'visible') return
// 3 秒内不重复触发(避免 focus + visibilitychange 同时连发)
const now = Date.now()
if (now - _lastRedetectAt < 3000) return
_lastRedetectAt = now
runDetect(page)
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('focus', onVisibilityChange)
runDetect(page)
return page
}
@@ -165,8 +187,12 @@ async function runDetect(page) {
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
`
// 清除缓存,确保拿到最新检测结果
// 清除前端 invoke 缓存
invalidate('get_version_info', 'check_node', 'check_git', 'get_services_status', 'check_installation')
// #Compat-4: 同步刷新 Rust 端 PATH 缓存 + CLI 检测缓存
// 用户手动装完 Node.js/Git 后Tauri 进程的 PATH 仍是启动时快照,且 enhanced_path 有缓存。
// 必须先调此命令扫描文件系统新装路径,才能让 where/which 找到新二进制。
try { await api.invalidatePathCache() } catch {}
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
const [nodeRes, gitRes, clawRes, configRes, versionRes] = await Promise.allSettled([
api.checkNode(),