diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index f5bf331fc..b1dcc3f92 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::services::ProviderService; use once_cell::sync::Lazy; use regex::Regex; +use std::collections::HashMap; use std::path::Path; use std::str::FromStr; use tauri::AppHandle; @@ -85,50 +86,122 @@ pub struct ToolVersion { version: Option, latest_version: Option, // 新增字段:最新版本 error: Option, + /// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown" + env_type: String, + /// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells) + wsl_distro: Option, +} + +const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"]; + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslShellPreferenceInput { + #[serde(default)] + pub wsl_shell: Option, + #[serde(default)] + pub wsl_shell_flag: Option, +} + +// Keep platform-specific env detection in one place to avoid repeating cfg blocks. +#[cfg(target_os = "windows")] +fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option) { + if let Some(distro) = wsl_distro_for_tool(tool) { + ("wsl".to_string(), Some(distro)) + } else { + ("windows".to_string(), None) + } +} + +#[cfg(target_os = "macos")] +fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option) { + ("macos".to_string(), None) +} + +#[cfg(target_os = "linux")] +fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option) { + ("linux".to_string(), None) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option) { + ("unknown".to_string(), None) } #[tauri::command] -pub async fn get_tool_versions() -> Result, String> { - let tools = vec!["claude", "codex", "gemini", "opencode"]; +pub async fn get_tool_versions( + tools: Option>, + wsl_shell_by_tool: Option>, +) -> Result, String> { + let requested: Vec<&str> = if let Some(tools) = tools.as_ref() { + let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect(); + VALID_TOOLS + .iter() + .copied() + .filter(|t| set.contains(t)) + .collect() + } else { + VALID_TOOLS.to_vec() + }; let mut results = Vec::new(); + for tool in requested { + let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool)); + let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref()); + let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref()); + + results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await); + } + + Ok(results) +} + +/// 获取单个工具的版本信息(内部实现) +async fn get_single_tool_version_impl( + tool: &str, + wsl_shell: Option<&str>, + wsl_shell_flag: Option<&str>, +) -> ToolVersion { + debug_assert!( + VALID_TOOLS.contains(&tool), + "unexpected tool name in get_single_tool_version_impl: {tool}" + ); + + // 判断该工具的运行环境 & WSL distro(如有) + let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool); + // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); - for tool in tools { - // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 - let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) { - try_get_version_wsl(tool, &distro) + // 1. 获取本地版本 + let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() { + try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag) + } else { + let direct_result = try_get_version(tool); + if direct_result.0.is_some() { + direct_result } else { - // 先尝试直接执行 - let direct_result = try_get_version(tool); + scan_cli_version(tool) + } + }; - if direct_result.0.is_some() { - direct_result - } else { - // 扫描常见的 npm 全局安装路径 - scan_cli_version(tool) - } - }; + // 2. 获取远程最新版本 + let latest_version = match tool { + "claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await, + "codex" => fetch_npm_latest_version(&client, "@openai/codex").await, + "gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, + "opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await, + _ => None, + }; - // 2. 获取远程最新版本 - let latest_version = match tool { - "claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await, - "codex" => fetch_npm_latest_version(&client, "@openai/codex").await, - "gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, - "opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await, - _ => None, - }; - - results.push(ToolVersion { - name: tool.to_string(), - version: local_version, - latest_version, - error: local_error, - }); + ToolVersion { + name: tool.to_string(), + version: local_version, + latest_version, + error: local_error, + env_type, + wsl_distro, } - - Ok(results) } /// Helper function to fetch latest version from npm registry @@ -242,8 +315,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } +/// Validate that the given shell name is one of the allowed shells. #[cfg(target_os = "windows")] -fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option) { +fn is_valid_shell(shell: &str) -> bool { + matches!( + shell.rsplit('/').next().unwrap_or(shell), + "sh" | "bash" | "zsh" | "fish" | "dash" + ) +} + +/// Validate that the given shell flag is one of the allowed flags. +#[cfg(target_os = "windows")] +fn is_valid_shell_flag(flag: &str) -> bool { + matches!(flag, "-c" | "-lc" | "-lic") +} + +/// Return the default invocation flag for the given shell. +#[cfg(target_os = "windows")] +fn default_flag_for_shell(shell: &str) -> &'static str { + match shell.rsplit('/').next().unwrap_or(shell) { + "dash" | "sh" => "-c", + "fish" => "-lc", + _ => "-lic", + } +} + +#[cfg(target_os = "windows")] +fn try_get_version_wsl( + tool: &str, + distro: &str, + force_shell: Option<&str>, + force_shell_flag: Option<&str>, +) -> (Option, Option) { use std::process::Command; // 防御性断言:tool 只能是预定义的值 @@ -257,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'" + ) + }; + + ("sh".to_string(), "-c", cmd) + }; + let output = Command::new("wsl.exe") - .args([ - "-d", - distro, - "--", - "sh", - "-lc", - &format!("{tool} --version"), - ]) + .args(["-d", distro, "--", &shell, flag, &cmd]) .creation_flags(CREATE_NO_WINDOW) .output(); @@ -306,7 +441,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option (Option, Option) { +fn try_get_version_wsl( + _tool: &str, + _distro: &str, + _force_shell: Option<&str>, + _force_shell_flag: Option<&str>, +) -> (Option, Option) { ( None, Some("WSL check not supported on this platform".to_string()), @@ -1061,6 +1201,63 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn test_extract_version() { + assert_eq!(extract_version("claude 1.0.20"), "1.0.20"); + assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1"); + assert_eq!(extract_version("no version here"), "no version here"); + } + + #[cfg(target_os = "windows")] + mod wsl_helpers { + use super::super::*; + + #[test] + fn test_is_valid_shell() { + assert!(is_valid_shell("bash")); + assert!(is_valid_shell("zsh")); + assert!(is_valid_shell("sh")); + assert!(is_valid_shell("fish")); + assert!(is_valid_shell("dash")); + assert!(is_valid_shell("/usr/bin/bash")); + assert!(is_valid_shell("/bin/zsh")); + assert!(!is_valid_shell("powershell")); + assert!(!is_valid_shell("cmd")); + assert!(!is_valid_shell("")); + } + + #[test] + fn test_is_valid_shell_flag() { + assert!(is_valid_shell_flag("-c")); + assert!(is_valid_shell_flag("-lc")); + assert!(is_valid_shell_flag("-lic")); + assert!(!is_valid_shell_flag("-x")); + assert!(!is_valid_shell_flag("")); + assert!(!is_valid_shell_flag("--login")); + } + + #[test] + fn test_default_flag_for_shell() { + assert_eq!(default_flag_for_shell("sh"), "-c"); + assert_eq!(default_flag_for_shell("dash"), "-c"); + assert_eq!(default_flag_for_shell("/bin/dash"), "-c"); + assert_eq!(default_flag_for_shell("fish"), "-lc"); + assert_eq!(default_flag_for_shell("bash"), "-lic"); + assert_eq!(default_flag_for_shell("zsh"), "-lic"); + assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic"); + } + + #[test] + fn test_is_valid_wsl_distro_name() { + assert!(is_valid_wsl_distro_name("Ubuntu")); + assert!(is_valid_wsl_distro_name("Ubuntu-22.04")); + assert!(is_valid_wsl_distro_name("my_distro")); + assert!(!is_valid_wsl_distro_name("")); + assert!(!is_valid_wsl_distro_name("distro with spaces")); + assert!(!is_valid_wsl_distro_name(&"a".repeat(65))); + } + } + #[test] fn opencode_extra_search_paths_includes_install_and_fallback_dirs() { let home = PathBuf::from("/home/tester"); diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index dab867d92..9a7df5491 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -11,6 +11,13 @@ import { AlertCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getVersion } from "@tauri-apps/api/app"; @@ -30,8 +37,48 @@ interface ToolVersion { version: string | null; latest_version: string | null; error: string | null; + env_type: "windows" | "wsl" | "macos" | "linux" | "unknown"; + wsl_distro: string | null; } +const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const; +type ToolName = (typeof TOOL_NAMES)[number]; + +type WslShellPreference = { + wslShell?: string | null; + wslShellFlag?: string | null; +}; + +const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const; +// UI-friendly order: login shell first. +const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const; + +const ENV_BADGE_CONFIG: Record< + string, + { labelKey: string; className: string } +> = { + wsl: { + labelKey: "settings.envBadge.wsl", + className: + "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", + }, + windows: { + labelKey: "settings.envBadge.windows", + className: + "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + }, + macos: { + labelKey: "settings.envBadge.macos", + className: + "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20", + }, + linux: { + labelKey: "settings.envBadge.linux", + className: + "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", + }, +}; + const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended) curl -fsSL https://claude.ai/install.sh | bash # Codex @@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) { isChecking, } = useUpdate(); - const loadToolVersions = useCallback(async () => { + const [wslShellByTool, setWslShellByTool] = useState< + Record + >({}); + const [loadingTools, setLoadingTools] = useState>({}); + + const refreshToolVersions = useCallback( + async ( + toolNames: ToolName[], + wslOverrides?: Record, + ) => { + if (toolNames.length === 0) return; + + // 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。 + setLoadingTools((prev) => { + const next = { ...prev }; + for (const name of toolNames) next[name] = true; + return next; + }); + + try { + const updated = await settingsApi.getToolVersions( + toolNames, + wslOverrides, + ); + + setToolVersions((prev) => { + if (prev.length === 0) return updated; + const byName = new Map(updated.map((t) => [t.name, t])); + const merged = prev.map((t) => byName.get(t.name) ?? t); + const existing = new Set(prev.map((t) => t.name)); + for (const u of updated) { + if (!existing.has(u.name)) merged.push(u); + } + return merged; + }); + } catch (error) { + console.error("[AboutSection] Failed to refresh tools", error); + } finally { + setLoadingTools((prev) => { + const next = { ...prev }; + for (const name of toolNames) next[name] = false; + return next; + }); + } + }, + [], + ); + + const loadAllToolVersions = useCallback(async () => { setIsLoadingTools(true); try { - const tools = await settingsApi.getToolVersions(); - setToolVersions(tools); + // Respect current UI overrides (shell / flag) when doing a full refresh. + const versions = await settingsApi.getToolVersions( + [...TOOL_NAMES], + wslShellByTool, + ); + setToolVersions(versions); } catch (error) { console.error("[AboutSection] Failed to load tool versions", error); } finally { setIsLoadingTools(false); } - }, []); + }, [wslShellByTool]); + + const handleToolShellChange = async (toolName: ToolName, value: string) => { + const wslShell = value === "auto" ? null : value; + const nextPref: WslShellPreference = { + ...(wslShellByTool[toolName] ?? {}), + wslShell, + }; + setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref })); + await refreshToolVersions([toolName], { [toolName]: nextPref }); + }; + + const handleToolShellFlagChange = async ( + toolName: ToolName, + value: string, + ) => { + const wslShellFlag = value === "auto" ? null : value; + const nextPref: WslShellPreference = { + ...(wslShellByTool[toolName] ?? {}), + wslShellFlag, + }; + setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref })); + await refreshToolVersions([toolName], { [toolName]: nextPref }); + }; useEffect(() => { let active = true; const load = async () => { try { - const [appVersion, tools] = await Promise.all([ + const [appVersion] = await Promise.all([ getVersion(), - settingsApi.getToolVersions(), + loadAllToolVersions(), ]); if (active) { setVersion(appVersion); - setToolVersions(tools); } } catch (error) { console.error("[AboutSection] Failed to load info", error); @@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) { } finally { if (active) { setIsLoadingVersion(false); - setIsLoadingTools(false); } } }; @@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) { return () => { active = false; }; + // Mount-only: loadAllToolVersions is intentionally excluded to avoid + // re-fetching all tools whenever wslShellByTool changes. Single-tool + // refreshes are handled by refreshToolVersions in the shell/flag handlers. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ... @@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { size="sm" variant="outline" className="h-7 gap-1.5 text-xs" - onClick={loadToolVersions} + onClick={() => loadAllToolVersions()} disabled={isLoadingTools} > +
- {["claude", "codex", "gemini", "opencode"].map((toolName, index) => { + {TOOL_NAMES.map((toolName, index) => { const tool = toolVersions.find((item) => item.name === toolName); // Special case for OpenCode (capital C), others use capitalize const displayName = @@ -340,8 +465,64 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{displayName} + {/* Environment Badge */} + {tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && ( + + {t(ENV_BADGE_CONFIG[tool.env_type].labelKey)} + + )} + {/* WSL Shell Selector */} + {tool?.env_type === "wsl" && ( + + )} + {/* WSL Shell Flag Selector */} + {tool?.env_type === "wsl" && ( + + )}
- {isLoadingTools ? ( + {isLoadingTools || loadingTools[toolName] ? ( ) : tool?.version ? (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 99c7ae6ef..e79bc8954 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -37,7 +37,8 @@ "search": "Search", "reset": "Reset", "actions": "Actions", - "deleting": "Deleting..." + "deleting": "Deleting...", + "auto": "Auto" }, "apiKeyInput": { "placeholder": "Enter API Key", @@ -452,6 +453,14 @@ "oneClickInstall": "One-click Install", "oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode", "localEnvCheck": "Local environment check", + "envBadge": { + "wsl": "WSL", + "windows": "Win", + "macos": "macOS", + "linux": "Linux" + }, + "wslShell": "Shell", + "wslShellFlag": "Flag", "installCommandsCopied": "Install commands copied", "installCommandsCopyFailed": "Copy failed, please copy manually.", "importFailedError": "Import config failed: {{message}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 085bb0441..d09f49071 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -37,7 +37,8 @@ "search": "検索", "reset": "リセット", "actions": "操作", - "deleting": "削除中..." + "deleting": "削除中...", + "auto": "自動" }, "apiKeyInput": { "placeholder": "API Key を入力", @@ -452,6 +453,14 @@ "oneClickInstall": "ワンクリックインストール", "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール", "localEnvCheck": "ローカル環境チェック", + "envBadge": { + "wsl": "WSL", + "windows": "Win", + "macos": "macOS", + "linux": "Linux" + }, + "wslShell": "Shell", + "wslShellFlag": "フラグ", "installCommandsCopied": "インストールコマンドをコピーしました", "installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。", "importFailedError": "設定のインポートに失敗しました: {{message}}", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cd106dfe2..a56ba9a29 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -37,7 +37,8 @@ "search": "查询", "reset": "重置", "actions": "操作", - "deleting": "删除中..." + "deleting": "删除中...", + "auto": "自动" }, "apiKeyInput": { "placeholder": "请输入API Key", @@ -452,6 +453,14 @@ "oneClickInstall": "一键安装", "oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode", "localEnvCheck": "本地环境检查", + "envBadge": { + "wsl": "WSL", + "windows": "Win", + "macos": "macOS", + "linux": "Linux" + }, + "wslShell": "Shell", + "wslShellFlag": "标志", "installCommandsCopied": "安装命令已复制", "installCommandsCopyFailed": "复制失败,请手动复制。", "importFailedError": "导入配置失败:{{message}}", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 6b791659d..5649dce7a 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -169,15 +169,23 @@ export const settingsApi = { return await invoke("get_auto_launch_status"); }, - async getToolVersions(): Promise< + async getToolVersions( + tools?: string[], + wslShellByTool?: Record< + string, + { wslShell?: string | null; wslShellFlag?: string | null } + >, + ): Promise< Array<{ name: string; version: string | null; latest_version: string | null; error: string | null; + env_type: "windows" | "wsl" | "macos" | "linux" | "unknown"; + wsl_distro: string | null; }> > { - return await invoke("get_tool_versions"); + return await invoke("get_tool_versions", { tools, wslShellByTool }); }, async getRectifierConfig(): Promise {