mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(apps): follow up uninstall feedback and Windows CLI PATH (#8262)
* fix(windows): clarify uninstall results and restore CLI PATH * fix(apps): handle diagnostic failures and canonical Windows CLI paths
This commit is contained in:
1 parent
4958863adf
commit
74b98ca940
8 files changed
+381
-70
No files matched your search
+165
-48
@@ -2063,13 +2063,15 @@ fn extend_mise_node_search_paths(paths: &mut Vec<std::path::PathBuf>, home: &Pat
|
||||
/// duplicates are removed.
|
||||
///
|
||||
/// See `env_checker::check_system_env` for the same set of registry keys; here
|
||||
/// we read only the `Path` value.
|
||||
/// referenced variables also fall back to these keys if absent from the process.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn effective_path_string() -> String {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
|
||||
let process = std::env::var("PATH").unwrap_or_default();
|
||||
let process = std::env::var_os("PATH")
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let user = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey("Environment")
|
||||
.and_then(|k| k.get_value::<String, &str>("Path"))
|
||||
@@ -2112,12 +2114,43 @@ fn prepend_search_dir_to_path(dir: &Path, current_path: &std::ffi::OsStr) -> std
|
||||
|
||||
/// Expand `%VAR%` environment-variable references. The registry `Path` value is
|
||||
/// `REG_EXPAND_SZ`, and the `String` returned by `winreg` is not auto-expanded.
|
||||
/// Variables such as `%LOCALAPPDATA%` / `%USERPROFILE%` / `%SystemRoot%` are
|
||||
/// still defined in a process that lost its user PATH (Winlogon injects them
|
||||
/// from the user profile), so expanding each via `std::env::var` is safe.
|
||||
/// Undefined variables are preserved verbatim (no characters dropped).
|
||||
/// MSI launches can also lose user-defined variables such as `%PNPM_HOME%`.
|
||||
/// Prefer process overrides, then the user and machine registry values. Registry
|
||||
/// values may reference other variables; unknown/cyclic references stay literal.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn expand_env_chars(raw: &str) -> String {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
|
||||
let user = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey("Environment")
|
||||
.ok();
|
||||
let machine = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||
.ok();
|
||||
expand_env_chars_with_lookup(
|
||||
raw,
|
||||
&|name| std::env::var(name).ok(),
|
||||
&|name| {
|
||||
user.as_ref()
|
||||
.and_then(|key| key.get_value::<String, _>(name).ok())
|
||||
.or_else(|| {
|
||||
machine
|
||||
.as_ref()
|
||||
.and_then(|key| key.get_value::<String, _>(name).ok())
|
||||
})
|
||||
},
|
||||
&mut Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn expand_env_chars_with_lookup(
|
||||
raw: &str,
|
||||
process: &impl Fn(&str) -> Option<String>,
|
||||
registry: &impl Fn(&str) -> Option<String>,
|
||||
expanding: &mut Vec<String>,
|
||||
) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut rest = raw;
|
||||
while let Some(open) = rest.find('%') {
|
||||
@@ -2134,19 +2167,31 @@ fn expand_env_chars(raw: &str) -> String {
|
||||
let name = &after[..close];
|
||||
let is_ident =
|
||||
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
if is_ident {
|
||||
match std::env::var(name) {
|
||||
Ok(val) => out.push_str(&val),
|
||||
Err(_) => {
|
||||
out.push('%');
|
||||
out.push_str(name);
|
||||
out.push('%');
|
||||
let value = if is_ident {
|
||||
process(name).or_else(|| {
|
||||
// Bound nesting even for an unusually long registry chain.
|
||||
if expanding.len() >= 32
|
||||
|| expanding.iter().any(|var| var.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let raw = registry(name)?;
|
||||
expanding.push(name.to_owned());
|
||||
let value =
|
||||
expand_env_chars_with_lookup(&raw, process, registry, expanding);
|
||||
expanding.pop();
|
||||
Some(value)
|
||||
})
|
||||
} else {
|
||||
out.push('%');
|
||||
out.push_str(name);
|
||||
out.push('%');
|
||||
None
|
||||
};
|
||||
match value {
|
||||
Some(value) => out.push_str(&value),
|
||||
None => {
|
||||
out.push('%');
|
||||
out.push_str(name);
|
||||
out.push('%');
|
||||
}
|
||||
}
|
||||
rest = &after[close + 1..];
|
||||
}
|
||||
@@ -3893,6 +3938,9 @@ pub(crate) fn run_detected_tool_command_with_timeout(
|
||||
let dir = tool_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid {tool} executable path"))?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let current_path = effective_path_string();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let current_path = std::env::var_os("PATH")
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
@@ -3937,38 +3985,9 @@ fn run_windows_tool_command_capture(
|
||||
extra_env: &[(&str, String)],
|
||||
working_dir: &Path,
|
||||
) -> Result<std::process::Output, String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = if is_windows_command_script(tool_path) {
|
||||
let path = tool_path.to_string_lossy();
|
||||
let args = args
|
||||
.iter()
|
||||
.map(|arg| windows_cmd_double_quote_arg(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let command = format!(
|
||||
"call {}{}",
|
||||
win_quote_path_for_batch(&path),
|
||||
if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {args}")
|
||||
}
|
||||
);
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C"])
|
||||
.raw_arg(&command)
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new(tool_path);
|
||||
cmd.args(args)
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
};
|
||||
use std::process::Stdio;
|
||||
|
||||
let mut cmd = build_windows_tool_command(tool_path, args, new_path);
|
||||
apply_extra_env(&mut cmd, extra_env);
|
||||
cmd.current_dir(working_dir)
|
||||
.stdout(Stdio::piped())
|
||||
@@ -8809,6 +8828,65 @@ mod tests {
|
||||
assert_eq!(expand_env_chars(r"C:\path\%%\tail"), r"C:\path\%%\tail");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn expand_env_chars_recovers_missing_user_variables_and_nested_references() {
|
||||
let process = |name: &str| match name.to_ascii_uppercase().as_str() {
|
||||
"USERPROFILE" => Some(r"C:\Users\tester".into()),
|
||||
"OVERRIDE" => Some(r"D:\runtime".into()),
|
||||
_ => None,
|
||||
};
|
||||
let registry = |name: &str| match name.to_ascii_uppercase().as_str() {
|
||||
"PNPM_HOME" => Some(r"%LOCALAPPDATA%\pnpm".into()),
|
||||
"LOCALAPPDATA" => Some(r"%USERPROFILE%\AppData\Local".into()),
|
||||
"OVERRIDE" => Some(r"D:\registry".into()),
|
||||
"CYCLE_A" => Some("%CYCLE_B%".into()),
|
||||
"CYCLE_B" => Some("%cycle_a%".into()),
|
||||
_ => None,
|
||||
};
|
||||
let expanded = expand_env_chars_with_lookup(
|
||||
r"%pnpm_home%;%OVERRIDE%;%MISSING%;%CYCLE_A%;%PNPM_HOME%;unfinished%",
|
||||
&process,
|
||||
®istry,
|
||||
&mut Vec::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
expanded,
|
||||
r"C:\Users\tester\AppData\Local\pnpm;D:\runtime;%MISSING%;%cycle_a%;C:\Users\tester\AppData\Local\pnpm;unfinished%"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn effective_path_keeps_process_entries_with_non_unicode_path() {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
const CHILD: &str = "CC_SWITCH_TEST_NON_UNICODE_PATH";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
assert!(std::env::var("PATH").is_err());
|
||||
let effective = effective_path_string();
|
||||
assert!(effective.starts_with(r"C:\cc-switch-path-test;"));
|
||||
assert!(effective.contains(r";D:\cc-switch-path-test"));
|
||||
return;
|
||||
}
|
||||
// Use a subprocess so other tests never observe a modified process PATH.
|
||||
let mut path: Vec<u16> = r"C:\cc-switch-path-test;C:\".encode_utf16().collect();
|
||||
path.push(0xD800); // Unpaired UTF-16 surrogate.
|
||||
path.extend(r";D:\cc-switch-path-test".encode_utf16());
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"commands::misc::tests::effective_path_keeps_process_entries_with_non_unicode_path",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("PATH", std::ffi::OsString::from_wide(&path))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn build_tool_search_paths_includes_standalone_installer_dirs() {
|
||||
@@ -8940,6 +9018,45 @@ mod tests {
|
||||
assert_eq!(preferred.as_deref(), Some(cmd.as_path()));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_tool_capture_accepts_canonicalized_batch_paths() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let bin = root.path().join("模型 tools & helpers");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
std::fs::write(
|
||||
bin.join("cc_switch_capture_helper.cmd"),
|
||||
"@echo helper-found\r\n@exit /b 23\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let path = format!("{};{}", bin.display(), effective_path_string());
|
||||
for extension in ["cmd", "bat"] {
|
||||
let entry = bin.join(format!("opencode.{extension}"));
|
||||
std::fs::write(
|
||||
&entry,
|
||||
"@echo off\r\necho arg=%~1\r\necho env=%CC_SWITCH_CAPTURE_TEST%\r\necho captured-stderr 1>&2\r\ncmd /D /C cc_switch_capture_helper.cmd\r\nexit /b %errorlevel%\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let canonical = std::fs::canonicalize(entry).unwrap();
|
||||
assert!(canonical.to_string_lossy().starts_with(r"\\?\"));
|
||||
let output = run_windows_tool_command_capture(
|
||||
&canonical,
|
||||
&["models"],
|
||||
&path,
|
||||
CommandDeadline::from_timeout(Some(std::time::Duration::from_secs(10))),
|
||||
&[("CC_SWITCH_CAPTURE_TEST", "preserved".into())],
|
||||
root.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let stdout = decode_command_output(&output.stdout);
|
||||
assert_eq!(output.status.code(), Some(23), "{output:?}");
|
||||
assert!(stdout.contains("arg=models"), "{stdout}");
|
||||
assert!(stdout.contains("env=preserved"), "{stdout}");
|
||||
assert!(stdout.contains("helper-found"), "{stdout}");
|
||||
assert!(decode_command_output(&output.stderr).contains("captured-stderr"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_shell_compatible_path_strips_verbatim_prefixes() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
hermesUninstallMayLeaveFiles,
|
||||
toolDisplayName,
|
||||
type ToolName,
|
||||
type WslShellPreference,
|
||||
@@ -94,6 +95,11 @@ export function ToolUninstallConfirmDialog({
|
||||
{t("settings.toolUninstallNativeHint")}
|
||||
</p>
|
||||
)}
|
||||
{hermesUninstallMayLeaveFiles(plan) && (
|
||||
<p role="note" className="text-caption text-fg-2">
|
||||
{t("settings.toolUninstallHermesWindowsHint")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
|
||||
@@ -49,6 +49,10 @@ export const TOOL_NAMES = [
|
||||
export type ToolName = (typeof TOOL_NAMES)[number];
|
||||
export type ToolLifecycleAction = "install" | "update" | "uninstall";
|
||||
|
||||
export function hermesUninstallMayLeaveFiles(plan: ToolUninstallPlan) {
|
||||
return isWindows() && plan.tool === "hermes" && !plan.wsl_distro;
|
||||
}
|
||||
|
||||
interface PendingUpgrade {
|
||||
toolNames: ToolName[];
|
||||
plans: ToolInstallationReport[];
|
||||
@@ -530,18 +534,18 @@ export function useToolManagement() {
|
||||
const diagnoseToolSilently = useCallback(async (toolName: ToolName) => {
|
||||
try {
|
||||
const [report] = await settingsApi.probeToolInstallations([toolName]);
|
||||
// 没拿到报告代表状态未知,不能把它当成「没有其他安装」。
|
||||
if (!report) return false;
|
||||
// 同一份报告也是行上的路径 / 来源 / 「另有 N 处安装」:装完、升级完就刷新,
|
||||
// 不用等手动「检查更新」
|
||||
if (report) {
|
||||
updateToolManagementState({
|
||||
installReports: {
|
||||
...(toolManagementState.installReports ?? {}),
|
||||
[toolName]: report,
|
||||
},
|
||||
});
|
||||
}
|
||||
updateToolManagementState({
|
||||
installReports: {
|
||||
...(toolManagementState.installReports ?? {}),
|
||||
[toolName]: report,
|
||||
},
|
||||
});
|
||||
setToolDiagnostics((prev) => {
|
||||
if (report?.is_conflict) {
|
||||
if (report.is_conflict) {
|
||||
return { ...prev, [toolName]: report.installs };
|
||||
}
|
||||
// 无冲突:清掉残留;无旧结果则返回同引用,避免无谓 re-render。
|
||||
@@ -550,11 +554,13 @@ export function useToolManagement() {
|
||||
delete next[toolName];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[useToolManagement] Auto-diagnose failed for ${toolName}`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -610,7 +616,7 @@ export function useToolManagement() {
|
||||
const isBatch = toolNames.length > 1;
|
||||
|
||||
// 每个工具独立调用后端,一个失败不会中断其它工具。
|
||||
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
|
||||
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/无法运行/卸载待核实),
|
||||
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
|
||||
const failures: {
|
||||
toolName: ToolName;
|
||||
@@ -658,16 +664,31 @@ export function useToolManagement() {
|
||||
);
|
||||
const tool = refreshed.find((t) => t.name === toolName);
|
||||
if (action === "uninstall") {
|
||||
await diagnoseToolSilently(toolName);
|
||||
if (!tool) {
|
||||
throw new Error(t("settings.toolUninstallCheckFailed"));
|
||||
const diagnosed = await diagnoseToolSilently(toolName);
|
||||
const warnings: string[] = [];
|
||||
if (!tool || !diagnosed) {
|
||||
warnings.push(t("settings.toolUninstallCheckFailed"));
|
||||
}
|
||||
if (tool.version || tool.installed_but_broken) {
|
||||
if (
|
||||
uninstallPlan &&
|
||||
hermesUninstallMayLeaveFiles(uninstallPlan)
|
||||
) {
|
||||
warnings.push(t("settings.toolUninstallHermesWindowsHint"));
|
||||
}
|
||||
if (tool?.version || tool?.installed_but_broken) {
|
||||
toast.info(t("settings.toolUninstallRemaining"), {
|
||||
description: TOOL_DISPLAY_NAMES[toolName],
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
failures.push({
|
||||
toolName,
|
||||
detail: warnings.join("\n"),
|
||||
soft: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
succeeded += 1;
|
||||
return;
|
||||
}
|
||||
@@ -777,12 +798,14 @@ export function useToolManagement() {
|
||||
failures.every((f) => f.soft && f.kind === "versionUnchanged");
|
||||
|
||||
if (succeeded === 0 && hardFailures.length === 0) {
|
||||
// 命令均成功执行、但结果需要用户介入(版本没变 / 装上却跑不起来)
|
||||
// 命令均成功执行、但结果需要用户介入(版本没变 / 无法运行 / 卸载待核实)
|
||||
// → 降级为 warning 并解释原因。
|
||||
toast.warning(
|
||||
allSoftVersionUnchanged
|
||||
? t("settings.toolActionVersionUnchangedTitle")
|
||||
: t("settings.toolActionInstalledNotRunnable"),
|
||||
action === "uninstall"
|
||||
? t("settings.toolUninstallWarningTitle")
|
||||
: allSoftVersionUnchanged
|
||||
? t("settings.toolActionVersionUnchangedTitle")
|
||||
: t("settings.toolActionInstalledNotRunnable"),
|
||||
{
|
||||
description: failureDescription || undefined,
|
||||
closeButton: true,
|
||||
|
||||
@@ -955,11 +955,13 @@
|
||||
"toolUninstall": "Uninstall",
|
||||
"toolUninstallConfirmTitle": "Uninstall {{tool}}?",
|
||||
"toolUninstallConfirmHint": "This removes the active installation and keeps configuration and session data. Other installations, if any, will remain.",
|
||||
"toolUninstallPreparing": "Checking the installation and uninstalling…",
|
||||
"toolUninstallPreparing": "Checking the installation…",
|
||||
"toolUninstallManualRequired": "Cannot uninstall automatically. Please uninstall this app manually.",
|
||||
"toolUninstallPath": "Installation to remove",
|
||||
"toolUninstallNativeHint": "Only the selected executable and its launcher will be removed. Older versions left by the installer will remain.",
|
||||
"toolUninstallFailed": "Uninstall failed",
|
||||
"toolUninstallWarningTitle": "Uninstall command completed; check the result",
|
||||
"toolUninstallHermesWindowsHint": "On Windows, Hermes may leave some program files behind because its running Python is locked during uninstall. After the command finishes, check the original installation directory for leftover files; keep configuration and session data when cleaning up.",
|
||||
"toolUninstallRemaining": "The active installation was removed; other installations remain",
|
||||
"toolUninstallCheckFailed": "Could not verify the installation status after uninstalling. Click “Check for updates” to try again.",
|
||||
"toolUpdate": "Update",
|
||||
|
||||
@@ -955,11 +955,13 @@
|
||||
"toolUninstall": "アンインストール",
|
||||
"toolUninstallConfirmTitle": "{{tool}} をアンインストールしますか?",
|
||||
"toolUninstallConfirmHint": "現在使用中のインストールを削除し、設定とセッションデータは保持します。ほかの場所にもインストールされている場合、それらは保持されます。",
|
||||
"toolUninstallPreparing": "インストールを確認し、アンインストールしています…",
|
||||
"toolUninstallPreparing": "インストールを確認しています…",
|
||||
"toolUninstallManualRequired": "自動でアンインストールできません。このアプリを手動でアンインストールしてください。",
|
||||
"toolUninstallPath": "削除するインストールのパス",
|
||||
"toolUninstallNativeHint": "選択した実行ファイルと起動用ファイルのみを削除します。インストーラーが保存した他の旧バージョンは残ります。",
|
||||
"toolUninstallFailed": "アンインストールに失敗しました",
|
||||
"toolUninstallWarningTitle": "アンインストールコマンドが完了しました。結果を確認してください",
|
||||
"toolUninstallHermesWindowsHint": "Windows では、アンインストール中に実行中の Python がロックされるため、Hermes のプログラムファイルが一部残る場合があります。コマンド完了後に元のインストール先を確認し、残ったファイルを削除する際は設定とセッションデータを保持してください。",
|
||||
"toolUninstallRemaining": "現在のインストールを削除しましたが、ほかのインストールが残っています",
|
||||
"toolUninstallCheckFailed": "アンインストール後の状態を確認できませんでした。「更新を確認」をクリックして再試行してください。",
|
||||
"toolUpdate": "更新",
|
||||
|
||||
@@ -956,11 +956,13 @@
|
||||
"toolUninstall": "解除安裝",
|
||||
"toolUninstallConfirmTitle": "解除安裝 {{tool}}?",
|
||||
"toolUninstallConfirmHint": "將解除安裝目前使用的安裝,保留設定和工作階段資料。如果存在多處安裝,其他安裝將保留。",
|
||||
"toolUninstallPreparing": "正在檢查安裝並解除安裝…",
|
||||
"toolUninstallPreparing": "正在檢查安裝…",
|
||||
"toolUninstallManualRequired": "無法自動解除安裝,請手動解除安裝此應用程式。",
|
||||
"toolUninstallPath": "將解除安裝的路徑",
|
||||
"toolUninstallNativeHint": "僅刪除目前選取的執行檔及其啟動入口,安裝程式保留的其他舊版本仍會保留。",
|
||||
"toolUninstallFailed": "解除安裝失敗",
|
||||
"toolUninstallWarningTitle": "解除安裝指令已完成,請檢查結果",
|
||||
"toolUninstallHermesWindowsHint": "Windows 上 Hermes 解除安裝時,可能因執行中的 Python 被鎖定而留下部分程式檔案。指令結束後請檢查原安裝目錄中的殘留;清理時請保留設定和工作階段資料。",
|
||||
"toolUninstallRemaining": "目前的安裝已移除,仍偵測到其他安裝",
|
||||
"toolUninstallCheckFailed": "解除安裝後無法確認安裝狀態,請點選「檢查更新」重試。",
|
||||
"toolUpdate": "更新",
|
||||
|
||||
@@ -955,11 +955,13 @@
|
||||
"toolUninstall": "卸载",
|
||||
"toolUninstallConfirmTitle": "卸载 {{tool}}?",
|
||||
"toolUninstallConfirmHint": "将卸载当前使用的安装,保留配置和会话数据。如果存在多处安装,其他安装将保留。",
|
||||
"toolUninstallPreparing": "正在检查安装并卸载…",
|
||||
"toolUninstallPreparing": "正在检查安装…",
|
||||
"toolUninstallManualRequired": "无法自动卸载,请手动卸载此应用。",
|
||||
"toolUninstallPath": "将卸载的安装路径",
|
||||
"toolUninstallNativeHint": "仅删除当前选中的可执行文件及其启动入口,其他旧版本仍会保留。",
|
||||
"toolUninstallFailed": "卸载失败",
|
||||
"toolUninstallWarningTitle": "卸载命令已完成,请检查结果",
|
||||
"toolUninstallHermesWindowsHint": "Windows 上 Hermes 卸载时可能因正在运行的 Python 被锁定而留下部分程序文件。命令结束后请检查原安装目录中的残留;清理时请保留配置和会话数据。",
|
||||
"toolUninstallRemaining": "当前安装已卸载,仍检测到其他安装",
|
||||
"toolUninstallCheckFailed": "卸载后无法确认安装状态,请点击“检查更新”重试。",
|
||||
"toolUpdate": "升级",
|
||||
|
||||
@@ -829,6 +829,163 @@ describe("AppsPage concurrent CLI upgrades", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["rejected", "empty"])(
|
||||
"warns when uninstall succeeds but installation diagnostics are %s",
|
||||
async (result) => {
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await renderApps();
|
||||
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
|
||||
missing.add("claude");
|
||||
if (result === "rejected") {
|
||||
mocks.probeToolInstallations.mockRejectedValueOnce(
|
||||
new Error("diagnostics failed"),
|
||||
);
|
||||
} else {
|
||||
mocks.probeToolInstallations.mockResolvedValueOnce([]);
|
||||
}
|
||||
});
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
await within(screen.getByRole("dialog")).findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
"settings.toolUninstallWarningTitle",
|
||||
{
|
||||
description: "settings.toolUninstallCheckFailed",
|
||||
closeButton: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolInstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
} finally {
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["rejected", "empty"])(
|
||||
"warns when uninstall succeeds but the status refresh is %s",
|
||||
async (result) => {
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await renderApps();
|
||||
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
|
||||
if (result === "rejected") {
|
||||
mocks.getToolVersions.mockRejectedValueOnce(
|
||||
new Error("refresh failed"),
|
||||
);
|
||||
} else {
|
||||
mocks.getToolVersions.mockResolvedValueOnce([]);
|
||||
}
|
||||
});
|
||||
fireEvent.click(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
await within(screen.getByRole("dialog")).findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
"settings.toolUninstallWarningTitle",
|
||||
{
|
||||
description: "settings.toolUninstallCheckFailed",
|
||||
closeButton: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
expect(
|
||||
card("Claude Code").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
).toBeEnabled();
|
||||
} finally {
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Windows NT 10.0", null, true],
|
||||
["Windows NT 10.0", "Ubuntu", false],
|
||||
["X11; Linux x86_64", null, false],
|
||||
])(
|
||||
"warns about Hermes leftovers only on native Windows (%s, %s)",
|
||||
async (userAgent, distro, warn) => {
|
||||
const platform = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(userAgent);
|
||||
try {
|
||||
mocks.prepareToolUninstall.mockResolvedValueOnce({
|
||||
...uninstallPlan("hermes"),
|
||||
command: "hermes uninstall --yes",
|
||||
wsl_distro: distro,
|
||||
});
|
||||
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
|
||||
missing.add("hermes");
|
||||
});
|
||||
await renderApps();
|
||||
fireEvent.click(
|
||||
card("Hermes").getByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
}),
|
||||
);
|
||||
const dialog = within(screen.getByRole("dialog"));
|
||||
const confirm = await dialog.findByRole("button", {
|
||||
name: "settings.toolUninstall",
|
||||
});
|
||||
expect(
|
||||
dialog.queryByText("settings.toolUninstallHermesWindowsHint") !==
|
||||
null,
|
||||
).toBe(warn);
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
card("Hermes").getByRole("button", {
|
||||
name: "settings.toolInstall",
|
||||
}),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
if (warn) {
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
"settings.toolUninstallWarningTitle",
|
||||
{
|
||||
description: "settings.toolUninstallHermesWindowsHint",
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mocks.warning).not.toHaveBeenCalled();
|
||||
expect(mocks.success).toHaveBeenCalledOnce();
|
||||
}
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platform.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refreshes the remaining installation after uninstall instead of reporting a broken install", async () => {
|
||||
await renderApps();
|
||||
mocks.probeToolInstallations.mockResolvedValueOnce([
|
||||
|
||||
Reference in new issue
Block a user