use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::PathBuf; use std::process::Command; use std::fs; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; /// Windows 常见 CLI 安装路径扫描 #[cfg(target_os = "windows")] fn scan_windows_cli_paths(cmd: &str) -> Option { let mut common_paths: Vec = Vec::new(); // 常见 Windows 安装路径,按优先级排序(仅加入可推导出的绝对路径,避免空/相对路径误判) if let Some(app_data) = std::env::var_os("APPDATA") { let npm_base = PathBuf::from(app_data).join("npm"); common_paths.push(npm_base.join(format!("{}.cmd", cmd))); common_paths.push(npm_base.join(cmd)); } if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { let pnpm_base = PathBuf::from(&local_app_data).join("pnpm"); common_paths.push(pnpm_base.join(format!("{}.cmd", cmd))); common_paths.push(pnpm_base.join(cmd)); let yarn_base = PathBuf::from(local_app_data).join("Yarn").join("bin"); common_paths.push(yarn_base.join(format!("{}.cmd", cmd))); common_paths.push(yarn_base.join(cmd)); } if let Some(home) = dirs::home_dir() { let bun_base = home.join(".bun").join("bin"); common_paths.push(bun_base.join(format!("{}.exe", cmd))); common_paths.push(bun_base.join(cmd)); } for path in common_paths { if is_safe_path(&path) { tracing::debug!("[CLI-Sync] Detected {} via Windows explicit path: {:?}", cmd, path); return Some(path); } } // 扫描 NVM Windows 目录 if let Ok(nvm_home) = std::env::var("NVM_HOME") { let nvm_path = PathBuf::from(nvm_home); if nvm_path.is_dir() { // NVM Windows 结构: %NVM_HOME%\v{version}\{cmd}.cmd if let Ok(entries) = fs::read_dir(&nvm_path) { for entry in entries.flatten() { let cmd_path = entry.path().join(format!("{}.cmd", cmd)); if is_safe_path(&cmd_path) { tracing::debug!("[CLI-Sync] Detected {} via NVM_HOME: {:?}", cmd, cmd_path); return Some(cmd_path); } // 也检查 .exe 版本 let exe_path = entry.path().join(format!("{}.exe", cmd)); if is_safe_path(&exe_path) { tracing::debug!("[CLI-Sync] Detected {} via NVM_HOME: {:?}", cmd, exe_path); return Some(exe_path); } } } } } None } /// 解析 where 命令输出获取第一个有效路径 #[cfg(target_os = "windows")] fn parse_where_output(output: &[u8]) -> Option { let stdout = String::from_utf8_lossy(output); for line in stdout.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { let path = PathBuf::from(trimmed); if is_safe_path(&path) { return Some(path); } } } None } /// 检查路径是否是 .cmd/.bat 文件 #[cfg(target_os = "windows")] fn is_cmd_file(path: &PathBuf) -> bool { path.extension() .and_then(|e| e.to_str()) .map(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) .unwrap_or(false) } /// 验证路径是否安全(防止命令注入) #[cfg(target_os = "windows")] fn is_safe_path(path: &PathBuf) -> bool { // 检查路径是否存在且是文件 if !path.exists() || !path.is_file() { return false; } // 必须为绝对路径,避免执行相对路径文件 if !path.is_absolute() { return false; } // 检查路径是否包含危险字符 let path_str = path.to_string_lossy(); let dangerous_chars = ['&', '|', ';', '<', '>', '(', ')', '`', '$', '^', '%', '!']; if path_str.chars().any(|c| dangerous_chars.contains(&c)) { tracing::warn!("[CLI-Sync] Path contains dangerous characters: {}", path_str); return false; } true } /// 执行版本命令(Windows 特殊处理 .cmd/.bat) #[cfg(target_os = "windows")] fn run_version_command(executable_path: &PathBuf) -> Option { // 安全校验:验证路径不包含危险字符 if !is_safe_path(executable_path) { return None; } let output = if is_cmd_file(executable_path) { // 使用引号包裹路径防止注入,使用 /S 开关确保安全解析 let quoted_path = format!("\"{}\"", executable_path.to_string_lossy()); Command::new("cmd.exe") .arg("/C") .arg("ed_path) .arg("--version") .creation_flags(CREATE_NO_WINDOW) .output() } else { let mut cmd = Command::new(executable_path); cmd.arg("--version"); cmd.creation_flags(CREATE_NO_WINDOW); cmd.output() }; match output { Ok(out) if out.status.success() => { let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); // 使用正则提取版本号(更精确) extract_version(&s) } _ => None, } } /// 提取版本号(使用更精确的 semver 匹配) fn extract_version(s: &str) -> Option { // 匹配 semver 格式: x.y.z 或 x.y let re = regex::Regex::new(r"(\d+\.\d+(?:\.\d+)?)").ok()?; re.captures(s) .and_then(|caps| caps.get(1)) .map(|m| m.as_str().to_string()) } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum CliApp { Claude, Codex, Gemini, OpenCode, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct CliConfigFile { pub name: String, pub path: PathBuf, } impl CliApp { pub fn as_str(&self) -> &'static str { match self { CliApp::Claude => "claude", CliApp::Codex => "codex", CliApp::Gemini => "gemini", CliApp::OpenCode => "opencode", } } pub fn config_files(&self) -> Vec { let home = match dirs::home_dir() { Some(p) => p, None => return vec![], }; match self { CliApp::Claude => vec![ CliConfigFile { name: ".claude.json".to_string(), path: home.join(".claude.json"), }, CliConfigFile { name: "settings.json".to_string(), path: home.join(".claude").join("settings.json"), }, ], CliApp::Codex => vec![ CliConfigFile { name: "auth.json".to_string(), path: home.join(".codex").join("auth.json"), }, CliConfigFile { name: "config.toml".to_string(), path: home.join(".codex").join("config.toml"), }, ], CliApp::Gemini => vec![ CliConfigFile { name: ".env".to_string(), path: home.join(".gemini").join(".env"), }, CliConfigFile { name: "settings.json".to_string(), path: home.join(".gemini").join("settings.json"), }, CliConfigFile { name: "config.json".to_string(), path: home.join(".gemini").join("config.json"), }, ], CliApp::OpenCode => vec![ CliConfigFile { name: "config.json".to_string(), path: home.join(".opencode").join("config.json"), }, ], } } pub fn default_url(&self) -> &'static str { match self { CliApp::Claude => "https://api.anthropic.com", CliApp::Codex => "https://api.openai.com/v1", CliApp::Gemini => "https://generativelanguage.googleapis.com", CliApp::OpenCode => "https://api.openai.com/v1", } } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CliStatus { pub installed: bool, pub version: Option, pub is_synced: bool, pub has_backup: bool, pub current_base_url: Option, pub files: Vec, // 返回关联的文件名列表供前端展示 } /// 检测 CLI 是否安装并获取版本 pub fn check_cli_installed(app: &CliApp) -> (bool, Option) { let cmd = app.as_str(); // 默认使用命令名,如果 fallback 找到路径则更新为绝对路径 let mut executable_path = PathBuf::from(cmd); // 1. 优先使用 which/where 检测 (遵循 PATH) let which_output = if cfg!(target_os = "windows") { let mut c = Command::new("where"); c.arg(cmd); #[cfg(target_os = "windows")] c.creation_flags(CREATE_NO_WINDOW); c.output() } else { Command::new("which").arg(cmd).output() }; let mut installed = match &which_output { Ok(out) => out.status.success(), Err(_) => false, }; #[cfg(target_os = "windows")] if installed { if let Ok(out) = &which_output { if let Some(found_path) = parse_where_output(&out.stdout) { executable_path = found_path; } } } #[cfg(target_os = "windows")] if !installed { if let Some(found_path) = scan_windows_cli_paths(cmd) { installed = true; executable_path = found_path; } } // [FIX #765] macOS 增强检测: 如果 which 失败,显式搜索常用二进制路径 // 解决 Tauri 进程 PATH 可能不完整导致检测不到已安装 CLI 的问题 if !installed && !cfg!(target_os = "windows") { let home = dirs::home_dir().unwrap_or_default(); let mut common_paths = vec![ home.join(".local/bin"), home.join(".bun/bin"), home.join(".bun/install/global/node_modules/.bin"), home.join(".npm-global/bin"), home.join(".volta/bin"), home.join("bin"), PathBuf::from("/opt/homebrew/bin"), PathBuf::from("/usr/local/bin"), PathBuf::from("/usr/bin"), ]; // 增强:扫描 nvm 目录下的所有 node 版本 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() { common_paths.push(bin_path); } } } } for path in common_paths { let full_path = path.join(cmd); if full_path.exists() { tracing::debug!("[CLI-Sync] Detected {} via explicit path: {:?}", cmd, full_path); installed = true; executable_path = full_path; break; } } } if !installed { return (false, None); } // 2. 获取版本(Windows 使用特殊处理 .cmd/.bat) #[cfg(target_os = "windows")] let version = run_version_command(&executable_path); #[cfg(not(target_os = "windows"))] let version = { let mut ver_cmd = Command::new(&executable_path); ver_cmd.arg("--version"); let version_output = ver_cmd.output(); match version_output { Ok(out) if out.status.success() => { let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); let cleaned = s.split(|c: char| !c.is_numeric() && c != '.') .filter(|part| !part.is_empty()) .last() .map(|p| p.trim()) .unwrap_or(&s) .to_string(); Some(cleaned) } _ => None, } }; (true, version) } /// 读取当前配置并检测同步状态 pub fn get_sync_status(app: &CliApp, proxy_url: &str) -> (bool, bool, Option) { let files = app.config_files(); if files.is_empty() { return (false, false, None); } let mut all_synced = true; let mut has_backup = false; let mut current_base_url = None; for file in &files { // 使用更简单的命名规则: original_name + .antigravity.bak let backup_path = file.path.with_file_name(format!("{}.antigravity.bak", file.name)); if backup_path.exists() { has_backup = true; } // 如果物理文件不存在 // 如果物理文件不存在 if !file.path.exists() { // Gemini 的 settings.json/config.json 只要有一个存在即可,或者都不存在(视为未同步) if app == &CliApp::Gemini && (file.name == "settings.json" || file.name == "config.json") { continue; } all_synced = false; continue; } let content = match fs::read_to_string(&file.path) { Ok(c) => c, Err(_) => { all_synced = false; continue; } }; match app { CliApp::Claude => { if file.name == "settings.json" { let json: Value = serde_json::from_str(&content).unwrap_or_default(); let url = json.get("env").and_then(|e| e.get("ANTHROPIC_BASE_URL")).and_then(|v| v.as_str()); if let Some(u) = url { current_base_url = Some(u.to_string()); if u.trim_end_matches('/') != proxy_url.trim_end_matches('/') { all_synced = false; } } else { all_synced = false; } } else if file.name == ".claude.json" { let json: Value = serde_json::from_str(&content).unwrap_or_default(); if json.get("hasCompletedOnboarding") != Some(&Value::Bool(true)) { all_synced = false; } } } CliApp::Codex => { if file.name == "config.toml" { // 正则匹配 base_url let re = regex::Regex::new(r#"(?m)^\s*base_url\s*=\s*['"]([^'"]+)['"]"#).unwrap(); if let Some(caps) = re.captures(&content) { let url = &caps[1]; current_base_url = Some(url.to_string()); if url.trim_end_matches('/') != proxy_url.trim_end_matches('/') { all_synced = false; } } else { all_synced = false; } } } CliApp::Gemini => { if file.name == ".env" { let re = regex::Regex::new(r#"(?m)^GOOGLE_GEMINI_BASE_URL=(.*)$"#).unwrap(); if let Some(caps) = re.captures(&content) { let url = caps[1].trim(); current_base_url = Some(url.to_string()); if url.trim_end_matches('/') != proxy_url.trim_end_matches('/') { all_synced = false; } } else { all_synced = false; } } } CliApp::OpenCode => { if file.name == "config.json" { let json: Value = serde_json::from_str(&content).unwrap_or_default(); let url = json.get("providers") .and_then(|p| p.get("openai")) .and_then(|o| o.get("baseURL")) .and_then(|v| v.as_str()); if let Some(u) = url { current_base_url = Some(u.to_string()); if u.trim_end_matches('/') != proxy_url.trim_end_matches('/') { all_synced = false; } } else { all_synced = false; } } } } } (all_synced, has_backup, current_base_url) } /// 执行同步逻辑 pub fn sync_config(app: &CliApp, proxy_url: &str, api_key: &str, model: Option<&str>) -> Result<(), String> { let files = app.config_files(); for file in &files { // Gemini 兼容性逻辑:优先使用 settings.json if app == &CliApp::Gemini && file.name == "config.json" && !file.path.exists() { let settings_path = file.path.with_file_name("settings.json"); if settings_path.exists() { continue; } } if let Some(parent) = file.path.parent() { fs::create_dir_all(parent).map_err(|e| format!("无法创建目录: {}", e))?; } // [New Feature] 自动备份:如果文件存在且没有备份,创建 .antigravity.bak 备份 // 这样可以保留用户最初的配置,后续多次同步不会覆盖这个备份 if file.path.exists() { let backup_path = file.path.with_file_name(format!("{}.antigravity.bak", file.name)); if !backup_path.exists() { if let Err(e) = fs::copy(&file.path, &backup_path) { tracing::warn!("Failed to create backup for {}: {}", file.name, e); } else { tracing::info!("Created backup for {}: {:?}", file.name, backup_path); } } } let mut content = if file.path.exists() { fs::read_to_string(&file.path).unwrap_or_default() } else { String::new() }; match app { CliApp::Claude => { if file.name == ".claude.json" { let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})); if let Some(obj) = json.as_object_mut() { obj.insert("hasCompletedOnboarding".to_string(), Value::Bool(true)); } content = serde_json::to_string_pretty(&json).unwrap(); } else if file.name == "settings.json" { let mut json: serde_json::Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})); if json.as_object().is_none() { json = serde_json::json!({}); } let env = json.as_object_mut().unwrap().entry("env").or_insert(serde_json::json!({})); if let Some(env_obj) = env.as_object_mut() { env_obj.insert("ANTHROPIC_BASE_URL".to_string(), Value::String(proxy_url.to_string())); if !api_key.is_empty() { env_obj.insert("ANTHROPIC_API_KEY".to_string(), Value::String(api_key.to_string())); // [FIX] 避免冲突:如果存在则移除 ANTHROPIC_AUTH_TOKEN env_obj.remove("ANTHROPIC_AUTH_TOKEN"); // [FIX] 清理可能来自其他 Provider 的模型覆盖设置 env_obj.remove("ANTHROPIC_MODEL"); env_obj.remove("ANTHROPIC_DEFAULT_HAIKU_MODEL"); env_obj.remove("ANTHROPIC_DEFAULT_OPUS_MODEL"); env_obj.remove("ANTHROPIC_DEFAULT_SONNET_MODEL"); } else { // 如果 API Key 为空,则移除该键,避免设置为空字符串 env_obj.remove("ANTHROPIC_API_KEY"); } } if let Some(m) = model { // 注意:Claude Code 的官方配置中,当前选定模型放在根节点的 model 字段 json.as_object_mut().unwrap().insert("model".to_string(), Value::String(m.to_string())); } content = serde_json::to_string_pretty(&json).unwrap(); } } CliApp::Codex => { if file.name == "auth.json" { let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})); if let Some(obj) = json.as_object_mut() { obj.insert("OPENAI_API_KEY".to_string(), Value::String(api_key.to_string())); // Codex 的 auth.json 似乎也支持 OPENAI_BASE_URL,但 ccs 没写,我们也同步写一下 obj.insert("OPENAI_BASE_URL".to_string(), Value::String(proxy_url.to_string())); } content = serde_json::to_string_pretty(&json).unwrap(); } else if file.name == "config.toml" { use toml_edit::{DocumentMut, value}; let mut doc = content.parse::().unwrap_or_else(|_| DocumentMut::new()); // 设置层级 [model_providers.custom] let providers = doc.entry("model_providers").or_insert(toml_edit::Item::Table(toml_edit::Table::new())); if let Some(p_table) = providers.as_table_mut() { let custom = p_table.entry("custom").or_insert(toml_edit::Item::Table(toml_edit::Table::new())); if let Some(c_table) = custom.as_table_mut() { c_table.insert("name", value("custom")); c_table.insert("wire_api", value("responses")); c_table.insert("requires_openai_auth", value(true)); c_table.insert("base_url", value(proxy_url)); if let Some(m) = model { c_table.insert("model", value(m)); } } } doc.insert("model_provider", value("custom")); if let Some(m) = model { doc.insert("model", value(m)); } // Codex 还需要清理可能存在的旧配置项 doc.remove("openai_api_key"); doc.remove("openai_base_url"); content = doc.to_string(); } } CliApp::Gemini => { if file.name == ".env" { let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); let mut found_url = false; let mut found_key = false; for line in lines.iter_mut() { if line.starts_with("GOOGLE_GEMINI_BASE_URL=") { *line = format!("GOOGLE_GEMINI_BASE_URL={}", proxy_url); found_url = true; } else if line.trim().starts_with("GEMINI_API_KEY=") { *line = format!("GEMINI_API_KEY={}", api_key); found_key = true; } } if !found_url { lines.push(format!("GOOGLE_GEMINI_BASE_URL={}", proxy_url)); } if !found_key { lines.push(format!("GEMINI_API_KEY={}", api_key)); } if let Some(m) = model { let mut found_model = false; for line in lines.iter_mut() { if line.starts_with("GOOGLE_GEMINI_MODEL=") { *line = format!("GOOGLE_GEMINI_MODEL={}", m); found_model = true; } } if !found_model { lines.push(format!("GOOGLE_GEMINI_MODEL={}", m)); } } content = lines.join("\n"); } else if file.name == "settings.json" || file.name == "config.json" { let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})); if json.as_object().is_none() { json = serde_json::json!({}); } let sec = json.as_object_mut().unwrap().entry("security").or_insert(serde_json::json!({})); let auth = sec.as_object_mut().unwrap().entry("auth").or_insert(serde_json::json!({})); if let Some(auth_obj) = auth.as_object_mut() { auth_obj.insert("selectedType".to_string(), Value::String("gemini-api-key".to_string())); } content = serde_json::to_string_pretty(&json).unwrap(); } } CliApp::OpenCode => { if file.name == "config.json" { let mut json: Value = serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})); if json.as_object().is_none() { json = serde_json::json!({}); } let providers = json.as_object_mut().unwrap().entry("providers").or_insert(serde_json::json!({})); let openai = providers.as_object_mut().unwrap().entry("openai").or_insert(serde_json::json!({})); if let Some(openai_obj) = openai.as_object_mut() { openai_obj.insert("baseURL".to_string(), Value::String(proxy_url.to_string())); if !api_key.is_empty() { openai_obj.insert("apiKey".to_string(), Value::String(api_key.to_string())); } } content = serde_json::to_string_pretty(&json).unwrap(); } } } // 使用临时文件原子写入 let tmp_path = file.path.with_extension("tmp"); fs::write(&tmp_path, &content).map_err(|e| format!("写入临时文件失败: {}", e))?; fs::rename(&tmp_path, &file.path).map_err(|e| format!("重命名配置文件失败: {}", e))?; } Ok(()) } // Tauri Commands #[tauri::command] pub async fn get_cli_sync_status(app_type: CliApp, proxy_url: String) -> Result { let (installed, version) = check_cli_installed(&app_type); let (is_synced, has_backup, current_base_url) = if installed { get_sync_status(&app_type, &proxy_url) } else { (false, false, None) }; Ok(CliStatus { installed, version, is_synced, has_backup, current_base_url, files: app_type.config_files().into_iter().map(|f| f.name).collect(), }) } #[tauri::command] pub async fn execute_cli_sync(app_type: CliApp, proxy_url: String, api_key: String, model: Option) -> Result<(), String> { sync_config(&app_type, &proxy_url, &api_key, model.as_deref()) } #[tauri::command] pub async fn execute_cli_restore(app_type: CliApp) -> Result<(), String> { let files = app_type.config_files(); let mut restored_count = 0; // 尝试从备份恢复 for file in &files { let backup_path = file.path.with_file_name(format!("{}.antigravity.bak", file.name)); if backup_path.exists() { // 还原:覆盖原文件 if let Err(e) = fs::rename(&backup_path, &file.path) { return Err(format!("恢复备份失败 {}: {}", file.name, e)); } restored_count += 1; } } if restored_count > 0 { // 如果成功恢复了至少一个备份,就认为是恢复成功 return Ok(()); } // 如果没有备份,则执行原来的逻辑:恢复为默认配置 let default_url = app_type.default_url(); // 恢复默认时清空 API Key,让用户重新授权或使用官方 Key sync_config(&app_type, default_url, "", None) } #[tauri::command] pub async fn get_cli_config_content(app_type: CliApp, file_name: Option) -> Result { let files = app_type.config_files(); let file = if let Some(name) = file_name { files.into_iter().find(|f| f.name == name).ok_or("找不到指定的文件".to_string())? } else { files.into_iter().next().ok_or("找不到配置文件".to_string())? }; if !file.path.exists() { return Err("配置文件不存在".to_string()); } fs::read_to_string(&file.path).map_err(|e| format!("读取配置文件失败: {}", e)) }