From ee69c83687a522c110c186719f611dcb6cca0c8f Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 17:46:35 +0800 Subject: [PATCH] Fix garbled output and false "not runnable" in Windows version probe The version probe ran `cmd /C "\"{path}\" --version"` on Windows. Rust's own arg quoting plus the manual quotes produced nested quotes that cmd (without /S) misparsed, so it reported even working tools as an unrecognized command -> non-zero exit -> shown as "installed but not runnable". cmd's error is emitted in the OEM code page (e.g. GBK on zh-CN) and was decoded as UTF-8, turning it into mojibake. - Add decode_command_output: try strict UTF-8, then OEM (GetOEMCP), then ANSI (GetACP) code page via MultiByteToWideChar; route all command output decoding through it (probes, lifecycle output, osascript, terminal launchers). - Add run_windows_tool_version_command: run .exe directly (no cmd); invoke .cmd/.bat via `cmd /D /S /C call --version` with raw_arg to bypass Rust's quoting and keep deterministic quote handling. - Add windows-sys Win32_Globalization dependency. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/misc.rs | 168 ++++++++++++++++++++++++++------- 3 files changed, 135 insertions(+), 35 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5a46910a7..aef068569 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -798,6 +798,7 @@ dependencies = [ "uuid", "webkit2gtk", "webpki-roots 0.26.11", + "windows-sys 0.61.2", "winreg 0.52.0", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc5b62a82..8a6bcfbd6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -88,6 +88,7 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" +windows-sys = { version = "0.61", features = ["Win32_Globalization"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 0d4d8ac6e..81aa1328d 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -249,8 +249,8 @@ fn finish_lifecycle_output(output: &std::process::Output) -> Result<(), String> if output.status.success() { return Ok(()); } - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = decode_command_output(&output.stderr); + let stdout = decode_command_output(&output.stdout); let raw = if stderr.trim().is_empty() { stdout.trim() } else { @@ -271,6 +271,81 @@ fn last_lines(text: &str, n: usize) -> String { lines[start..].join("\n") } +fn decode_command_output(bytes: &[u8]) -> String { + #[cfg(target_os = "windows")] + { + decode_windows_command_output(bytes) + } + + #[cfg(not(target_os = "windows"))] + { + String::from_utf8_lossy(bytes).into_owned() + } +} + +#[cfg(target_os = "windows")] +fn decode_windows_command_output(bytes: &[u8]) -> String { + if bytes.is_empty() { + return String::new(); + } + + if let Ok(text) = std::str::from_utf8(bytes) { + return text.to_string(); + } + + use windows_sys::Win32::Globalization::{GetACP, GetOEMCP, MultiByteToWideChar}; + + fn decode_codepage(bytes: &[u8], codepage: u32) -> Option { + if codepage == 0 { + return None; + } + + let input_len = i32::try_from(bytes.len()).ok()?; + unsafe { + let wide_len = MultiByteToWideChar( + codepage, + 0, + bytes.as_ptr(), + input_len, + std::ptr::null_mut(), + 0, + ); + if wide_len <= 0 { + return None; + } + + let mut wide = vec![0u16; wide_len as usize]; + let written = MultiByteToWideChar( + codepage, + 0, + bytes.as_ptr(), + input_len, + wide.as_mut_ptr(), + wide_len, + ); + if written <= 0 { + return None; + } + + Some(String::from_utf16_lossy(&wide[..written as usize])) + } + } + + let oem_cp = unsafe { GetOEMCP() }; + if let Some(decoded) = decode_codepage(bytes, oem_cp) { + return decoded; + } + + let ansi_cp = unsafe { GetACP() }; + if ansi_cp != oem_cp { + if let Some(decoded) = decode_codepage(bytes, ansi_cp) { + return decoded; + } + } + + String::from_utf8_lossy(bytes).into_owned() +} + fn normalize_requested_tools(tools: &[String]) -> Vec<&'static str> { let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect(); VALID_TOOLS @@ -936,8 +1011,8 @@ fn try_get_version(tool: &str) -> ShellProbe { match output { Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = decode_command_output(&out.stdout).trim().to_string(); + let stderr = decode_command_output(&out.stderr).trim().to_string(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if raw.is_empty() { @@ -1051,8 +1126,8 @@ fn try_get_version_wsl( match output { Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = decode_command_output(&out.stdout).trim().to_string(); + let stderr = decode_command_output(&out.stderr).trim().to_string(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if raw.is_empty() { @@ -1431,8 +1506,43 @@ fn build_tool_search_paths(tool: &str) -> Vec { search_paths } +#[cfg(target_os = "windows")] +fn is_windows_command_script(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + .unwrap_or(false) +} + +#[cfg(target_os = "windows")] +fn run_windows_tool_version_command( + tool_path: &Path, + new_path: &str, +) -> std::io::Result { + use std::process::Command; + + if is_windows_command_script(tool_path) { + let path = tool_path.to_string_lossy(); + let command = format!("call {} --version", win_quote_path_for_batch(&path)); + let mut cmd = Command::new("cmd"); + return cmd + .args(["/D", "/S", "/C"]) + .raw_arg(&command) + .env("PATH", new_path) + .creation_flags(CREATE_NO_WINDOW) + .output(); + } + + Command::new(tool_path) + .arg("--version") + .env("PATH", new_path) + .creation_flags(CREATE_NO_WINDOW) + .output() +} + /// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。 fn scan_cli_version(tool: &str) -> ShellProbe { + #[cfg(not(target_os = "windows"))] use std::process::Command; let search_paths = build_tool_search_paths(tool); @@ -1458,13 +1568,7 @@ fn scan_cli_version(tool: &str) -> ShellProbe { } #[cfg(target_os = "windows")] - let output = { - Command::new("cmd") - .args(["/C", &format!("\"{}\" --version", tool_path.display())]) - .env("PATH", &new_path) - .creation_flags(CREATE_NO_WINDOW) - .output() - }; + let output = run_windows_tool_version_command(&tool_path, &new_path); #[cfg(not(target_os = "windows"))] let output = { @@ -1475,8 +1579,8 @@ fn scan_cli_version(tool: &str) -> ShellProbe { }; if let Ok(out) = output { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = decode_command_output(&out.stdout).trim().to_string(); + let stderr = decode_command_output(&out.stderr).trim().to_string(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if !raw.is_empty() { @@ -1588,7 +1692,7 @@ fn resolve_path_default(tool: &str) -> Option { if !out.status.success() { return None; } - let raw = String::from_utf8_lossy(&out.stdout); + let raw = decode_command_output(&out.stdout); // 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"), // command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。 let first = first_abs_path_line(&raw)?; @@ -1607,7 +1711,7 @@ fn resolve_path_default(tool: &str) -> Option { if !out.status.success() { return None; } - let raw = String::from_utf8_lossy(&out.stdout); + let raw = decode_command_output(&out.stdout); let first = raw.lines().next()?.trim(); if first.is_empty() { return None; @@ -1619,6 +1723,7 @@ fn resolve_path_default(tool: &str) -> Option { /// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实 /// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。 fn enumerate_tool_installations(tool: &str) -> Vec { + #[cfg(not(target_os = "windows"))] use std::process::Command; let search_paths = build_tool_search_paths(tool); @@ -1648,14 +1753,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec { } #[cfg(target_os = "windows")] - let output = { - use std::os::windows::process::CommandExt; - Command::new("cmd") - .args(["/C", &format!("\"{}\" --version", tool_path.display())]) - .env("PATH", &new_path) - .creation_flags(CREATE_NO_WINDOW) - .output() - }; + let output = run_windows_tool_version_command(&tool_path, &new_path); #[cfg(not(target_os = "windows"))] let output = Command::new(&tool_path) .arg("--version") @@ -1664,14 +1762,14 @@ fn enumerate_tool_installations(tool: &str) -> Vec { let (version, runnable, error) = match output { Ok(out) if out.status.success() => { - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = decode_command_output(&out.stdout).trim().to_string(); + let stderr = decode_command_output(&out.stderr).trim().to_string(); let raw = if stdout.is_empty() { stderr } else { stdout }; (Some(extract_version(&raw)), true, None) } Ok(out) => { - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = decode_command_output(&out.stderr).trim().to_string(); + let stdout = decode_command_output(&out.stdout).trim().to_string(); let detail = if stderr.is_empty() { stdout } else { stderr }; let detail = detail.trim(); let error = if detail.is_empty() { @@ -2542,7 +2640,7 @@ end tell"#, .map_err(|e| format!("执行 osascript 失败: {e}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "Terminal.app 执行失败 (exit code: {:?}): {}", output.status.code(), @@ -2603,7 +2701,7 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("执行 osascript 失败: {e}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "iTerm2 执行失败 (exit code: {:?}): {}", output.status.code(), @@ -2633,7 +2731,7 @@ fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("启动 Ghostty 失败: {e}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "Ghostty 启动失败 (exit code: {:?}): {}", output.status.code(), @@ -2666,7 +2764,7 @@ fn launch_macos_open_app( .map_err(|e| format!("启动 {app_name} 失败: {e}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "{} 启动失败 (exit code: {:?}): {}", app_name, @@ -2718,7 +2816,7 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> { let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "Warp 启动失败 (exit code: {:?}): {}", output.status.code(), @@ -2961,7 +3059,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S .map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = decode_command_output(&output.stderr); return Err(format!( "{} 启动失败 (exit code: {:?}): {}", terminal_name,