diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index d1c94d1a5..b633f8a92 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1428,16 +1428,67 @@ fn brew_formula_from_path(real: &str) -> Option { None } +/// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。 +/// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。 +/// +/// **仅按空格判定,不防其他 shell 元字符**(`$` / `` ` `` / `'` / `"` / `;` 等)。 +/// 调用方传入的是探测得到的可执行路径(`enumerate_tool_installations` 里来源于 +/// `Path::display()`),实际 macOS/Linux 上 home dir 名几乎不允许这类字符、 +/// npm/brew/volta/bun 也不会装到含这类字符的路径,与 diff 前内联在 npm 分支里的 +/// `if npm.contains(' ')` 实现等价。若未来要扩广,改成 `shell_single_quote` 无条件 +/// 包裹即可,但会失去"无空格时的清洁展示"。 +#[cfg(not(target_os = "windows"))] +fn quote_path_if_spaced(p: &str) -> String { + if p.contains(' ') { + shell_single_quote(p) + } else { + p.to_string() + } +} + +/// 返回 `/` 的绝对路径。bin_path 是命令行命中的入口 +/// (如 `/opt/homebrew/bin/gemini`、`~/.volta/bin/codex`),`exe` 是与之共处一个 +/// bin 目录的另一个可执行(`brew` / `volta` / `bun` / `npm`)——这些包管理器 +/// 都把自己的 cli 跟它们安装的命令并列放在同一个 bin 目录,所以"同目录推导" +/// 是可靠的绝对路径来源。 +/// +/// **dir 为空(bin_path 不含 `/`) → 返回 None**:此时无法推导出绝对路径,让上游 +/// `anchored_command_from_paths` 整体退化为 None,调用方落到静态命令兜底——而非 +/// 悄悄拼出 `npm i -g ` 这种依赖 PATH 的指令,违背"必须绝对路径"不变量。 +/// 实际从 `enumerate_tool_installations` 走的 bin_path 都是 `Path::display()` 出 +/// 来的绝对路径,这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。 +#[cfg(not(target_os = "windows"))] +fn sibling_bin(bin_path: &str, exe: &str) -> Option { + let dir = parent_dir(bin_path); + if dir.is_empty() { + None + } else { + Some(format!("{dir}/{exe}")) + } +} + /// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径, /// 推断"写回同一处"的锚定升级命令。纯函数(不碰 FS),真实 canonicalize 由调用方做, /// 便于单测覆盖各包管理器分支。hermes(pip)不在此处:`npm_package_for` 返回 None → None。 /// +/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。 +/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行—— +/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含** +/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测 +/// 阶段 `try_get_version` 用的是 `$SHELL -lic`(登录+交互式,会读 .zshrc/.zprofile), +/// 两者 PATH 不对称。裸 `claude update` / `brew upgrade ...` 在 GUI 进程里大概率 +/// `command not found`(exit 127)→ `set -e` 中止 → 用户看到失败 toast,锚定决策却 +/// 已展示给用户"将写回原生那处"——欺骗性故障。 +/// /// 判定顺序(命中即返回): -/// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ `claude update` 自更新; -/// 它不归 npm 管,用 npm 升级会装到别处且被原生那份遮蔽(PATH 更靠前)。 -/// ② Homebrew formula(真身在 `Cellar//`)→ `brew upgrade `; +/// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ ` update`; +/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管, +/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。 +/// ② Homebrew formula(真身在 `Cellar//`)→ `/brew upgrade `; /// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli)。 -/// ③ volta / bun → 各自的全局安装命令。 +/// ③ volta → `/volta install `;bun → `/bun add -g @latest`。 +/// `~/.volta/bin` / `~/.bun/bin` 几乎不在 GUI 进程的 PATH 里,且用户可能 PATH +/// 上有另一份 volta/bun → 必须绝对路径锚定到命令行实际命中的那一份。 /// ④ 其余 npm 全局包(nvm / homebrew-npm / mise / fnm / system)→ 锚定到"那处 bin /// 目录的 npm",确保升级写回命令行实际在用的那个 node,而非 PATH 第一个 npm。 #[cfg(not(target_os = "windows"))] @@ -1449,14 +1500,24 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> && (real_lower.contains("/.local/share/claude/") || real_lower.contains("/claude/versions/")) { - return Some("claude update".to_string()); + return Some(format!("{} update", quote_path_if_spaced(bin_path))); } if let Some(formula) = brew_formula_from_path(real_target) { - return Some(format!("brew upgrade {formula}")); + let brew = sibling_bin(bin_path, "brew")?; + return Some(format!("{} upgrade {formula}", quote_path_if_spaced(&brew))); } match infer_install_source(Path::new(bin_path)) { - "volta" => return Some(format!("volta install {pkg}")), - "bun" => return Some(format!("bun add -g {pkg}@latest")), + "volta" => { + let volta = sibling_bin(bin_path, "volta")?; + return Some(format!("{} install {pkg}", quote_path_if_spaced(&volta))); + } + "bun" => { + let bun = sibling_bin(bin_path, "bun")?; + return Some(format!( + "{} add -g {pkg}@latest", + quote_path_if_spaced(&bun) + )); + } // 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。 "nvm" | "fnm" | "mise" | "homebrew" => {} // system / 未知来源(如 opencode install.sh 装的 ~/.opencode/bin、~/go/bin) @@ -1464,20 +1525,8 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> // 回退静态裸命令(至少能跑),并由前端据 anchored=false 给出诚实文案。 _ => return None, } - let dir = parent_dir(bin_path); - let npm = if dir.is_empty() { - "npm".to_string() - } else { - format!("{dir}/npm") - }; - // 含空格才单引号包裹(复用 shell_single_quote 的 POSIX 转义),否则保持裸路径, - // 命令展示更干净。 - let npm = if npm.contains(' ') { - shell_single_quote(&npm) - } else { - npm - }; - Some(format!("{npm} i -g {pkg}@latest")) + let npm = sibling_bin(bin_path, "npm")?; + Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm))) } /// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到 @@ -2578,26 +2627,33 @@ mod tests { #[test] fn claude_native_installer_uses_self_update() { - // ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/,自带 self-update; - // 它不归 npm 管,且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级纯属白装。 + // ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/,自带 self-update; + // 它不归 npm 管,且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级纯属白装。 + // **绝对路径调用 launcher** 避免 GUI 非登录 `bash -c` 时 PATH 没有 + // ~/.local/bin 导致 `claude: not found`(exit 127)而失败。 let cmd = anchored_command_from_paths( "claude", "/Users/me/.local/bin/claude", "/Users/me/.local/share/claude/versions/2.1.146", ); - assert_eq!(cmd.as_deref(), Some("claude update")); + assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update")); } #[test] fn gemini_homebrew_formula_uses_brew_upgrade() { - // /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包, + // /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包, // 且 formula 名(gemini-cli) ≠ npm 包名(@google/gemini-cli)。 + // **brew 与 formula 入口同目录**,用 `/brew` 绝对路径调用,避免 GUI + // 非登录 `bash -c` 时 PATH 没有 /opt/homebrew/bin 导致 `brew: not found`。 let cmd = anchored_command_from_paths( "gemini", "/opt/homebrew/bin/gemini", "/opt/homebrew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", ); - assert_eq!(cmd.as_deref(), Some("brew upgrade gemini-cli")); + assert_eq!( + cmd.as_deref(), + Some("/opt/homebrew/bin/brew upgrade gemini-cli") + ); } #[test] @@ -2631,22 +2687,60 @@ mod tests { #[test] fn volta_uses_volta_install() { + // `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能 + // PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。 let cmd = anchored_command_from_paths( "codex", "/Users/me/.volta/bin/codex", "/Users/me/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex", ); - assert_eq!(cmd.as_deref(), Some("volta install @openai/codex")); + assert_eq!( + cmd.as_deref(), + Some("/Users/me/.volta/bin/volta install @openai/codex") + ); } #[test] fn bun_uses_bun_add() { + // bun 同 volta:`~/.bun/bin` 几乎不在 GUI PATH 里,绝对路径锚定。 let cmd = anchored_command_from_paths( "opencode", "/Users/me/.bun/bin/opencode", "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode", ); - assert_eq!(cmd.as_deref(), Some("bun add -g opencode-ai@latest")); + assert_eq!( + cmd.as_deref(), + Some("/Users/me/.bun/bin/bun add -g opencode-ai@latest") + ); + } + + #[test] + fn volta_path_with_space_is_quoted() { + // volta 分支用 `/volta`,目录含空格时同样要 POSIX 引号包裹。 + let cmd = anchored_command_from_paths( + "codex", + "/Users/my name/.volta/bin/codex", + "/Users/my name/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex", + ); + assert_eq!( + cmd.as_deref(), + Some("'/Users/my name/.volta/bin/volta' install @openai/codex") + ); + } + + #[test] + fn bun_path_with_space_is_quoted() { + // bun 分支与 volta 共享 sibling_bin + quote_path_if_spaced, + // 这条用例锁住 `bun add -g` 命令头部的引号包裹形态。 + let cmd = anchored_command_from_paths( + "opencode", + "/Users/my name/.bun/bin/opencode", + "/Users/my name/.bun/install/global/node_modules/opencode-ai/bin/opencode", + ); + assert_eq!( + cmd.as_deref(), + Some("'/Users/my name/.bun/bin/bun' add -g opencode-ai@latest") + ); } #[test] @@ -2712,6 +2806,35 @@ mod tests { ); } + #[test] + fn claude_native_path_with_space_is_quoted() { + // claude 分支同样要 POSIX 引号包裹含空格的 bin_path, + // 否则 `/Users/my name/.local/bin/claude update` 会被 shell 拆词。 + let cmd = anchored_command_from_paths( + "claude", + "/Users/my name/.local/bin/claude", + "/Users/my name/.local/share/claude/versions/2.1.146", + ); + assert_eq!( + cmd.as_deref(), + Some("'/Users/my name/.local/bin/claude' update") + ); + } + + #[test] + fn brew_path_with_space_is_quoted() { + // brew 分支用 `/brew`,目录含空格时同样要引号包裹。 + let cmd = anchored_command_from_paths( + "gemini", + "/opt/my brew/bin/gemini", + "/opt/my brew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", + ); + assert_eq!( + cmd.as_deref(), + Some("'/opt/my brew/bin/brew' upgrade gemini-cli") + ); + } + #[test] fn brew_formula_extraction() { assert_eq!( @@ -2730,6 +2853,22 @@ mod tests { ); } + #[test] + fn sibling_bin_returns_none_when_bin_path_has_no_directory() { + // bin_path 不含 `/` → parent_dir 返回空 → sibling_bin 不能拼出绝对路径 + // → None,让上游 anchored_command_from_paths 整体退化为静态命令兜底, + // 而不是悄悄拼出 `npm i -g ` 这种依赖 PATH 的指令(违背"必须绝对路径" + // 不变量)。实际从 enumerate_tool_installations 走的 bin_path 都是绝对路径, + // 这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。 + assert_eq!(sibling_bin("codex", "npm"), None); + assert_eq!(sibling_bin("", "brew"), None); + // 含 `/` 即可拼出绝对路径——这是常规路径。 + assert_eq!( + sibling_bin("/opt/homebrew/bin/gemini", "brew").as_deref(), + Some("/opt/homebrew/bin/brew") + ); + } + #[test] fn default_install_prefers_path_default() { let installs = vec![ diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 5db247223..cfdaa1e32 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -234,6 +234,16 @@ export function AboutSection({ isPortable }: AboutSectionProps) { toolNames: ToolName[]; plans: ToolInstallationReport[]; } | null>(null); + // 升级 preflight(probe 阶段)的 in-flight 工具集合。 + // probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize), + // 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击 + // 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / `curl | bash`,写冲突)。 + // 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。 + // 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight, + // 精确反映到各自卡片按钮的 disabled。 + const [preflightTools, setPreflightTools] = useState>( + () => new Set(), + ); const toolVersionByName = useMemo(() => { return new Map(toolVersions.map((tool) => [tool.name, tool])); @@ -643,32 +653,60 @@ export function AboutSection({ isPortable }: AboutSectionProps) { [t, wslShellByTool, refreshToolVersions, diagnoseToolSilently], ); - // 升级前先探测安装分布:检测到多处安装(命令行只命中其中一处)时弹确认, - // 让用户在「升级只动默认那处、其余不动」这件事上知情。install 无锚点,直接执行。 + // 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁; + // 这层锁覆盖三个否则会漏掉的窗口: + // ① update 的 probeToolInstallations 阶段(1-3 秒跨进程,executeRun 之前); + // ② executeRun 内部 setToolActions 落到 React commit 前的几个 microtask; + // ③ install 直接进 executeRun 的同一段 microtask 窗口。 + // 早退检查避免同一工具在 toolActions / preflight 已登记时被重复入栈——批量场景里 + // 只要有一个工具被锁,整批不开新一轮,因为后端 set -e 串行的语义假设是「一次性 + // 单脚本」,跨两次 IPC 并发会破坏它。 const handleRunToolAction = useCallback( async (toolNames: ToolName[], action: ToolLifecycleAction) => { if (toolNames.length === 0) return; - if (action === "install") { - await executeRun(toolNames, action); + if ( + toolNames.some( + (name) => preflightTools.has(name) || toolActions[name] !== undefined, + ) + ) { return; } - let reports: ToolInstallationReport[]; + // 入栈 preflight,按钮立刻 disabled。new Set(prev) 是不可变更新(直接 mutate + // 原 Set 会让 React 复用引用、跳过 re-render);finally 块负责异常路径解锁。 + setPreflightTools((prev) => { + const next = new Set(prev); + toolNames.forEach((name) => next.add(name)); + return next; + }); try { - reports = await settingsApi.probeToolInstallations(toolNames); - } catch (error) { - // 探测失败不应阻断升级:退回直接执行(等同旧行为)。 - console.error("[AboutSection] probeToolInstallations failed", error); - await executeRun(toolNames, action); - return; + if (action === "install") { + await executeRun(toolNames, action); + return; + } + let reports: ToolInstallationReport[]; + try { + reports = await settingsApi.probeToolInstallations(toolNames); + } catch (error) { + // 探测失败不应阻断升级:退回直接执行(等同旧行为)。 + console.error("[AboutSection] probeToolInstallations failed", error); + await executeRun(toolNames, action); + return; + } + const needConfirm = reports.filter((r) => r.needs_confirmation); + if (needConfirm.length === 0) { + await executeRun(toolNames, action); + return; + } + setPendingUpgrade({ toolNames, plans: needConfirm }); + } finally { + setPreflightTools((prev) => { + const next = new Set(prev); + toolNames.forEach((name) => next.delete(name)); + return next; + }); } - const needConfirm = reports.filter((r) => r.needs_confirmation); - if (needConfirm.length === 0) { - await executeRun(toolNames, action); - return; - } - setPendingUpgrade({ toolNames, plans: needConfirm }); }, - [executeRun], + [executeRun, preflightTools, toolActions], ); const handleConfirmUpgrade = useCallback(() => { @@ -684,7 +722,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) { // 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮, // 避免并发触发多个 npm/pip 全局写入造成冲突。 - const isAnyBusy = Boolean(batchAction) || Object.keys(toolActions).length > 0; + // preflightTools 覆盖升级前的 probe 阶段——那段在 executeRun 之前、toolActions + // 还没置位,如果不算进 busy 会留出 1-3 秒的并发触发窗口。 + const isAnyBusy = + Boolean(batchAction) || + Object.keys(toolActions).length > 0 || + preflightTools.size > 0; return (