#![allow(non_snake_case)] use crate::app_config::AppType; 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, PathBuf}; use std::str::FromStr; use tauri::AppHandle; use tauri::State; use tauri_plugin_opener::OpenerExt; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; /// 打开外部链接 #[tauri::command] pub async fn open_external(app: AppHandle, url: String) -> Result { let url = if url.starts_with("http://") || url.starts_with("https://") { url } else { format!("https://{url}") }; app.opener() .open_url(&url, None::) .map_err(|e| format!("打开链接失败: {e}"))?; Ok(true) } #[tauri::command] pub async fn copy_text_to_clipboard(text: String) -> Result { // Use spawn_blocking to avoid blocking the async runtime // Clipboard access can block on some platforms and may have thread/loop constraints tokio::task::spawn_blocking(move || { let mut clipboard = arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?; clipboard .set_text(text) .map_err(|e| format!("写入系统剪贴板失败: {e}"))?; Ok(true) }) .await .map_err(|e| format!("剪贴板任务执行失败: {e}"))? } /// 检查更新 #[tauri::command] pub async fn check_for_updates(handle: AppHandle) -> Result { handle .opener() .open_url( "https://github.com/farion1231/cc-switch/releases/latest", None::, ) .map_err(|e| format!("打开更新页面失败: {e}"))?; Ok(true) } /// 判断是否为便携版(绿色版)运行 #[tauri::command] pub async fn is_portable_mode() -> Result { let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?; if let Some(dir) = exe_path.parent() { Ok(dir.join("portable.ini").is_file()) } else { Ok(false) } } /// 获取应用启动阶段的初始化错误(若有)。 /// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。 #[tauri::command] pub async fn get_init_error() -> Result, String> { Ok(crate::init_status::get_init_error()) } /// 获取 JSON→SQLite 迁移结果(若有)。 /// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。 #[tauri::command] pub async fn get_migration_result() -> Result { Ok(crate::init_status::take_migration_success()) } /// 获取 Skills 自动导入(SSOT)迁移结果(若有)。 /// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。 #[tauri::command] pub async fn get_skills_migration_result() -> Result, String> { Ok(crate::init_status::take_skills_migration_result()) } #[derive(serde::Serialize)] pub struct ToolVersion { name: String, 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( tools: Option>, wsl_shell_by_tool: Option>, ) -> Result, String> { // Windows: completely disable tool version detection to prevent // accidentally launching apps (e.g. Claude Code) via protocol handlers. #[cfg(target_os = "windows")] { let _ = (tools, wsl_shell_by_tool); return Ok(Vec::new()); } #[cfg(not(target_os = "windows"))] { 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(); // 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 { 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, }; ToolVersion { name: tool.to_string(), version: local_version, latest_version, error: local_error, env_type, wsl_distro, } } /// Helper function to fetch latest version from npm registry async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option { let url = format!("https://registry.npmjs.org/{package}"); match client.get(&url).send().await { Ok(resp) => { if let Ok(json) = resp.json::().await { json.get("dist-tags") .and_then(|tags| tags.get("latest")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) } else { None } } Err(_) => None, } } /// Helper function to fetch latest version from GitHub releases async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option { let url = format!("https://api.github.com/repos/{repo}/releases/latest"); match client .get(&url) .header("User-Agent", "cc-switch") .header("Accept", "application/vnd.github+json") .send() .await { Ok(resp) => { if let Ok(json) = resp.json::().await { json.get("tag_name") .and_then(|v| v.as_str()) .map(|s| s.strip_prefix('v').unwrap_or(s).to_string()) } else { None } } Err(_) => None, } } /// 预编译的版本号正则表达式 static VERSION_RE: Lazy = Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex")); /// 从版本输出中提取纯版本号 fn extract_version(raw: &str) -> String { VERSION_RE .find(raw) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| raw.to_string()) } /// 尝试直接执行命令获取版本 fn try_get_version(tool: &str) -> (Option, Option) { use std::process::Command; #[cfg(target_os = "windows")] let output = { Command::new("cmd") .args(["/C", &format!("{tool} --version")]) .creation_flags(CREATE_NO_WINDOW) .output() }; #[cfg(not(target_os = "windows"))] let output = { Command::new("sh") .arg("-c") .arg(format!("{tool} --version")) .output() }; 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(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if raw.is_empty() { (None, Some("not installed or not executable".to_string())) } else { (Some(extract_version(raw)), None) } } else { let err = if stderr.is_empty() { stdout } else { stderr }; ( None, Some(if err.is_empty() { "not installed or not executable".to_string() } else { err }), ) } } Err(e) => (None, Some(e.to_string())), } } /// 校验 WSL 发行版名称是否合法 /// WSL 发行版名称只允许字母、数字、连字符和下划线 #[cfg(target_os = "windows")] fn is_valid_wsl_distro_name(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && name .chars() .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 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 只能是预定义的值 debug_assert!( ["claude", "codex", "gemini", "opencode"].contains(&tool), "unexpected tool name: {tool}" ); // 校验 distro 名称,防止命令注入 if !is_valid_wsl_distro_name(distro) { return (None, Some(format!("[WSL:{distro}] invalid distro name"))); } // 构建 Shell 脚本检测逻辑 let (shell, flag, cmd) = if let Some(shell) = force_shell { // Defensive validation: never allow an arbitrary executable name here. if !is_valid_shell(shell) { return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}"))); } let shell = shell.rsplit('/').next().unwrap_or(shell); let flag = if let Some(flag) = force_shell_flag { if !is_valid_shell_flag(flag) { return ( None, Some(format!("[WSL:{distro}] invalid shell flag: {flag}")), ); } flag } else { default_flag_for_shell(shell) }; (shell.to_string(), flag, format!("{tool} --version")) } else { let cmd = if let Some(flag) = force_shell_flag { if !is_valid_shell_flag(flag) { return ( None, Some(format!("[WSL:{distro}] invalid shell flag: {flag}")), ); } format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'") } else { // 兜底:自动尝试 -lic, -lc, -c format!( "\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/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, "--", &shell, flag, &cmd]) .creation_flags(CREATE_NO_WINDOW) .output(); 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(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if raw.is_empty() { ( None, Some(format!("[WSL:{distro}] not installed or not executable")), ) } else { (Some(extract_version(raw)), None) } } else { let err = if stderr.is_empty() { stdout } else { stderr }; ( None, Some(format!( "[WSL:{distro}] {}", if err.is_empty() { "not installed or not executable".to_string() } else { err } )), ) } } Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))), } } /// 非 Windows 平台的 WSL 版本检测存根 /// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。 /// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。 #[cfg(not(target_os = "windows"))] 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()), ) } fn push_unique_path(paths: &mut Vec, path: std::path::PathBuf) { if path.as_os_str().is_empty() { return; } if !paths.iter().any(|existing| existing == &path) { paths.push(path); } } fn push_env_single_dir(paths: &mut Vec, value: Option) { if let Some(raw) = value { push_unique_path(paths, std::path::PathBuf::from(raw)); } } fn extend_from_path_list( paths: &mut Vec, value: Option, suffix: Option<&str>, ) { if let Some(raw) = value { for p in std::env::split_paths(&raw) { let dir = match suffix { Some(s) => p.join(s), None => p, }; push_unique_path(paths, dir); } } } /// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README): /// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin /// 额外扫描 Bun 默认全局安装路径(~/.bun/bin) /// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。 fn opencode_extra_search_paths( home: &Path, opencode_install_dir: Option, xdg_bin_dir: Option, gopath: Option, ) -> Vec { let mut paths = Vec::new(); push_env_single_dir(&mut paths, opencode_install_dir); push_env_single_dir(&mut paths, xdg_bin_dir); if !home.as_os_str().is_empty() { push_unique_path(&mut paths, home.join("bin")); push_unique_path(&mut paths, home.join(".opencode").join("bin")); push_unique_path(&mut paths, home.join(".bun").join("bin")); push_unique_path(&mut paths, home.join("go").join("bin")); } extend_from_path_list(&mut paths, gopath, Some("bin")); paths } fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec { #[cfg(target_os = "windows")] { vec![ dir.join(format!("{tool}.cmd")), dir.join(format!("{tool}.exe")), dir.join(tool), ] } #[cfg(not(target_os = "windows"))] { vec![dir.join(tool)] } } /// 扫描常见路径查找 CLI fn scan_cli_version(tool: &str) -> (Option, Option) { use std::process::Command; let home = dirs::home_dir().unwrap_or_default(); // 常见的安装路径(原生安装优先) let mut search_paths: Vec = Vec::new(); if !home.as_os_str().is_empty() { push_unique_path(&mut search_paths, home.join(".local/bin")); push_unique_path(&mut search_paths, home.join(".npm-global/bin")); push_unique_path(&mut search_paths, home.join("n/bin")); push_unique_path(&mut search_paths, home.join(".volta/bin")); } #[cfg(target_os = "macos")] { push_unique_path( &mut search_paths, std::path::PathBuf::from("/opt/homebrew/bin"), ); push_unique_path( &mut search_paths, std::path::PathBuf::from("/usr/local/bin"), ); } #[cfg(target_os = "linux")] { push_unique_path( &mut search_paths, std::path::PathBuf::from("/usr/local/bin"), ); push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin")); } #[cfg(target_os = "windows")] { if let Some(appdata) = dirs::data_dir() { push_unique_path(&mut search_paths, appdata.join("npm")); } push_unique_path( &mut search_paths, std::path::PathBuf::from("C:\\Program Files\\nodejs"), ); } let fnm_base = home.join(".local/state/fnm_multishells"); if fnm_base.exists() { if let Ok(entries) = std::fs::read_dir(&fnm_base) { for entry in entries.flatten() { let bin_path = entry.path().join("bin"); if bin_path.exists() { push_unique_path(&mut search_paths, bin_path); } } } } let nvm_base = home.join(".nvm/versions/node"); if nvm_base.exists() { if let Ok(entries) = std::fs::read_dir(&nvm_base) { for entry in entries.flatten() { let bin_path = entry.path().join("bin"); if bin_path.exists() { push_unique_path(&mut search_paths, bin_path); } } } } if tool == "opencode" { let extra_paths = opencode_extra_search_paths( &home, std::env::var_os("OPENCODE_INSTALL_DIR"), std::env::var_os("XDG_BIN_DIR"), std::env::var_os("GOPATH"), ); for path in extra_paths { push_unique_path(&mut search_paths, path); } } let current_path = std::env::var("PATH").unwrap_or_default(); for path in &search_paths { #[cfg(target_os = "windows")] let new_path = format!("{};{}", path.display(), current_path); #[cfg(not(target_os = "windows"))] let new_path = format!("{}:{}", path.display(), current_path); for tool_path in tool_executable_candidates(tool, path) { if !tool_path.exists() { continue; } #[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() }; #[cfg(not(target_os = "windows"))] let output = { Command::new(&tool_path) .arg("--version") .env("PATH", &new_path) .output() }; 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(); if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if !raw.is_empty() { return (Some(extract_version(raw)), None); } } } } } (None, Some("not installed or not executable".to_string())) } #[cfg(target_os = "windows")] fn wsl_distro_for_tool(tool: &str) -> Option { let override_dir = match tool { "claude" => crate::settings::get_claude_override_dir(), "codex" => crate::settings::get_codex_override_dir(), "gemini" => crate::settings::get_gemini_override_dir(), "opencode" => crate::settings::get_opencode_override_dir(), _ => None, }?; wsl_distro_from_path(&override_dir) } /// 从 UNC 路径中提取 WSL 发行版名称 /// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式 #[cfg(target_os = "windows")] fn wsl_distro_from_path(path: &Path) -> Option { use std::path::{Component, Prefix}; let Some(Component::Prefix(prefix)) = path.components().next() else { return None; }; match prefix.kind() { Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { let server_name = server.to_string_lossy(); if server_name.eq_ignore_ascii_case("wsl$") || server_name.eq_ignore_ascii_case("wsl.localhost") { let distro = share.to_string_lossy().to_string(); if !distro.is_empty() { return Some(distro); } } None } _ => None, } } /// 打开指定提供商的终端 /// /// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端 /// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端 #[allow(non_snake_case)] #[tauri::command] pub async fn open_provider_terminal( state: State<'_, crate::store::AppState>, app: String, #[allow(non_snake_case)] providerId: String, cwd: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let launch_cwd = resolve_launch_cwd(cwd)?; // 获取提供商配置 let providers = ProviderService::list(state.inner(), app_type.clone()) .map_err(|e| format!("获取提供商列表失败: {e}"))?; let provider = providers .get(&providerId) .ok_or_else(|| format!("提供商 {providerId} 不存在"))?; // 从提供商配置中提取环境变量 let config = &provider.settings_config; let env_vars = extract_env_vars_from_config(config, &app_type); // 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名 launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref()) .map_err(|e| format!("启动终端失败: {e}"))?; Ok(true) } /// 从提供商配置中提取环境变量 fn extract_env_vars_from_config( config: &serde_json::Value, app_type: &AppType, ) -> Vec<(String, String)> { let mut env_vars = Vec::new(); let Some(obj) = config.as_object() else { return env_vars; }; // 处理 env 字段(Claude/Gemini 通用) if let Some(env) = obj.get("env").and_then(|v| v.as_object()) { for (key, value) in env { if let Some(str_val) = value.as_str() { env_vars.push((key.clone(), str_val.to_string())); } } // 处理 base_url: 根据应用类型添加对应的环境变量 let base_url_key = match app_type { AppType::Claude => Some("ANTHROPIC_BASE_URL"), AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"), _ => None, }; if let Some(key) = base_url_key { if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) { env_vars.push((key.to_string(), url_str.to_string())); } } } // Codex 使用 auth 字段转换为 OPENAI_API_KEY if *app_type == AppType::Codex { if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) { env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string())); } } // Gemini 使用 api_key 字段转换为 GEMINI_API_KEY if *app_type == AppType::Gemini { if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) { env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string())); } } env_vars } fn resolve_launch_cwd(cwd: Option) -> Result, String> { let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else { return Ok(None); }; if raw_path.contains('\n') || raw_path.contains('\r') { return Err("目录路径包含非法换行符".to_string()); } let path = Path::new(&raw_path); if !path.exists() { return Err(format!("目录不存在: {raw_path}")); } let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?; if !resolved.is_dir() { return Err(format!("选择的路径不是文件夹: {}", resolved.display())); } // Strip Windows extended-length prefix that canonicalize produces, // as it can break batch scripts and other shell commands. // Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths. #[cfg(target_os = "windows")] let resolved = { let s = resolved.to_string_lossy(); if let Some(unc) = s.strip_prefix(r"\\?\UNC\") { PathBuf::from(format!(r"\\{unc}")) } else if let Some(stripped) = s.strip_prefix(r"\\?\") { PathBuf::from(stripped) } else { resolved } }; Ok(Some(resolved)) } /// 创建临时配置文件并启动 claude 终端 /// 使用 --settings 参数传入提供商特定的 API 配置 fn launch_terminal_with_env( env_vars: Vec<(String, String)>, provider_id: &str, cwd: Option<&Path>, ) -> Result<(), String> { let temp_dir = std::env::temp_dir(); let config_file = temp_dir.join(format!( "claude_{}_{}.json", provider_id, std::process::id() )); // 创建并写入配置文件 write_claude_config(&config_file, &env_vars)?; #[cfg(target_os = "macos")] { launch_macos_terminal(&config_file, cwd)?; Ok(()) } #[cfg(target_os = "linux")] { launch_linux_terminal(&config_file, cwd)?; Ok(()) } #[cfg(target_os = "windows")] { launch_windows_terminal(&temp_dir, &config_file, cwd)?; return Ok(()); } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] Err("不支持的操作系统".to_string()) } /// 写入 claude 配置文件 fn write_claude_config( config_file: &std::path::Path, env_vars: &[(String, String)], ) -> Result<(), String> { let mut config_obj = serde_json::Map::new(); let mut env_obj = serde_json::Map::new(); for (key, value) in env_vars { env_obj.insert(key.clone(), serde_json::Value::String(value.clone())); } config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj)); let config_json = serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?; std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}")) } /// macOS: 根据用户首选终端启动 #[cfg(target_os = "macos")] fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let preferred = crate::settings::get_preferred_terminal(); let terminal = preferred.as_deref().unwrap_or("terminal"); let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); let cd_command = build_shell_cd_command(cwd); // Write the shell script to a temp file let script_content = format!( r#"#!/bin/bash trap 'rm -f "{config_path}" "{script_file}"' EXIT {cd_command} echo "Using provider-specific claude config:" echo "{config_path}" claude --settings "{config_path}" exec bash --norc --noprofile "#, config_path = config_path, script_file = script_file.display(), cd_command = cd_command, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; // Make script executable std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("设置脚本权限失败: {e}"))?; // Try the preferred terminal first, fall back to Terminal.app if it fails // Note: Kitty doesn't need the -e flag, others do let result = match terminal { "iterm2" => launch_macos_iterm2(&script_file), "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), "kaku" => launch_macos_open_app("Kaku", &script_file, true), _ => launch_macos_terminal_app(&script_file), // "terminal" or default }; // If preferred terminal fails and it's not the default, try Terminal.app as fallback if result.is_err() && terminal != "terminal" { log::warn!( "首选终端 {} 启动失败,回退到 Terminal.app: {:?}", terminal, result.as_ref().err() ); return launch_macos_terminal_app(&script_file); } result } /// macOS: Terminal.app #[cfg(target_os = "macos")] fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> { use std::process::Command; let applescript = format!( r#"tell application "Terminal" activate do script "bash '{}'" end tell"#, script_file.display() ); let output = Command::new("osascript") .arg("-e") .arg(&applescript) .output() .map_err(|e| format!("执行 osascript 失败: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "Terminal.app 执行失败 (exit code: {:?}): {}", output.status.code(), stderr )); } Ok(()) } /// macOS: iTerm2 #[cfg(target_os = "macos")] fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { use std::process::Command; let applescript = format!( r#"tell application "iTerm" activate tell current window create tab with default profile tell current session write text "bash '{}'" end tell end tell end tell"#, script_file.display() ); let output = Command::new("osascript") .arg("-e") .arg(&applescript) .output() .map_err(|e| format!("执行 osascript 失败: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "iTerm2 执行失败 (exit code: {:?}): {}", output.status.code(), stderr )); } Ok(()) } /// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty) #[cfg(target_os = "macos")] fn launch_macos_open_app( app_name: &str, script_file: &std::path::Path, use_e_flag: bool, ) -> Result<(), String> { use std::process::Command; let mut cmd = Command::new("open"); cmd.arg("-a").arg(app_name).arg("--args"); if use_e_flag { cmd.arg("-e"); } cmd.arg("bash").arg(script_file); let output = cmd .output() .map_err(|e| format!("启动 {app_name} 失败: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "{} 启动失败 (exit code: {:?}): {}", app_name, output.status.code(), stderr )); } Ok(()) } /// Linux: 根据用户首选终端启动 #[cfg(target_os = "linux")] fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; use std::process::Command; let preferred = crate::settings::get_preferred_terminal(); // Default terminal list with their arguments let default_terminals = [ ("gnome-terminal", vec!["--"]), ("konsole", vec!["-e"]), ("xfce4-terminal", vec!["-e"]), ("mate-terminal", vec!["--"]), ("lxterminal", vec!["-e"]), ("alacritty", vec!["-e"]), ("kitty", vec!["-e"]), ("ghostty", vec!["-e"]), ]; // Create temp script file let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); let cd_command = build_shell_cd_command(cwd); let script_content = format!( r#"#!/bin/bash trap 'rm -f "{config_path}" "{script_file}"' EXIT {cd_command} echo "Using provider-specific claude config:" echo "{config_path}" claude --settings "{config_path}" exec bash --norc --noprofile "#, config_path = config_path, script_file = script_file.display(), cd_command = cd_command, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("设置脚本权限失败: {e}"))?; // Build terminal list: preferred terminal first (if specified), then defaults let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred { // Find the preferred terminal's args from default list let pref_args = default_terminals .iter() .find(|(name, _)| *name == pref.as_str()) .map(|(_, args)| args.to_vec()) .unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals let mut list = vec![(pref.as_str(), pref_args)]; // Add remaining terminals as fallbacks for (name, args) in &default_terminals { if *name != pref.as_str() { list.push((*name, args.to_vec())); } } list } else { default_terminals .iter() .map(|(name, args)| (*name, args.to_vec())) .collect() }; let mut last_error = String::from("未找到可用的终端"); for (terminal, args) in terminals_to_try { // Check if terminal exists in common paths let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists() || std::path::Path::new(&format!("/bin/{}", terminal)).exists() || std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists() || which_command(terminal); if terminal_exists { let result = Command::new(terminal) .args(&args) .arg("bash") .arg(script_file.to_string_lossy().as_ref()) .spawn(); match result { Ok(_) => return Ok(()), Err(e) => { last_error = format!("执行 {} 失败: {}", terminal, e); } } } } // Clean up on failure let _ = std::fs::remove_file(&script_file); let _ = std::fs::remove_file(config_file); Err(last_error) } /// Check if a command exists using `which` #[cfg(target_os = "linux")] fn which_command(cmd: &str) -> bool { use std::process::Command; Command::new("which") .arg(cmd) .output() .map(|o| o.status.success()) .unwrap_or(false) } /// Windows: 根据用户首选终端启动 #[cfg(target_os = "windows")] fn launch_windows_terminal( temp_dir: &std::path::Path, config_file: &std::path::Path, cwd: Option<&Path>, ) -> Result<(), String> { let preferred = crate::settings::get_preferred_terminal(); let terminal = preferred.as_deref().unwrap_or("cmd"); let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy()); let cwd_command = build_windows_cwd_command(cwd); let content = format!( "@echo off {cwd_command} echo Using provider-specific claude config: echo {} claude --settings \"{}\" del \"{}\" >nul 2>&1 del \"%~f0\" >nul 2>&1 ", config_path_for_batch, config_path_for_batch, config_path_for_batch, cwd_command = cwd_command, ); std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; let bat_path = bat_file.to_string_lossy(); let ps_cmd = format!("& '{}'", bat_path); // Try the preferred terminal first let result = match terminal { "powershell" => run_windows_start_command( &["powershell", "-NoExit", "-Command", &ps_cmd], "PowerShell", ), "wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"), _ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default }; // If preferred terminal fails and it's not the default, try cmd as fallback if result.is_err() && terminal != "cmd" { log::warn!( "首选终端 {} 启动失败,回退到 cmd: {:?}", terminal, result.as_ref().err() ); return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"); } result } fn build_shell_cd_command(cwd: Option<&Path>) -> String { cwd.map(|dir| { format!( "cd {} || exit 1\n", shell_single_quote(&dir.to_string_lossy()) ) }) .unwrap_or_default() } fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn is_windows_unc_path(path: &str) -> bool { path.starts_with(r"\\") } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn build_windows_cwd_command_str(path: &str) -> String { let escaped = escape_windows_batch_value(path); if is_windows_unc_path(path) { // `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first. format!("pushd \"{escaped}\" || exit /b 1\r\n") } else { format!("cd /d \"{escaped}\" || exit /b 1\r\n") } } #[cfg(target_os = "windows")] fn build_windows_cwd_command(cwd: Option<&Path>) -> String { cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy())) .unwrap_or_default() } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn escape_windows_batch_value(value: &str) -> String { value .replace('^', "^^") .replace('%', "%%") .replace('&', "^&") .replace('|', "^|") .replace('<', "^<") .replace('>', "^>") .replace('(', "^(") .replace(')', "^)") } /// Windows: Run a start command with common error handling #[cfg(target_os = "windows")] fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> { use std::process::Command; let mut full_args = vec!["/C", "start"]; full_args.extend(args); let output = Command::new("cmd") .args(&full_args) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "{} 启动失败 (exit code: {:?}): {}", terminal_name, output.status.code(), stderr )); } Ok(()) } /// 设置窗口主题(Windows/macOS 标题栏颜色) /// theme: "dark" | "light" | "system" #[tauri::command] pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> { use tauri::Theme; let tauri_theme = match theme.as_str() { "dark" => Some(Theme::Dark), "light" => Some(Theme::Light), _ => None, // system default }; window.set_theme(tauri_theme).map_err(|e| e.to_string()) } #[cfg(test)] 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"); let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin")); let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin")); let gopath = std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok(); let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath); assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin")); assert_eq!(paths[1], PathBuf::from("/xdg/bin")); assert!(paths.contains(&PathBuf::from("/home/tester/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/go/bin"))); assert!(paths.contains(&PathBuf::from("/go/path1/bin"))); assert!(paths.contains(&PathBuf::from("/go/path2/bin"))); } #[test] fn opencode_extra_search_paths_deduplicates_repeated_entries() { let home = PathBuf::from("/home/tester"); let same_dir = Some(std::ffi::OsString::from("/same/path")); let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None); let count = paths .iter() .filter(|path| **path == PathBuf::from("/same/path")) .count(); assert_eq!(count, 1); } #[test] fn opencode_extra_search_paths_deduplicates_bun_default_dir() { let home = PathBuf::from("/home/tester"); let paths = opencode_extra_search_paths(&home, None, None, None); let count = paths .iter() .filter(|path| **path == PathBuf::from("/home/tester/.bun/bin")) .count(); assert_eq!(count, 1); } #[cfg(not(target_os = "windows"))] #[test] fn tool_executable_candidates_non_windows_uses_plain_binary_name() { let dir = PathBuf::from("/usr/local/bin"); let candidates = tool_executable_candidates("opencode", &dir); assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]); } #[cfg(target_os = "windows")] #[test] fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() { let dir = PathBuf::from("C:\\tools"); let candidates = tool_executable_candidates("opencode", &dir); assert_eq!( candidates, vec![ PathBuf::from("C:\\tools\\opencode.cmd"), PathBuf::from("C:\\tools\\opencode.exe"), PathBuf::from("C:\\tools\\opencode"), ] ); } #[test] fn resolve_launch_cwd_accepts_existing_directory() { let resolved = resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned())) .expect("temp dir should resolve") .expect("temp dir should be present"); assert!(resolved.is_dir()); } #[test] fn resolve_launch_cwd_rejects_missing_directory() { let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos(); let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}")); let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned())) .expect_err("missing directory should fail"); assert!(error.contains("目录不存在")); } #[test] fn build_shell_cd_command_quotes_spaces_and_single_quotes() { let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien"))); assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n"); } #[test] fn build_windows_cwd_command_str_uses_cd_for_drive_paths() { let command = build_windows_cwd_command_str(r"C:\work\repo"); assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n"); } #[test] fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() { let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo"); assert_eq!( command, "pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n" ); } #[test] fn build_windows_cwd_command_str_escapes_batch_metacharacters() { let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)"); assert_eq!( command, "pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n" ); } }