From 8a05e7bd3df613bbd05b1bfe023cfaaf32d3463c Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 12 Nov 2025 10:47:34 +0800 Subject: [PATCH] feat(gemini): add Gemini provider integration (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gemini): add Gemini provider integration - Add gemini_config.rs module for .env file parsing - Extend AppType enum to support Gemini - Implement GeminiConfigEditor and GeminiFormFields components - Add GeminiIcon with standardized 1024x1024 viewBox - Add Gemini provider presets configuration - Update i18n translations for Gemini support - Extend ProviderService and McpService for Gemini * fix(gemini): resolve TypeScript errors, add i18n support, and fix MCP logic **Critical Fixes:** - Fix TS2741 errors in tests/msw/state.ts by adding missing Gemini type definitions - Fix ProviderCard.extractApiUrl to support GOOGLE_GEMINI_BASE_URL display - Add missing apps.gemini i18n keys (zh/en) for proper app name display - Fix MCP service Gemini cross-app duplication logic to prevent self-copy **Technical Details:** - tests/msw/state.ts: Add gemini default providers, current ID, and MCP config - ProviderCard.tsx: Check both ANTHROPIC_BASE_URL and GOOGLE_GEMINI_BASE_URL - services/mcp.rs: Skip Gemini in sync_other_side logic with unreachable!() guards - Run pnpm format to auto-fix code style issues **Verification:** - ✅ pnpm typecheck passes - ✅ pnpm format completed * feat(gemini): enhance authentication and config parsing - Add strict and lenient .env parsing modes - Implement PackyCode partner authentication detection - Support Google OAuth official authentication - Auto-configure security.auth.selectedType for PackyCode - Add comprehensive test coverage for all auth types - Update i18n for OAuth hints and Gemini config --------- Co-authored-by: Jason --- .gitignore | 5 + src-tauri/src/app_config.rs | 25 +- src-tauri/src/app_store.rs | 18 +- src-tauri/src/claude_mcp.rs | 7 +- src-tauri/src/claude_plugin.rs | 4 +- src-tauri/src/codex_config.rs | 4 +- src-tauri/src/commands/config.rs | 23 +- src-tauri/src/commands/import_export.rs | 4 +- src-tauri/src/commands/misc.rs | 8 +- src-tauri/src/config.rs | 6 +- src-tauri/src/gemini_config.rs | 579 ++++++++++++++++++ src-tauri/src/lib.rs | 59 +- src-tauri/src/mcp.rs | 38 +- src-tauri/src/provider.rs | 9 + src-tauri/src/services/config.rs | 68 +- src-tauri/src/services/mcp.rs | 8 +- src-tauri/src/services/provider.rs | 529 +++++++++++++++- src-tauri/src/settings.rs | 56 ++ src-tauri/src/usage_script.rs | 84 +-- src-tauri/tests/import_export_sync.rs | 121 +++- src-tauri/tests/provider_service.rs | 192 +++++- src-tauri/tests/support.rs | 2 +- src/components/AppSwitcher.tsx | 22 +- src/components/BrandIcons.tsx | 15 + src/components/UsageScriptModal.tsx | 56 +- .../providers/AddProviderDialog.tsx | 25 +- src/components/providers/ProviderCard.tsx | 15 +- .../providers/forms/CommonConfigEditor.tsx | 5 - .../providers/forms/EndpointSpeedTest.tsx | 1 + .../providers/forms/GeminiConfigEditor.tsx | 139 +++++ .../providers/forms/GeminiFormFields.tsx | 150 +++++ .../providers/forms/ProviderForm.tsx | 155 ++++- .../providers/forms/hooks/useApiKeyLink.ts | 9 +- .../providers/forms/hooks/useApiKeyState.ts | 14 +- .../providers/forms/hooks/useBaseUrlState.ts | 53 +- .../forms/hooks/useProviderCategory.ts | 8 +- .../forms/hooks/useSpeedTestEndpoints.ts | 38 +- .../providers/forms/shared/EndpointField.tsx | 10 +- src/config/geminiProviderPresets.ts | 83 +++ src/i18n/locales/en.json | 16 +- src/i18n/locales/zh.json | 16 +- src/lib/api/types.ts | 2 +- src/types.ts | 10 + src/utils/providerConfigUtils.ts | 73 ++- src/utils/textNormalization.ts | 16 +- tests/msw/state.ts | 18 + 46 files changed, 2522 insertions(+), 276 deletions(-) create mode 100644 src-tauri/src/gemini_config.rs create mode 100644 src/components/providers/forms/GeminiConfigEditor.tsx create mode 100644 src/components/providers/forms/GeminiFormFields.tsx create mode 100644 src/config/geminiProviderPresets.ts diff --git a/.gitignore b/.gitignore index 8859ed0..9ff73d9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ release/ .npmrc CLAUDE.md AGENTS.md +GEMINI.md /.claude +/.codex +/.gemini +/.cc-switch +/.idea /.vscode vitest-report.json diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index aa0c200..185184d 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -17,6 +17,8 @@ pub struct McpRoot { pub claude: McpConfig, #[serde(default)] pub codex: McpConfig, + #[serde(default)] + pub gemini: McpConfig, // Gemini MCP 配置(预留) } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -24,11 +26,12 @@ use crate::error::AppError; use crate::provider::ProviderManager; /// 应用类型 -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AppType { Claude, Codex, + Gemini, // 新增 } impl AppType { @@ -36,6 +39,7 @@ impl AppType { match self { AppType::Claude => "claude", AppType::Codex => "codex", + AppType::Gemini => "gemini", // 新增 } } } @@ -48,10 +52,11 @@ impl FromStr for AppType { match normalized.as_str() { "claude" => Ok(AppType::Claude), "codex" => Ok(AppType::Codex), + "gemini" => Ok(AppType::Gemini), // 新增 other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."), )), } } @@ -79,6 +84,7 @@ impl Default for MultiAppConfig { let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); + apps.insert("gemini".to_string(), ProviderManager::default()); // 新增 Self { version: 2, @@ -122,7 +128,14 @@ impl MultiAppConfig { } // 解析 v2 结构 - serde_json::from_value::(value).map_err(|e| AppError::json(&config_path, e)) + let mut config: Self = serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?; + + // 确保 gemini 应用存在(兼容旧配置文件) + if !config.apps.contains_key("gemini") { + config.apps.insert("gemini".to_string(), ProviderManager::default()); + } + + Ok(config) } /// 保存配置到文件 @@ -132,7 +145,7 @@ impl MultiAppConfig { if config_path.exists() { let backup_path = get_app_config_dir().join("config.json.bak"); if let Err(e) = copy_file(&config_path, &backup_path) { - log::warn!("备份 config.json 到 .bak 失败: {}", e); + log::warn!("备份 config.json 到 .bak 失败: {e}"); } } @@ -163,6 +176,7 @@ impl MultiAppConfig { match app { AppType::Claude => &self.mcp.claude, AppType::Codex => &self.mcp.codex, + AppType::Gemini => &self.mcp.gemini, } } @@ -171,6 +185,7 @@ impl MultiAppConfig { match app { AppType::Claude => &mut self.mcp.claude, AppType::Codex => &mut self.mcp.codex, + AppType::Gemini => &mut self.mcp.gemini, } } } diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index 485fdf7..30fd099 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -30,7 +30,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option { let store = match app.store_builder("app_paths.json").build() { Ok(store) => store, Err(e) => { - log::warn!("无法创建 Store: {}", e); + log::warn!("无法创建 Store: {e}"); return None; } }; @@ -46,20 +46,18 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option { if !path.exists() { log::warn!( - "Store 中配置的 app_config_dir 不存在: {:?}\n\ - 将使用默认路径。", - path + "Store 中配置的 app_config_dir 不存在: {path:?}\n\ + 将使用默认路径。" ); return None; } - log::info!("使用 Store 中的 app_config_dir: {:?}", path); + log::info!("使用 Store 中的 app_config_dir: {path:?}"); Some(path) } Some(_) => { log::warn!( - "Store 中的 {} 类型不正确,应为字符串", - STORE_KEY_APP_CONFIG_DIR + "Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串" ); None } @@ -82,14 +80,14 @@ pub fn set_app_config_dir_to_store( let store = app .store_builder("app_paths.json") .build() - .map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?; match path { Some(p) => { let trimmed = p.trim(); if !trimmed.is_empty() { store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string())); - log::info!("已将 app_config_dir 写入 Store: {}", trimmed); + log::info!("已将 app_config_dir 写入 Store: {trimmed}"); } else { store.delete(STORE_KEY_APP_CONFIG_DIR); log::info!("已从 Store 中删除 app_config_dir 配置"); @@ -103,7 +101,7 @@ pub fn set_app_config_dir_to_store( store .save() - .map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?; refresh_app_config_dir_override(app); Ok(()) diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 603a73d..54cc6f4 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -37,7 +37,7 @@ fn ensure_mcp_override_migrated() { if let Some(parent) = new_path.parent() { if let Err(err) = fs::create_dir_all(parent) { - log::warn!("创建 MCP 目录失败: {}", err); + log::warn!("创建 MCP 目录失败: {err}"); return; } } @@ -250,14 +250,13 @@ pub fn set_mcp_servers_map( map.clone() } else { return Err(AppError::McpValidation(format!( - "MCP 服务器 '{}' 不是对象", - id + "MCP 服务器 '{id}' 不是对象" ))); }; if let Some(server_val) = obj.remove("server") { let server_obj = server_val.as_object().cloned().ok_or_else(|| { - AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id)) + AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象")) })?; obj = server_obj; } diff --git a/src-tauri/src/claude_plugin.rs b/src-tauri/src/claude_plugin.rs index 08acf19..d729460 100644 --- a/src-tauri/src/claude_plugin.rs +++ b/src-tauri/src/claude_plugin.rs @@ -80,7 +80,7 @@ pub fn write_claude_config() -> Result { if changed || !path.exists() { let serialized = serde_json::to_string_pretty(&obj) .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?; + fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?; Ok(true) } else { Ok(false) @@ -114,7 +114,7 @@ pub fn clear_claude_config() -> Result { let serialized = serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?; + fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?; Ok(true) } diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ac13628..c3a5e19 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -37,8 +37,8 @@ pub fn get_codex_provider_paths( .map(sanitize_provider_name) .unwrap_or_else(|| sanitize_provider_name(provider_id)); - let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name)); - let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name)); + let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json")); + let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml")); (auth_path, config_path) } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index c0daeea..b6b721d 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -29,6 +29,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::Gemini => { + let env_path = crate::gemini_config::get_gemini_env_path(); + let exists = env_path.exists(); + let path = crate::gemini_config::get_gemini_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -44,6 +53,7 @@ pub async fn get_config_dir(app: String) -> Result { let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { AppType::Claude => config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), + AppType::Gemini => crate::gemini_config::get_gemini_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -55,16 +65,17 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result config::get_claude_config_dir(), AppType::Codex => codex_config::get_codex_config_dir(), + AppType::Gemini => crate::gemini_config::get_gemini_dir(), }; if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?; } handle .opener() .open_path(config_dir.to_string_lossy().to_string(), None::) - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("打开文件夹失败: {e}"))?; Ok(true) } @@ -87,14 +98,14 @@ pub async fn pick_directory( builder.blocking_pick_folder() }) .await - .map_err(|e| format!("弹出目录选择器失败: {}", e))?; + .map_err(|e| format!("弹出目录选择器失败: {e}"))?; match result { Some(file_path) => { let resolved = file_path .simplified() .into_path() - .map_err(|e| format!("解析选择的目录失败: {}", e))?; + .map_err(|e| format!("解析选择的目录失败: {e}"))?; Ok(Some(resolved.to_string_lossy().to_string())) } None => Ok(None), @@ -114,13 +125,13 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { let config_dir = config::get_app_config_dir(); if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?; + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?; } handle .opener() .open_path(config_dir.to_string_lossy().to_string(), None::) - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("打开文件夹失败: {e}"))?; Ok(true) } diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 245ff4c..a16787d 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -24,7 +24,7 @@ pub async fn export_config_to_file( })) }) .await - .map_err(|e| format!("导出配置失败: {}", e))? + .map_err(|e| format!("导出配置失败: {e}"))? .map_err(|e: AppError| e.to_string()) } @@ -39,7 +39,7 @@ pub async fn import_config_from_file( ConfigService::load_config_for_import(&path_buf) }) .await - .map_err(|e| format!("导入配置失败: {}", e))? + .map_err(|e| format!("导入配置失败: {e}"))? .map_err(|e: AppError| e.to_string())?; { diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 95ed580..3a83962 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -10,12 +10,12 @@ 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) + format!("https://{url}") }; app.opener() .open_url(&url, None::) - .map_err(|e| format!("打开链接失败: {}", e))?; + .map_err(|e| format!("打开链接失败: {e}"))?; Ok(true) } @@ -29,7 +29,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result { "https://github.com/farion1231/cc-switch/releases/latest", None::, ) - .map_err(|e| format!("打开更新页面失败: {}", e))?; + .map_err(|e| format!("打开更新页面失败: {e}"))?; Ok(true) } @@ -37,7 +37,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result { /// 判断是否为便携版(绿色版)运行 #[tauri::command] pub async fn is_portable_mode() -> Result { - let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?; + 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 { diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c354e6f..dfd0756 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -33,7 +33,7 @@ fn derive_mcp_path_from_override(dir: &Path) -> Option { return None; } let parent = dir.parent().unwrap_or_else(|| Path::new("")); - Some(parent.join(format!("{}.json", file_name))) + Some(parent.join(format!("{file_name}.json"))) } /// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级 @@ -95,7 +95,7 @@ pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) .map(sanitize_provider_name) .unwrap_or_else(|| sanitize_provider_name(provider_id)); - get_claude_config_dir().join(format!("settings-{}.json", base_name)) + get_claude_config_dir().join(format!("settings-{base_name}.json")) } /// 读取 JSON 配置文件 @@ -149,7 +149,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - tmp.push(format!("{}.tmp.{}", file_name, ts)); + tmp.push(format!("{file_name}.tmp.{ts}")); { let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs new file mode 100644 index 0000000..89f8fde --- /dev/null +++ b/src-tauri/src/gemini_config.rs @@ -0,0 +1,579 @@ +use crate::config::write_text_file; +use crate::error::AppError; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// 获取 Gemini 配置目录路径(支持设置覆盖) +pub fn get_gemini_dir() -> PathBuf { + if let Some(custom) = crate::settings::get_gemini_override_dir() { + return custom; + } + + dirs::home_dir() + .expect("无法获取用户主目录") + .join(".gemini") +} + +/// 获取 Gemini .env 文件路径 +pub fn get_gemini_env_path() -> PathBuf { + get_gemini_dir().join(".env") +} + +/// 解析 .env 文件内容为键值对 +/// +/// 此函数宽松地解析 .env 文件,跳过无效行。 +/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。 +pub fn parse_env_file(content: &str) -> HashMap { + let mut map = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + + // 跳过空行和注释 + if line.is_empty() || line.starts_with('#') { + continue; + } + + // 解析 KEY=VALUE + if let Some((key, value)) = line.split_once('=') { + let key = key.trim().to_string(); + let value = value.trim().to_string(); + + // 验证 key 是否有效(不为空,只包含字母、数字和下划线) + if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') { + map.insert(key, value); + } + } + } + + map +} + +/// 严格解析 .env 文件内容,返回详细的错误信息 +/// +/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误, +/// 包含行号和详细的错误信息。 +/// +/// # 错误 +/// +/// 返回 `AppError` 如果遇到以下情况: +/// - 行不包含 `=` 分隔符 +/// - Key 为空或包含无效字符 +/// - Key 不符合环境变量命名规范 +/// +/// # 使用场景 +/// +/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。 +/// 可用于: +/// - 配置导入验证 +/// - CLI 工具的严格模式 +/// - 配置文件错误诊断 +/// +/// 已有完整的测试覆盖,可直接使用。 +#[allow(dead_code)] +pub fn parse_env_file_strict(content: &str) -> Result, AppError> { + let mut map = HashMap::new(); + + for (line_num, line) in content.lines().enumerate() { + let line = line.trim(); + let line_number = line_num + 1; // 行号从 1 开始 + + // 跳过空行和注释 + if line.is_empty() || line.starts_with('#') { + continue; + } + + // 检查是否包含 = + if !line.contains('=') { + return Err(AppError::localized( + "gemini.env.parse_error.no_equals", + format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"), + format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"), + )); + } + + // 解析 KEY=VALUE + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim(); + + // 验证 key 不为空 + if key.is_empty() { + return Err(AppError::localized( + "gemini.env.parse_error.empty_key", + format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"), + format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"), + )); + } + + // 验证 key 只包含字母、数字和下划线 + if !key.chars().all(|c| c.is_alphanumeric() || c == '_') { + return Err(AppError::localized( + "gemini.env.parse_error.invalid_key", + format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"), + format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"), + )); + } + + map.insert(key.to_string(), value.to_string()); + } + } + + Ok(map) +} + +/// 将键值对序列化为 .env 格式 +pub fn serialize_env_file(map: &HashMap) -> String { + let mut lines = Vec::new(); + + // 按键排序以保证输出稳定 + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + + for key in keys { + if let Some(value) = map.get(key) { + lines.push(format!("{key}={value}")); + } + } + + lines.join("\n") +} + +/// 读取 Gemini .env 文件 +pub fn read_gemini_env() -> Result, AppError> { + let path = get_gemini_env_path(); + + if !path.exists() { + return Ok(HashMap::new()); + } + + let content = fs::read_to_string(&path) + .map_err(|e| AppError::io(&path, e))?; + + Ok(parse_env_file(&content)) +} + +/// 写入 Gemini .env 文件(原子操作) +pub fn write_gemini_env_atomic(map: &HashMap) -> Result<(), AppError> { + let path = get_gemini_env_path(); + + // 确保目录存在 + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| AppError::io(parent, e))?; + + // 设置目录权限为 700(仅所有者可读写执行) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(parent) + .map_err(|e| AppError::io(parent, e))? + .permissions(); + perms.set_mode(0o700); + fs::set_permissions(parent, perms) + .map_err(|e| AppError::io(parent, e))?; + } + } + + let content = serialize_env_file(map); + write_text_file(&path, &content)?; + + // 设置文件权限为 600(仅所有者可读写) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path) + .map_err(|e| AppError::io(&path, e))? + .permissions(); + perms.set_mode(0o600); + fs::set_permissions(&path, perms) + .map_err(|e| AppError::io(&path, e))?; + } + + Ok(()) +} + +/// 从 .env 格式转换为 Provider.settings_config (JSON Value) +pub fn env_to_json(env_map: &HashMap) -> Value { + let mut json_map = serde_json::Map::new(); + + for (key, value) in env_map { + json_map.insert(key.clone(), Value::String(value.clone())); + } + + serde_json::json!({ "env": json_map }) +} + +/// 从 Provider.settings_config (JSON Value) 提取 .env 格式 +pub fn json_to_env(settings: &Value) -> Result, AppError> { + let mut env_map = HashMap::new(); + + if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) { + for (key, value) in env_obj { + if let Some(val_str) = value.as_str() { + env_map.insert(key.clone(), val_str.to_string()); + } + } + } + + Ok(env_map) +} + +/// 验证 Gemini 配置的必需字段 +pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> { + let env_map = json_to_env(settings)?; + + // 如果 env 为空,表示使用 OAuth(如 Google 官方),跳过验证 + if env_map.is_empty() { + return Ok(()); + } + + // 如果 env 不为空,检查必需字段 GEMINI_API_KEY + if !env_map.contains_key("GEMINI_API_KEY") { + return Err(AppError::localized( + "gemini.validation.missing_api_key", + "Gemini 配置缺少必需字段: GEMINI_API_KEY", + "Gemini config missing required field: GEMINI_API_KEY", + )); + } + + Ok(()) +} + +/// 获取 Gemini settings.json 文件路径 +/// +/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级) +pub fn get_gemini_settings_path() -> PathBuf { + get_gemini_dir().join("settings.json") +} + +/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段 +/// +/// 此函数会: +/// 1. 读取现有的 settings.json(如果存在) +/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段 +/// 3. 原子性写入文件 +/// +/// # 参数 +/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal") +fn update_selected_type(selected_type: &str) -> Result<(), AppError> { + let settings_path = get_gemini_settings_path(); + + // 确保目录存在 + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| AppError::io(parent, e))?; + } + + // 读取现有的 settings.json(如果存在) + let mut settings_content = if settings_path.exists() { + let content = fs::read_to_string(&settings_path) + .map_err(|e| AppError::io(&settings_path, e))?; + serde_json::from_str::(&content) + .unwrap_or_else(|_| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + // 只更新 security.auth.selectedType 字段 + if let Some(obj) = settings_content.as_object_mut() { + let security = obj.entry("security") + .or_insert_with(|| serde_json::json!({})); + + if let Some(security_obj) = security.as_object_mut() { + let auth = security_obj.entry("auth") + .or_insert_with(|| serde_json::json!({})); + + if let Some(auth_obj) = auth.as_object_mut() { + auth_obj.insert( + "selectedType".to_string(), + Value::String(selected_type.to_string()) + ); + } + } + } + + // 写入文件 + crate::config::write_json_file(&settings_path, &settings_content)?; + + Ok(()) +} + +/// 为 Packycode Gemini 供应商写入 settings.json +/// +/// 设置 `~/.gemini/settings.json` 中的: +/// ```json +/// { +/// "security": { +/// "auth": { +/// "selectedType": "gemini-api-key" +/// } +/// } +/// } +/// ``` +/// +/// 保留文件中的其他所有字段。 +pub fn write_packycode_settings() -> Result<(), AppError> { + update_selected_type("gemini-api-key") +} + +/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式) +/// +/// 设置 `~/.gemini/settings.json` 中的: +/// ```json +/// { +/// "security": { +/// "auth": { +/// "selectedType": "oauth-personal" +/// } +/// } +/// } +/// ``` +/// +/// 保留文件中的其他所有字段。 +pub fn write_google_oauth_settings() -> Result<(), AppError> { + update_selected_type("oauth-personal") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_env_file() { + let content = r#" +# Comment line +GOOGLE_GEMINI_BASE_URL=https://example.com +GEMINI_API_KEY=sk-test123 +GEMINI_MODEL=gemini-2.5-pro + +# Another comment +"#; + + let map = parse_env_file(content); + + assert_eq!(map.len(), 3); + assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string())); + assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string())); + assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string())); + } + + #[test] + fn test_serialize_env_file() { + let mut map = HashMap::new(); + map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string()); + map.insert("GEMINI_MODEL".to_string(), "gemini-2.5-pro".to_string()); + + let content = serialize_env_file(&map); + + assert!(content.contains("GEMINI_API_KEY=sk-test")); + assert!(content.contains("GEMINI_MODEL=gemini-2.5-pro")); + } + + #[test] + fn test_env_json_conversion() { + let mut env_map = HashMap::new(); + env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string()); + + let json = env_to_json(&env_map); + let converted = json_to_env(&json).unwrap(); + + assert_eq!(converted.get("GEMINI_API_KEY"), Some(&"test-key".to_string())); + } + + #[test] + fn test_parse_env_file_strict_success() { + // 测试严格模式下正常解析 + let content = r#" +# Comment line +GOOGLE_GEMINI_BASE_URL=https://example.com +GEMINI_API_KEY=sk-test123 +GEMINI_MODEL=gemini-2.5-pro + +# Another comment +"#; + + let result = parse_env_file_strict(content); + assert!(result.is_ok()); + + let map = result.unwrap(); + assert_eq!(map.len(), 3); + assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string())); + assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string())); + assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string())); + } + + #[test] + fn test_parse_env_file_strict_missing_equals() { + // 测试严格模式下检测缺少 = 的行 + let content = "GOOGLE_GEMINI_BASE_URL=https://example.com +INVALID_LINE_WITHOUT_EQUALS +GEMINI_API_KEY=sk-test123"; + + let result = parse_env_file_strict(content); + assert!(result.is_err()); + + let err = result.unwrap_err(); + let err_msg = format!("{err:?}"); + assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2")); + assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS")); + } + + #[test] + fn test_parse_env_file_strict_empty_key() { + // 测试严格模式下检测空 key + let content = "GOOGLE_GEMINI_BASE_URL=https://example.com +=value_without_key +GEMINI_API_KEY=sk-test123"; + + let result = parse_env_file_strict(content); + assert!(result.is_err()); + + let err = result.unwrap_err(); + let err_msg = format!("{err:?}"); + assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2")); + assert!(err_msg.contains("empty") || err_msg.contains("空")); + } + + #[test] + fn test_parse_env_file_strict_invalid_key_characters() { + // 测试严格模式下检测无效字符(如空格、特殊符号) + let content = "GOOGLE_GEMINI_BASE_URL=https://example.com +INVALID KEY WITH SPACES=value +GEMINI_API_KEY=sk-test123"; + + let result = parse_env_file_strict(content); + assert!(result.is_err()); + + let err = result.unwrap_err(); + let err_msg = format!("{err:?}"); + assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2")); + assert!(err_msg.contains("INVALID KEY WITH SPACES")); + } + + #[test] + fn test_parse_env_file_lax_vs_strict() { + // 测试宽松模式和严格模式的差异 + let content = "VALID_KEY=value +INVALID LINE +KEY_WITH-DASH=value"; + + // 宽松模式:跳过无效行,继续解析 + let lax_result = parse_env_file(content); + assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY + assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string())); + + // 严格模式:遇到无效行立即返回错误 + let strict_result = parse_env_file_strict(content); + assert!(strict_result.is_err()); + } + + #[test] + fn test_packycode_settings_structure() { + // 验证 Packycode settings.json 的结构正确 + let settings_content = serde_json::json!({ + "security": { + "auth": { + "selectedType": "gemini-api-key" + } + } + }); + + assert_eq!( + settings_content["security"]["auth"]["selectedType"], + "gemini-api-key" + ); + } + + #[test] + fn test_packycode_settings_merge() { + // 测试合并逻辑:应该保留其他字段 + let mut existing_settings = serde_json::json!({ + "otherField": "should-be-kept", + "security": { + "otherSetting": "also-kept", + "auth": { + "otherAuth": "preserved" + } + } + }); + + // 模拟更新 selectedType + if let Some(obj) = existing_settings.as_object_mut() { + let security = obj.entry("security") + .or_insert_with(|| serde_json::json!({})); + + if let Some(security_obj) = security.as_object_mut() { + let auth = security_obj.entry("auth") + .or_insert_with(|| 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()) + ); + } + } + } + + // 验证所有字段都被保留 + assert_eq!(existing_settings["otherField"], "should-be-kept"); + assert_eq!(existing_settings["security"]["otherSetting"], "also-kept"); + assert_eq!(existing_settings["security"]["auth"]["otherAuth"], "preserved"); + assert_eq!(existing_settings["security"]["auth"]["selectedType"], "gemini-api-key"); + } + + #[test] + fn test_google_oauth_settings_structure() { + // 验证 Google OAuth settings.json 的结构正确 + let settings_content = serde_json::json!({ + "security": { + "auth": { + "selectedType": "oauth-personal" + } + } + }); + + assert_eq!( + settings_content["security"]["auth"]["selectedType"], + "oauth-personal" + ); + } + + #[test] + fn test_validate_empty_env_for_oauth() { + // 测试空 env(Google 官方 OAuth)可以通过验证 + let settings = serde_json::json!({ + "env": {} + }); + + assert!(validate_gemini_settings(&settings).is_ok()); + } + + #[test] + fn test_validate_env_with_api_key() { + // 测试有 API Key 的配置可以通过验证 + let settings = serde_json::json!({ + "env": { + "GEMINI_API_KEY": "sk-test123", + "GEMINI_MODEL": "gemini-2.5-pro" + } + }); + + assert!(validate_gemini_settings(&settings).is_ok()); + } + + #[test] + fn test_validate_env_without_api_key_fails() { + // 测试缺少 API Key 的非空配置会失败 + let settings = serde_json::json!({ + "env": { + "GEMINI_MODEL": "gemini-2.5-pro" + } + }); + + assert!(validate_gemini_settings(&settings).is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2440eb8..acb3e49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod codex_config; mod commands; mod config; mod error; +mod gemini_config; // 新增 mod init_status; mod mcp; mod provider; @@ -22,7 +23,7 @@ pub use error::AppError; pub use mcp::{ import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex, }; -pub use provider::Provider; +pub use provider::{Provider, ProviderMeta}; pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService}; pub use settings::{update_settings, AppSettings}; pub use store::AppState; @@ -74,7 +75,7 @@ fn create_tray_menu( // 顶部:打开主界面 let show_main_item = MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>) - .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; menu_builder = menu_builder.item(&show_main_item).separator(); // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) @@ -82,7 +83,7 @@ fn create_tray_menu( // 添加Claude标题(禁用状态,仅作为分组标识) let claude_header = MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>) - .map_err(|e| AppError::Message(format!("创建Claude标题失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建Claude标题失败: {e}")))?; menu_builder = menu_builder.item(&claude_header); if !claude_manager.providers.is_empty() { @@ -111,13 +112,13 @@ fn create_tray_menu( let is_current = claude_manager.current == *id; let item = CheckMenuItem::with_id( app, - format!("claude_{}", id), + format!("claude_{id}"), &provider.name, true, is_current, None::<&str>, ) - .map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?; menu_builder = menu_builder.item(&item); } } else { @@ -129,7 +130,7 @@ fn create_tray_menu( false, None::<&str>, ) - .map_err(|e| AppError::Message(format!("创建Claude空提示失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建Claude空提示失败: {e}")))?; menu_builder = menu_builder.item(&empty_hint); } } @@ -138,7 +139,7 @@ fn create_tray_menu( // 添加Codex标题(禁用状态,仅作为分组标识) let codex_header = MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>) - .map_err(|e| AppError::Message(format!("创建Codex标题失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建Codex标题失败: {e}")))?; menu_builder = menu_builder.item(&codex_header); if !codex_manager.providers.is_empty() { @@ -167,13 +168,13 @@ fn create_tray_menu( let is_current = codex_manager.current == *id; let item = CheckMenuItem::with_id( app, - format!("codex_{}", id), + format!("codex_{id}"), &provider.name, true, is_current, None::<&str>, ) - .map_err(|e| AppError::Message(format!("创建菜单项失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?; menu_builder = menu_builder.item(&item); } } else { @@ -185,20 +186,20 @@ fn create_tray_menu( false, None::<&str>, ) - .map_err(|e| AppError::Message(format!("创建Codex空提示失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建Codex空提示失败: {e}")))?; menu_builder = menu_builder.item(&empty_hint); } } // 分隔符和退出菜单 let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) - .map_err(|e| AppError::Message(format!("创建退出菜单失败: {}", e)))?; + .map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?; menu_builder = menu_builder.separator().item(&quit_item); menu_builder .build() - .map_err(|e| AppError::Message(format!("构建菜单失败: {}", e))) + .map_err(|e| AppError::Message(format!("构建菜单失败: {e}"))) } #[cfg(target_os = "macos")] @@ -210,17 +211,17 @@ fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { }; if let Err(err) = app.set_dock_visibility(dock_visible) { - log::warn!("设置 Dock 显示状态失败: {}", err); + log::warn!("设置 Dock 显示状态失败: {err}"); } if let Err(err) = app.set_activation_policy(desired_policy) { - log::warn!("设置激活策略失败: {}", err); + log::warn!("设置激活策略失败: {err}"); } } /// 处理托盘菜单事件 fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { - log::info!("处理托盘菜单事件: {}", event_id); + log::info!("处理托盘菜单事件: {event_id}"); match event_id { "show_main" => { @@ -244,10 +245,10 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } id if id.starts_with("claude_") => { let Some(provider_id) = id.strip_prefix("claude_") else { - log::error!("无效的 Claude 菜单项 ID: {}", id); + log::error!("无效的 Claude 菜单项 ID: {id}"); return; }; - log::info!("切换到Claude供应商: {}", provider_id); + log::info!("切换到Claude供应商: {provider_id}"); // 执行切换 let app_handle = app.clone(); @@ -258,16 +259,16 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { crate::app_config::AppType::Claude, provider_id, ) { - log::error!("切换Claude供应商失败: {}", e); + log::error!("切换Claude供应商失败: {e}"); } }); } id if id.starts_with("codex_") => { let Some(provider_id) = id.strip_prefix("codex_") else { - log::error!("无效的 Codex 菜单项 ID: {}", id); + log::error!("无效的 Codex 菜单项 ID: {id}"); return; }; - log::info!("切换到Codex供应商: {}", provider_id); + log::info!("切换到Codex供应商: {provider_id}"); // 执行切换 let app_handle = app.clone(); @@ -278,12 +279,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { crate::app_config::AppType::Codex, provider_id, ) { - log::error!("切换Codex供应商失败: {}", e); + log::error!("切换Codex供应商失败: {e}"); } }); } _ => { - log::warn!("未处理的菜单事件: {}", event_id); + log::warn!("未处理的菜单事件: {event_id}"); } } } @@ -308,7 +309,7 @@ fn switch_provider_internal( if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { if let Some(tray) = app.tray_by_id("main") { if let Err(e) = tray.set_menu(Some(new_menu)) { - log::error!("更新托盘菜单失败: {}", e); + log::error!("更新托盘菜单失败: {e}"); } } } @@ -319,7 +320,7 @@ fn switch_provider_internal( "providerId": provider_id_clone }); if let Err(e) = app.emit("provider-switched", event_data) { - log::error!("发射供应商切换事件失败: {}", e); + log::error!("发射供应商切换事件失败: {e}"); } } Ok(()) @@ -335,13 +336,13 @@ async fn update_tray_menu( Ok(new_menu) => { if let Some(tray) = app.tray_by_id("main") { tray.set_menu(Some(new_menu)) - .map_err(|e| format!("更新托盘菜单失败: {}", e))?; + .map_err(|e| format!("更新托盘菜单失败: {e}"))?; return Ok(true); } Ok(false) } Err(err) => { - log::error!("创建托盘菜单失败: {}", err); + log::error!("创建托盘菜单失败: {err}"); Ok(false) } } @@ -397,7 +398,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) { // 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用 - log::warn!("初始化 Updater 插件失败,已跳过:{}", e); + log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } #[cfg(target_os = "macos")] @@ -454,7 +455,7 @@ pub fn run() { }); // 事件通知(可能早于前端订阅,不保证送达) if let Err(e) = app.emit("configLoadError", payload_json) { - log::error!("发射配置加载错误事件失败: {}", e); + log::error!("发射配置加载错误事件失败: {e}"); } // 同时缓存错误,供前端启动阶段主动拉取 crate::init_status::set_init_error(crate::init_status::InitErrorPayload { @@ -468,7 +469,7 @@ pub fn run() { // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { - log::warn!("迁移 app_config_dir 失败: {}", e); + log::warn!("迁移 app_config_dir 失败: {e}"); } // 确保配置结构就绪(已移除旧版本的副本迁移逻辑) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index a7139af..45c7701 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -55,8 +55,7 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> { if let Some(val) = obj.get(key) { if !val.is_string() { return Err(AppError::McpValidation(format!( - "MCP 服务器 {} 必须为字符串", - key + "MCP 服务器 {key} 必须为字符串" ))); } } @@ -138,9 +137,7 @@ fn normalize_server_keys(map: &mut HashMap) -> usize { } if map.contains_key(&new_key) { log::warn!( - "MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键", - old_key, - new_key + "MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键" ); if let Some(value) = map.get_mut(&old_key) { if let Some(obj) = value.as_object_mut() { @@ -161,7 +158,7 @@ fn normalize_server_keys(map: &mut HashMap) -> usize { if let Some(obj) = value.as_object_mut() { obj.insert("id".into(), json!(new_key.clone())); } - log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key); + log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'"); map.insert(new_key, value); change_count += 1; } @@ -208,7 +205,7 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { out.insert(id.clone(), spec); } Err(err) => { - log::warn!("跳过无效的 MCP 条目 '{}': {}", id, err); + log::warn!("跳过无效的 MCP 条目 '{id}': {err}"); } } } @@ -223,7 +220,7 @@ pub fn get_servers_snapshot_for( let mut snapshot = config.mcp_for(app).servers.clone(); snapshot.retain(|id, value| { let Some(obj) = value.as_object_mut() else { - log::warn!("跳过无效的 MCP 条目 '{}': 必须为 JSON 对象", id); + log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象"); return false; }; @@ -232,7 +229,7 @@ pub fn get_servers_snapshot_for( match validate_mcp_entry(value) { Ok(()) => true, Err(err) => { - log::error!("config.json 中存在无效的 MCP 条目 '{}': {}", id, err); + log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}"); false } } @@ -262,8 +259,7 @@ pub fn upsert_in_config_for( }; if existing_id_str != id { return Err(AppError::McpValidation(format!( - "MCP 服务器条目中的 id '{}' 与参数 id '{}' 不一致", - existing_id_str, id + "MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致" ))); } } else { @@ -332,7 +328,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result { let value = occ.get_mut(); let Some(existing) = value.as_object_mut() else { - log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id); + log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据"); let mut obj = serde_json::Map::new(); obj.insert(String::from("id"), json!(id)); obj.insert(String::from("name"), json!(id)); @@ -380,12 +376,12 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result let mut changed_total = normalize_servers_for(config, &AppType::Codex); let root: toml::Table = toml::from_str(&text) - .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {}", e)))?; + .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?; // helper:处理一组 servers 表 let mut import_servers_tbl = |servers_tbl: &toml::value::Table| { @@ -484,7 +480,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result // 校验 if let Err(e) = validate_server_spec(&spec_v) { - log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e); + log::warn!("跳过无效 Codex MCP 项 '{id}': {e}"); continue; } @@ -507,7 +503,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result Entry::Occupied(mut occ) => { let value = occ.get_mut(); let Some(existing) = value.as_object_mut() else { - log::warn!("MCP 条目 '{}' 不是 JSON 对象,覆盖为导入数据", id); + log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据"); let mut obj = serde_json::Map::new(); obj.insert(String::from("id"), json!(id)); obj.insert(String::from("name"), json!(id)); @@ -528,12 +524,12 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result modified = true; } if existing.get("server").is_none() { - log::warn!("MCP 条目 '{}' 缺少 server 字段,覆盖为导入数据", id); + log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据"); existing.insert(String::from("server"), spec_v.clone()); modified = true; } if existing.get("id").is_none() { - log::warn!("MCP 条目 '{}' 缺少 id 字段,自动填充", id); + log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充"); existing.insert(String::from("id"), json!(id)); modified = true; } @@ -587,7 +583,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { } else { base_text .parse::() - .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {}", e)))? + .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))? }; enum Target { diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 6682dee..b6aa159 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -128,6 +128,15 @@ pub struct ProviderMeta { /// 用量查询脚本配置 #[serde(skip_serializing_if = "Option::is_none")] pub usage_script: Option, + /// 合作伙伴标记(前端使用 isPartner,保持字段名一致) + #[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")] + pub is_partner: Option, + /// 合作伙伴促销 key,用于识别 PackyCode 等特殊供应商 + #[serde( + rename = "partnerPromotionKey", + skip_serializing_if = "Option::is_none" + )] + pub partner_promotion_key: Option, } impl ProviderManager { diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 4036725..2b62a76 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,3 +1,4 @@ +use super::provider::ProviderService; use crate::app_config::{AppType, MultiAppConfig}; use crate::error::AppError; use crate::provider::Provider; @@ -20,7 +21,7 @@ impl ConfigService { } let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); - let backup_id = format!("backup_{}", timestamp); + let backup_id = format!("backup_{timestamp}"); let backup_dir = config_path .parent() @@ -29,7 +30,7 @@ impl ConfigService { fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - let backup_path = backup_dir.join(format!("{}.json", backup_id)); + let backup_path = backup_dir.join(format!("{backup_id}.json")); let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?; fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?; @@ -123,6 +124,7 @@ impl ConfigService { pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> { Self::sync_current_provider_for_app(config, &AppType::Claude)?; Self::sync_current_provider_for_app(config, &AppType::Codex)?; + Self::sync_current_provider_for_app(config, &AppType::Gemini)?; Ok(()) } @@ -145,9 +147,7 @@ impl ConfigService { Some(provider) => provider.clone(), None => { log::warn!( - "当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步", - app_type, - current_id + "当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步" ); return Ok(()); } @@ -158,6 +158,7 @@ impl ConfigService { match app_type { AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, + AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, } Ok(()) @@ -169,18 +170,16 @@ impl ConfigService { provider: &Provider, ) -> Result<(), AppError> { let settings = provider.settings_config.as_object().ok_or_else(|| { - AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id)) + AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象")) })?; let auth = settings.get("auth").ok_or_else(|| { AppError::Config(format!( - "供应商 {} 的 Codex 配置缺少 auth 字段", - provider_id + "供应商 {provider_id} 的 Codex 配置缺少 auth 字段" )) })?; if !auth.is_object() { return Err(AppError::Config(format!( - "供应商 {} 的 Codex auth 配置必须是 JSON 对象", - provider_id + "供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象" ))); } let cfg_text = settings.get("config").and_then(Value::as_str); @@ -226,4 +225,53 @@ impl ConfigService { Ok(()) } + + fn sync_gemini_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + use crate::gemini_config::{json_to_env, write_gemini_env_atomic, read_gemini_env, env_to_json}; + + let env_path = crate::gemini_config::get_gemini_env_path(); + if let Some(parent) = env_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + // 转换 JSON 配置为 .env 格式 + let env_map = json_to_env(&provider.settings_config)?; + + // Google 官方(OAuth): env 为空,写入空文件并设置安全标志后返回 + if env_map.is_empty() { + write_gemini_env_atomic(&env_map)?; + ProviderService::ensure_google_oauth_security_flag(provider)?; + + let live_after_env = read_gemini_env()?; + let live_after = env_to_json(&live_after_env); + + if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + return Ok(()); + } + + // 非 OAuth:按常规写入,并在必要时设置 Packycode 安全标志 + write_gemini_env_atomic(&env_map)?; + ProviderService::ensure_packycode_security_flag(provider)?; + + // 读回实际写入的内容并更新到配置中 + let live_after_env = read_gemini_env()?; + let live_after = env_to_json(&live_after_env); + + if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 5bd8f81..2f1621f 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -54,7 +54,8 @@ impl McpService { // 修复:sync_other_side=true 时,先将 MCP 复制到另一侧,然后强制同步 // 这才是"同步到另一侧"的正确语义:将 MCP 跨应用复制 - if sync_other_side { + if sync_other_side && app != AppType::Gemini { + // Gemini 暂不支持跨应用复制,直接跳过 // 获取当前 MCP 条目的克隆(刚刚插入的,不可能失败) let current_entry = cfg .mcp_for(&app) @@ -67,6 +68,7 @@ impl McpService { let other_app = match app { AppType::Claude => AppType::Codex, AppType::Codex => AppType::Claude, + AppType::Gemini => unreachable!("Gemini 已在外层 if 中跳过"), }; cfg.mcp_for_mut(&other_app) @@ -77,6 +79,7 @@ impl McpService { match app { AppType::Claude => sync_codex = true, AppType::Codex => sync_claude = true, + AppType::Gemini => unreachable!("Gemini 已在外层 if 中跳过"), } } @@ -118,6 +121,7 @@ impl McpService { match app { AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步 } } } @@ -144,6 +148,7 @@ impl McpService { match app { AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步 } } } @@ -163,6 +168,7 @@ impl McpService { match app { AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?, AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?, + AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步 } Ok(()) } diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs index d8afcfd..2cc0de9 100644 --- a/src-tauri/src/services/provider.rs +++ b/src-tauri/src/services/provider.rs @@ -29,6 +29,9 @@ enum LiveSnapshot { auth: Option, config: Option, }, + Gemini { + env: Option>, // 新增 + }, } #[derive(Clone)] @@ -66,6 +69,15 @@ impl LiveSnapshot { delete_file(&config_path)?; } } + LiveSnapshot::Gemini { env } => { // 新增 + use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic}; + let path = get_gemini_env_path(); + if let Some(env_map) = env { + write_gemini_env_atomic(env_map)?; + } else if path.exists() { + delete_file(&path)?; + } + } } Ok(()) } @@ -111,7 +123,285 @@ mod tests { } } +/// Gemini 认证类型枚举 +/// +/// 用于优化性能,避免重复检测供应商类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GeminiAuthType { + /// PackyCode 供应商(使用 API Key) + Packycode, + /// Google 官方(使用 OAuth) + GoogleOfficial, + /// 通用 Gemini 供应商(使用 API Key) + Generic, +} + impl ProviderService { + // 认证类型常量 + const PACKYCODE_SECURITY_SELECTED_TYPE: &'static str = "gemini-api-key"; + const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &'static str = "oauth-personal"; + + // Partner Promotion Key 常量 + const PACKYCODE_PARTNER_KEY: &'static str = "packycode"; + const GOOGLE_OFFICIAL_PARTNER_KEY: &'static str = "google-official"; + + // PackyCode 关键词常量 + const PACKYCODE_KEYWORDS: [&'static str; 3] = ["packycode", "packyapi", "packy"]; + + /// 检测 Gemini 供应商的认证类型 + /// + /// 一次性检测,避免在多个地方重复调用 `is_packycode_gemini` 和 `is_google_official_gemini` + /// + /// # 返回值 + /// + /// - `GeminiAuthType::GoogleOfficial`: Google 官方,使用 OAuth + /// - `GeminiAuthType::Packycode`: PackyCode 供应商,使用 API Key + /// - `GeminiAuthType::Generic`: 其他通用供应商,使用 API Key + fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType { + // 优先检查 partner_promotion_key(最可靠) + if let Some(key) = provider + .meta + .as_ref() + .and_then(|meta| meta.partner_promotion_key.as_deref()) + { + if key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY) { + return GeminiAuthType::GoogleOfficial; + } + if key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY) { + return GeminiAuthType::Packycode; + } + } + + // 检查 Google 官方(名称匹配) + let name_lower = provider.name.to_ascii_lowercase(); + if name_lower == "google" || name_lower.starts_with("google ") { + return GeminiAuthType::GoogleOfficial; + } + + // 检查 PackyCode 关键词 + if Self::contains_packycode_keyword(&provider.name) { + return GeminiAuthType::Packycode; + } + + if let Some(site) = provider.website_url.as_deref() { + if Self::contains_packycode_keyword(site) { + return GeminiAuthType::Packycode; + } + } + + if let Some(base_url) = provider + .settings_config + .pointer("/env/GOOGLE_GEMINI_BASE_URL") + .and_then(|v| v.as_str()) + { + if Self::contains_packycode_keyword(base_url) { + return GeminiAuthType::Packycode; + } + } + + GeminiAuthType::Generic + } + + /// 检查字符串是否包含 PackyCode 相关关键词(不区分大小写) + /// + /// 关键词列表:["packycode", "packyapi", "packy"] + fn contains_packycode_keyword(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + Self::PACKYCODE_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) + } + + /// 检测供应商是否为 PackyCode Gemini(使用 API Key 认证) + /// + /// PackyCode 是官方合作伙伴,需要特殊的安全配置。 + /// + /// # 检测规则(优先级从高到低) + /// + /// 1. **Partner Promotion Key**(最可靠): + /// - `provider.meta.partner_promotion_key == "packycode"` + /// + /// 2. **供应商名称**: + /// - 名称包含 "packycode"、"packyapi" 或 "packy"(不区分大小写) + /// + /// 3. **网站 URL**: + /// - `provider.website_url` 包含关键词 + /// + /// 4. **Base URL**: + /// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` 包含关键词 + /// + /// # 为什么需要多重检测 + /// + /// - 用户可能手动创建供应商,没有 `partner_promotion_key` + /// - 从预设复制后可能修改了 meta 字段 + /// - 确保所有 PackyCode 供应商都能正确设置安全标志 + fn is_packycode_gemini(provider: &Provider) -> bool { + // 策略 1: 检查 partner_promotion_key(最可靠) + if provider + .meta + .as_ref() + .and_then(|meta| meta.partner_promotion_key.as_deref()) + .is_some_and(|key| key.eq_ignore_ascii_case(Self::PACKYCODE_PARTNER_KEY)) + { + return true; + } + + // 策略 2: 检查供应商名称 + if Self::contains_packycode_keyword(&provider.name) { + return true; + } + + // 策略 3: 检查网站 URL + if let Some(site) = provider.website_url.as_deref() { + if Self::contains_packycode_keyword(site) { + return true; + } + } + + // 策略 4: 检查 Base URL + if let Some(base_url) = provider + .settings_config + .pointer("/env/GOOGLE_GEMINI_BASE_URL") + .and_then(|v| v.as_str()) + { + if Self::contains_packycode_keyword(base_url) { + return true; + } + } + + false + } + + /// 检测供应商是否为 Google 官方 Gemini(使用 OAuth 认证) + /// + /// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。 + /// + /// # 检测规则(优先级从高到低) + /// + /// 1. **Partner Promotion Key**(最可靠): + /// - `provider.meta.partner_promotion_key == "google-official"` + /// + /// 2. **供应商名称**: + /// - 名称完全等于 "google"(不区分大小写) + /// - 或名称以 "google " 开头(例如 "Google Official") + /// + /// # OAuth vs API Key + /// + /// - **OAuth 模式**: `security.auth.selectedType = "oauth-personal"` + /// - 用户需要通过浏览器登录 Google 账号 + /// - 不需要在 `.env` 文件中配置 API Key + /// + /// - **API Key 模式**: `security.auth.selectedType = "gemini-api-key"` + /// - 用于第三方中转服务(如 PackyCode) + /// - 需要在 `.env` 文件中配置 `GEMINI_API_KEY` + fn is_google_official_gemini(provider: &Provider) -> bool { + // 策略 1: 检查 partner_promotion_key(最可靠) + if provider + .meta + .as_ref() + .and_then(|meta| meta.partner_promotion_key.as_deref()) + .is_some_and(|key| key.eq_ignore_ascii_case(Self::GOOGLE_OFFICIAL_PARTNER_KEY)) + { + return true; + } + + // 策略 2: 检查名称匹配(备用方案) + let name_lower = provider.name.to_ascii_lowercase(); + name_lower == "google" || name_lower.starts_with("google ") + } + + /// 确保 PackyCode Gemini 供应商的安全标志正确设置 + /// + /// PackyCode 是官方合作伙伴,使用 API Key 认证模式。 + /// + /// # 写入两处 settings.json 的原因 + /// + /// 1. **`~/.cc-switch/settings.json`** (应用级配置): + /// - CC-Switch 应用的全局设置 + /// - 确保应用知道当前使用的认证类型 + /// - 用于 UI 显示和其他应用逻辑 + /// + /// 2. **`~/.gemini/settings.json`** (Gemini 客户端配置): + /// - Gemini CLI 客户端读取的配置文件 + /// - 直接影响 Gemini 客户端的认证行为 + /// - 确保 Gemini 使用正确的认证方式连接 API + /// + /// # 设置的值 + /// + /// ```json + /// { + /// "security": { + /// "auth": { + /// "selectedType": "gemini-api-key" + /// } + /// } + /// } + /// ``` + /// + /// # 错误处理 + /// + /// 如果供应商不是 PackyCode,函数立即返回 `Ok(())`,不做任何操作。 + pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> { + if !Self::is_packycode_gemini(provider) { + return Ok(()); + } + + // 写入应用级别的 settings.json (~/.cc-switch/settings.json) + settings::ensure_security_auth_selected_type(Self::PACKYCODE_SECURITY_SELECTED_TYPE)?; + + // 写入 Gemini 目录的 settings.json (~/.gemini/settings.json) + use crate::gemini_config::write_packycode_settings; + write_packycode_settings()?; + + Ok(()) + } + + /// 确保 Google 官方 Gemini 供应商的安全标志正确设置(OAuth 模式) + /// + /// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。 + /// + /// # 写入两处 settings.json 的原因 + /// + /// 同 `ensure_packycode_security_flag`,需要同时配置应用级和客户端级设置。 + /// + /// # 设置的值 + /// + /// ```json + /// { + /// "security": { + /// "auth": { + /// "selectedType": "oauth-personal" + /// } + /// } + /// } + /// ``` + /// + /// # OAuth 认证流程 + /// + /// 1. 用户切换到 Google 官方供应商 + /// 2. CC-Switch 设置 `selectedType = "oauth-personal"` + /// 3. 用户首次使用 Gemini CLI 时,会自动打开浏览器进行 OAuth 登录 + /// 4. 登录成功后,凭证保存在 Gemini 的 credential store 中 + /// 5. 后续请求自动使用保存的凭证 + /// + /// # 错误处理 + /// + /// 如果供应商不是 Google 官方,函数立即返回 `Ok(())`,不做任何操作。 + pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> { + if !Self::is_google_official_gemini(provider) { + return Ok(()); + } + + // 写入应用级别的 settings.json (~/.cc-switch/settings.json) + settings::ensure_security_auth_selected_type(Self::GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?; + + // 写入 Gemini 目录的 settings.json (~/.gemini/settings.json) + use crate::gemini_config::write_google_oauth_settings; + write_google_oauth_settings()?; + + Ok(()) + } + /// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键 fn normalize_claude_models_in_value(settings: &mut Value) -> bool { let mut changed = false; @@ -211,10 +501,9 @@ impl ProviderService { if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) { return Err(AppError::localized( "config.save.rollback_failed", - format!("保存配置失败: {};回滚失败: {}", save_err, rollback_err), + format!("保存配置失败: {save_err};回滚失败: {rollback_err}"), format!( - "Failed to save config: {}; rollback failed: {}", - save_err, rollback_err + "Failed to save config: {save_err}; rollback failed: {rollback_err}" ), )); } @@ -228,10 +517,9 @@ impl ProviderService { { return Err(AppError::localized( "post_commit.rollback_failed", - format!("后置操作失败: {};回滚失败: {}", err, rollback_err), + format!("后置操作失败: {err};回滚失败: {rollback_err}"), format!( - "Post-commit step failed: {}; rollback failed: {}", - err, rollback_err + "Post-commit step failed: {err}; rollback failed: {rollback_err}" ), )); } @@ -319,8 +607,7 @@ impl ProviderService { if let Some(target) = manager.providers.get_mut(provider_id) { let obj = target.settings_config.as_object_mut().ok_or_else(|| { AppError::Config(format!( - "供应商 {} 的 Codex 配置必须是 JSON 对象", - provider_id + "供应商 {provider_id} 的 Codex 配置必须是 JSON 对象" )) })?; obj.insert("auth".to_string(), auth.clone()); @@ -330,6 +617,30 @@ impl ProviderService { } state.save()?; } + AppType::Gemini => { + use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json}; + + let env_path = get_gemini_env_path(); + if !env_path.exists() { + return Err(AppError::localized( + "gemini.live.missing", + "Gemini .env 文件不存在,无法刷新快照", + "Gemini .env file missing; cannot refresh snapshot", + )); + } + let env_map = read_gemini_env()?; + let live_after = env_to_json(&env_map); + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } } Ok(()) } @@ -363,6 +674,16 @@ impl ProviderService { }; Ok(LiveSnapshot::Codex { auth, config }) } + AppType::Gemini => { // 新增 + use crate::gemini_config::{get_gemini_env_path, read_gemini_env}; + let path = get_gemini_env_path(); + let env = if path.exists() { + Some(read_gemini_env()?) + } else { + None + }; + Ok(LiveSnapshot::Gemini { env }) + } } } @@ -447,8 +768,8 @@ impl ProviderService { if !manager.providers.contains_key(&provider_id) { return Err(AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), )); } @@ -530,6 +851,20 @@ impl ProviderService { let _ = Self::normalize_claude_models_in_value(&mut v); v } + AppType::Gemini => { // 新增 + use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json}; + + let path = get_gemini_env_path(); + if !path.exists() { + return Err(AppError::localized( + "gemini.live.missing", + "Gemini 配置文件不存在", + "Gemini configuration file is missing", + )); + } + let env_map = read_gemini_env()?; + env_to_json(&env_map) + } }; let mut provider = Provider::with_id( @@ -582,6 +917,21 @@ impl ProviderService { } read_json_file(&path) } + AppType::Gemini => { // 新增 + use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json}; + + let path = get_gemini_env_path(); + if !path.exists() { + return Err(AppError::localized( + "gemini.env.missing", + "Gemini .env 文件不存在", + "Gemini .env file not found", + )); + } + + let env_map = read_gemini_env()?; + Ok(env_to_json(&env_map)) + } } } @@ -635,8 +985,8 @@ impl ProviderService { let provider = manager.providers.get_mut(provider_id).ok_or_else(|| { AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), ) })?; let meta = provider.meta.get_or_insert_with(ProviderMeta::default); @@ -750,16 +1100,16 @@ impl ProviderService { serde_json::from_value(data).map_err(|e| { AppError::localized( "usage_script.data_format_error", - format!("数据格式错误: {}", e), - format!("Data format error: {}", e), + format!("数据格式错误: {e}"), + format!("Data format error: {e}"), ) })? } else { let single: UsageData = serde_json::from_value(data).map_err(|e| { AppError::localized( "usage_script.data_format_error", - format!("数据格式错误: {}", e), - format!("Data format error: {}", e), + format!("数据格式错误: {e}"), + format!("Data format error: {e}"), ) })?; vec![single] @@ -810,8 +1160,8 @@ impl ProviderService { let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| { AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), ) })?; @@ -891,6 +1241,7 @@ impl ProviderService { let provider = match app_type_clone { AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?, AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?, + AppType::Gemini => Self::prepare_switch_gemini(config, &provider_id_owned)?, }; let action = PostCommitAction { @@ -918,8 +1269,8 @@ impl ProviderService { .ok_or_else(|| { AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), ) })?; @@ -1005,8 +1356,8 @@ impl ProviderService { .ok_or_else(|| { AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), ) })?; @@ -1019,6 +1370,33 @@ impl ProviderService { Ok(provider) } + fn prepare_switch_gemini( + config: &mut MultiAppConfig, + provider_id: &str, + ) -> Result { + let provider = config + .get_manager(&AppType::Gemini) + .ok_or_else(|| Self::app_not_found(&AppType::Gemini))? + .providers + .get(provider_id) + .cloned() + .ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + + Self::backfill_gemini_current(config, provider_id)?; + + if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { + manager.current = provider_id.to_string(); + } + + Ok(provider) + } + fn backfill_claude_current( config: &mut MultiAppConfig, next_provider: &str, @@ -1047,23 +1425,80 @@ impl ProviderService { Ok(()) } - fn write_claude_live(provider: &Provider) -> Result<(), AppError> { - let settings_path = get_claude_settings_path(); - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + fn backfill_gemini_current( + config: &mut MultiAppConfig, + next_provider: &str, + ) -> Result<(), AppError> { + use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json}; + + let env_path = get_gemini_env_path(); + if !env_path.exists() { + return Ok(()); } - // 归一化后再写入 + let current_id = config + .get_manager(&AppType::Gemini) + .map(|m| m.current.clone()) + .unwrap_or_default(); + if current_id.is_empty() || current_id == next_provider { + return Ok(()); + } + + let env_map = read_gemini_env()?; + let live = env_to_json(&env_map); + if let Some(manager) = config.get_manager_mut(&AppType::Gemini) { + if let Some(current) = manager.providers.get_mut(¤t_id) { + current.settings_config = live; + } + } + + Ok(()) + } + + fn write_claude_live(provider: &Provider) -> Result<(), AppError> { + let settings_path = get_claude_settings_path(); let mut content = provider.settings_config.clone(); let _ = Self::normalize_claude_models_in_value(&mut content); write_json_file(&settings_path, &content)?; Ok(()) } + fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { + use crate::gemini_config::{json_to_env, validate_gemini_settings, write_gemini_env_atomic}; + + // 一次性检测认证类型,避免重复检测 + let auth_type = Self::detect_gemini_auth_type(provider); + + match auth_type { + GeminiAuthType::GoogleOfficial => { + // Google 官方使用 OAuth,清空 env + let empty_env = std::collections::HashMap::new(); + write_gemini_env_atomic(&empty_env)?; + Self::ensure_google_oauth_security_flag(provider)?; + } + GeminiAuthType::Packycode => { + // PackyCode 供应商,使用 API Key + validate_gemini_settings(&provider.settings_config)?; + let env_map = json_to_env(&provider.settings_config)?; + write_gemini_env_atomic(&env_map)?; + Self::ensure_packycode_security_flag(provider)?; + } + GeminiAuthType::Generic => { + // 通用供应商,使用 API Key + validate_gemini_settings(&provider.settings_config)?; + let env_map = json_to_env(&provider.settings_config)?; + write_gemini_env_atomic(&env_map)?; + } + } + + Ok(()) + } + fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Codex => Self::write_codex_live(provider), AppType::Claude => Self::write_claude_live(provider), + AppType::Gemini => Self::write_gemini_live(provider), // 新增 } } @@ -1118,6 +1553,10 @@ impl ProviderService { } } } + AppType::Gemini => { // 新增 + use crate::gemini_config::validate_gemini_settings; + validate_gemini_settings(&provider.settings_config)? + } } Ok(()) @@ -1204,8 +1643,8 @@ impl ProviderService { let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { AppError::localized( "provider.regex_init_failed", - format!("正则初始化失败: {}", e), - format!("Failed to initialize regex: {}", e), + format!("正则初始化失败: {e}"), + format!("Failed to initialize regex: {e}"), ) })?; re.captures(config_toml) @@ -1226,6 +1665,27 @@ impl ProviderService { )); }; + Ok((api_key, base_url)) + } + AppType::Gemini => { // 新增 + use crate::gemini_config::json_to_env; + + let env_map = json_to_env(&provider.settings_config)?; + + let api_key = env_map + .get("GEMINI_API_KEY") + .cloned() + .ok_or_else(|| AppError::localized( + "gemini.missing_api_key", + "缺少 GEMINI_API_KEY", + "Missing GEMINI_API_KEY", + ))?; + + let base_url = env_map + .get("GOOGLE_GEMINI_BASE_URL") + .cloned() + .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()); + Ok((api_key, base_url)) } } @@ -1234,8 +1694,8 @@ impl ProviderService { fn app_not_found(app_type: &AppType) -> AppError { AppError::localized( "provider.app_not_found", - format!("应用类型不存在: {:?}", app_type), - format!("App type not found: {:?}", app_type), + format!("应用类型不存在: {app_type:?}"), + format!("App type not found: {app_type:?}"), ) } @@ -1264,8 +1724,8 @@ impl ProviderService { manager.providers.get(provider_id).cloned().ok_or_else(|| { AppError::localized( "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), ) })? }; @@ -1285,6 +1745,9 @@ impl ProviderService { delete_file(&by_name)?; delete_file(&by_id)?; } + AppType::Gemini => { + // Gemini 使用单一的 .env 文件,不需要删除单独的供应商配置文件 + } } { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b4d783e..75b4c3c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -16,6 +16,20 @@ pub struct CustomEndpoint { pub last_used: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SecurityAuthSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, +} + /// 应用设置结构,允许覆盖默认配置目录 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -32,7 +46,11 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub gemini_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option, /// Claude 自定义端点列表 #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub custom_endpoints_claude: HashMap, @@ -57,7 +75,9 @@ impl Default for AppSettings { enable_claude_plugin_integration: false, claude_config_dir: None, codex_config_dir: None, + gemini_config_dir: None, language: None, + security: None, custom_endpoints_claude: HashMap::new(), custom_endpoints_codex: HashMap::new(), } @@ -89,6 +109,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.gemini_config_dir = self + .gemini_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -171,6 +198,27 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { Ok(()) } +pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> { + let mut settings = get_settings(); + let current = settings + .security + .as_ref() + .and_then(|sec| sec.auth.as_ref()) + .and_then(|auth| auth.selected_type.as_deref()); + + if current == Some(selected_type) { + return Ok(()); + } + + let mut security = settings.security.unwrap_or_default(); + let mut auth = security.auth.unwrap_or_default(); + auth.selected_type = Some(selected_type.to_string()); + security.auth = Some(auth); + settings.security = Some(security); + + update_settings(settings) +} + pub fn get_claude_override_dir() -> Option { let settings = settings_store().read().ok()?; settings @@ -186,3 +234,11 @@ pub fn get_codex_override_dir() -> Option { .as_ref() .map(|p| resolve_override_path(p)) } + +pub fn get_gemini_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .gemini_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 8ec7357..304843c 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -33,15 +33,15 @@ pub async fn execute_usage_script( let runtime = Runtime::new().map_err(|e| { AppError::localized( "usage_script.runtime_create_failed", - format!("创建 JS 运行时失败: {}", e), - format!("Failed to create JS runtime: {}", e), + format!("创建 JS 运行时失败: {e}"), + format!("Failed to create JS runtime: {e}"), ) })?; let context = Context::full(&runtime).map_err(|e| { AppError::localized( "usage_script.context_create_failed", - format!("创建 JS 上下文失败: {}", e), - format!("Failed to create JS context: {}", e), + format!("创建 JS 上下文失败: {e}"), + format!("Failed to create JS context: {e}"), ) })?; @@ -50,8 +50,8 @@ pub async fn execute_usage_script( let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| { AppError::localized( "usage_script.config_parse_failed", - format!("解析配置失败: {}", e), - format!("Failed to parse config: {}", e), + format!("解析配置失败: {e}"), + format!("Failed to parse config: {e}"), ) })?; @@ -59,8 +59,8 @@ pub async fn execute_usage_script( let request: rquickjs::Object = config.get("request").map_err(|e| { AppError::localized( "usage_script.request_missing", - format!("缺少 request 配置: {}", e), - format!("Missing request config: {}", e), + format!("缺少 request 配置: {e}"), + format!("Missing request config: {e}"), ) })?; @@ -70,8 +70,8 @@ pub async fn execute_usage_script( .map_err(|e| { AppError::localized( "usage_script.request_serialize_failed", - format!("序列化 request 失败: {}", e), - format!("Failed to serialize request: {}", e), + format!("序列化 request 失败: {e}"), + format!("Failed to serialize request: {e}"), ) })? .ok_or_else(|| { @@ -85,8 +85,8 @@ pub async fn execute_usage_script( .map_err(|e| { AppError::localized( "usage_script.get_string_failed", - format!("获取字符串失败: {}", e), - format!("Failed to get string: {}", e), + format!("获取字符串失败: {e}"), + format!("Failed to get string: {e}"), ) })?; @@ -98,8 +98,8 @@ pub async fn execute_usage_script( let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| { AppError::localized( "usage_script.request_format_invalid", - format!("request 配置格式错误: {}", e), - format!("Invalid request config format: {}", e), + format!("request 配置格式错误: {e}"), + format!("Invalid request config format: {e}"), ) })?; @@ -111,15 +111,15 @@ pub async fn execute_usage_script( let runtime = Runtime::new().map_err(|e| { AppError::localized( "usage_script.runtime_create_failed", - format!("创建 JS 运行时失败: {}", e), - format!("Failed to create JS runtime: {}", e), + format!("创建 JS 运行时失败: {e}"), + format!("Failed to create JS runtime: {e}"), ) })?; let context = Context::full(&runtime).map_err(|e| { AppError::localized( "usage_script.context_create_failed", - format!("创建 JS 上下文失败: {}", e), - format!("Failed to create JS context: {}", e), + format!("创建 JS 上下文失败: {e}"), + format!("Failed to create JS context: {e}"), ) })?; @@ -128,8 +128,8 @@ pub async fn execute_usage_script( let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| { AppError::localized( "usage_script.config_reparse_failed", - format!("重新解析配置失败: {}", e), - format!("Failed to re-parse config: {}", e), + format!("重新解析配置失败: {e}"), + format!("Failed to re-parse config: {e}"), ) })?; @@ -137,8 +137,8 @@ pub async fn execute_usage_script( let extractor: Function = config.get("extractor").map_err(|e| { AppError::localized( "usage_script.extractor_missing", - format!("缺少 extractor 函数: {}", e), - format!("Missing extractor function: {}", e), + format!("缺少 extractor 函数: {e}"), + format!("Missing extractor function: {e}"), ) })?; @@ -147,8 +147,8 @@ pub async fn execute_usage_script( ctx.json_parse(response_data.as_str()).map_err(|e| { AppError::localized( "usage_script.response_parse_failed", - format!("解析响应 JSON 失败: {}", e), - format!("Failed to parse response JSON: {}", e), + format!("解析响应 JSON 失败: {e}"), + format!("Failed to parse response JSON: {e}"), ) })?; @@ -156,8 +156,8 @@ pub async fn execute_usage_script( let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| { AppError::localized( "usage_script.extractor_exec_failed", - format!("执行 extractor 失败: {}", e), - format!("Failed to execute extractor: {}", e), + format!("执行 extractor 失败: {e}"), + format!("Failed to execute extractor: {e}"), ) })?; @@ -167,8 +167,8 @@ pub async fn execute_usage_script( .map_err(|e| { AppError::localized( "usage_script.result_serialize_failed", - format!("序列化结果失败: {}", e), - format!("Failed to serialize result: {}", e), + format!("序列化结果失败: {e}"), + format!("Failed to serialize result: {e}"), ) })? .ok_or_else(|| { @@ -182,8 +182,8 @@ pub async fn execute_usage_script( .map_err(|e| { AppError::localized( "usage_script.get_string_failed", - format!("获取字符串失败: {}", e), - format!("Failed to get string: {}", e), + format!("获取字符串失败: {e}"), + format!("Failed to get string: {e}"), ) })?; @@ -191,8 +191,8 @@ pub async fn execute_usage_script( serde_json::from_str(&result_json).map_err(|e| { AppError::localized( "usage_script.json_parse_failed", - format!("JSON 解析失败: {}", e), - format!("JSON parse failed: {}", e), + format!("JSON 解析失败: {e}"), + format!("JSON parse failed: {e}"), ) }) })? @@ -225,8 +225,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< .map_err(|e| { AppError::localized( "usage_script.client_create_failed", - format!("创建客户端失败: {}", e), - format!("Failed to create client: {}", e), + format!("创建客户端失败: {e}"), + format!("Failed to create client: {e}"), ) })?; @@ -255,8 +255,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let resp = req.send().await.map_err(|e| { AppError::localized( "usage_script.request_failed", - format!("请求失败: {}", e), - format!("Request failed: {}", e), + format!("请求失败: {e}"), + format!("Request failed: {e}"), ) })?; @@ -264,8 +264,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let text = resp.text().await.map_err(|e| { AppError::localized( "usage_script.read_response_failed", - format!("读取响应失败: {}", e), - format!("Failed to read response: {}", e), + format!("读取响应失败: {e}"), + format!("Failed to read response: {e}"), ) })?; @@ -277,8 +277,8 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< }; return Err(AppError::localized( "usage_script.http_error", - format!("HTTP {} : {}", status, preview), - format!("HTTP {} : {}", status, preview), + format!("HTTP {status} : {preview}"), + format!("HTTP {status} : {preview}"), )); } @@ -300,8 +300,8 @@ fn validate_result(result: &Value) -> Result<(), AppError> { validate_single_usage(item).map_err(|e| { AppError::localized( "usage_script.array_validation_failed", - format!("数组索引[{}]验证失败: {}", idx, e), - format!("Validation failed at index [{}]: {}", idx, e), + format!("数组索引[{idx}]验证失败: {e}"), + format!("Validation failed at index [{idx}]: {e}"), ) })?; } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index fcd4a82..4074170 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -4,7 +4,7 @@ use tauri::async_runtime; use cc_switch_lib::{ get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService, - MultiAppConfig, Provider, + MultiAppConfig, Provider, ProviderMeta, }; #[path = "support.rs"] @@ -63,9 +63,7 @@ fn sync_claude_provider_writes_live_settings() { // 额外确认写入位置位于测试 HOME 下 assert!( settings_path.starts_with(home), - "settings path {:?} should reside under test HOME {:?}", - settings_path, - home + "settings path {settings_path:?} should reside under test HOME {home:?}" ); } @@ -909,6 +907,121 @@ fn import_config_from_path_missing_file_produces_io_error() { } } +#[test] +fn sync_gemini_packycode_sets_security_selected_type() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "packy-1".to_string(); + manager.providers.insert( + "packy-1".to_string(), + Provider::with_id( + "packy-1".to_string(), + "PackyCode".to_string(), + json!({ + "env": { + "GEMINI_API_KEY": "pk-key", + "GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com" + } + }), + Some("https://www.packyapi.com".to_string()), + ), + ); + } + + ConfigService::sync_current_providers_to_live(&mut config) + .expect("syncing gemini live should succeed"); + + let settings_path = home.join(".cc-switch").join("settings.json"); + assert!( + settings_path.exists(), + "settings.json should exist at {}", + settings_path.display() + ); + + let raw = std::fs::read_to_string(&settings_path).expect("read settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json"); + assert_eq!( + value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("gemini-api-key"), + "syncing PackyCode Gemini should enforce security.auth.selectedType" + ); +} + +#[test] +fn sync_gemini_google_official_sets_oauth_security() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "google-official".to_string(); + let mut provider = Provider::with_id( + "google-official".to_string(), + "Google".to_string(), + json!({ + "env": {} + }), + Some("https://ai.google.dev".to_string()), + ); + provider.meta = Some(ProviderMeta { + partner_promotion_key: Some("google-official".to_string()), + ..ProviderMeta::default() + }); + manager + .providers + .insert("google-official".to_string(), provider); + } + + ConfigService::sync_current_providers_to_live(&mut config) + .expect("syncing google official gemini should succeed"); + + let cc_settings = home.join(".cc-switch").join("settings.json"); + assert!( + cc_settings.exists(), + "app settings should exist at {}", + cc_settings.display() + ); + let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings"); + let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings"); + assert_eq!( + cc_value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("oauth-personal"), + "syncing Google official should set oauth-personal in app settings" + ); + + let gemini_settings = home.join(".gemini").join("settings.json"); + assert!( + gemini_settings.exists(), + "Gemini settings should exist at {}", + gemini_settings.display() + ); + let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings"); + let gemini_value: serde_json::Value = + serde_json::from_str(&gemini_raw).expect("parse gemini settings json"); + assert_eq!( + gemini_value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("oauth-personal"), + "Gemini settings should also record oauth-personal" + ); +} + #[test] fn export_config_to_file_writes_target_path() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 4a6eff1..5bc349e 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -3,7 +3,7 @@ use std::sync::RwLock; use cc_switch_lib::{ get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType, - MultiAppConfig, Provider, ProviderService, + MultiAppConfig, Provider, ProviderMeta, ProviderService, }; #[path = "support.rs"] @@ -139,6 +139,190 @@ command = "say" ); } +#[test] +fn switch_packycode_gemini_updates_security_selected_type() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "packy-gemini".to_string(); + manager.providers.insert( + "packy-gemini".to_string(), + Provider::with_id( + "packy-gemini".to_string(), + "PackyCode".to_string(), + json!({ + "env": { + "GEMINI_API_KEY": "pk-key", + "GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com" + } + }), + Some("https://www.packyapi.com".to_string()), + ), + ); + } + + let state = AppState { + config: RwLock::new(config), + }; + + ProviderService::switch(&state, AppType::Gemini, "packy-gemini") + .expect("switching to PackyCode Gemini should succeed"); + + let settings_path = home.join(".cc-switch").join("settings.json"); + assert!( + settings_path.exists(), + "settings.json should exist at {}", + settings_path.display() + ); + let raw = std::fs::read_to_string(&settings_path).expect("read settings.json"); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("parse settings.json after switch"); + + assert_eq!( + value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("gemini-api-key"), + "PackyCode Gemini should set security.auth.selectedType" + ); +} + +#[test] +fn packycode_partner_meta_triggers_security_flag_even_without_keywords() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "packy-meta".to_string(); + let mut provider = Provider::with_id( + "packy-meta".to_string(), + "Generic Gemini".to_string(), + json!({ + "env": { + "GEMINI_API_KEY": "pk-meta", + "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com" + } + }), + Some("https://example.com".to_string()), + ); + provider.meta = Some(ProviderMeta { + partner_promotion_key: Some("packycode".to_string()), + ..ProviderMeta::default() + }); + manager + .providers + .insert("packy-meta".to_string(), provider); + } + + let state = AppState { + config: RwLock::new(config), + }; + + ProviderService::switch(&state, AppType::Gemini, "packy-meta") + .expect("switching to partner meta provider should succeed"); + + let settings_path = home.join(".cc-switch").join("settings.json"); + assert!( + settings_path.exists(), + "settings.json should exist at {}", + settings_path.display() + ); + let raw = std::fs::read_to_string(&settings_path).expect("read settings.json"); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("parse settings.json after switch"); + + assert_eq!( + value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("gemini-api-key"), + "Partner meta should set security.auth.selectedType even without packy keywords" + ); +} + +#[test] +fn switch_google_official_gemini_sets_oauth_security() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "google-official".to_string(); + let mut provider = Provider::with_id( + "google-official".to_string(), + "Google".to_string(), + json!({ + "env": {} + }), + Some("https://ai.google.dev".to_string()), + ); + provider.meta = Some(ProviderMeta { + partner_promotion_key: Some("google-official".to_string()), + ..ProviderMeta::default() + }); + manager + .providers + .insert("google-official".to_string(), provider); + } + + let state = AppState { + config: RwLock::new(config), + }; + + ProviderService::switch(&state, AppType::Gemini, "google-official") + .expect("switching to Google official Gemini should succeed"); + + let settings_path = home.join(".cc-switch").join("settings.json"); + assert!( + settings_path.exists(), + "settings.json should exist at {}", + settings_path.display() + ); + + let raw = std::fs::read_to_string(&settings_path).expect("read settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json"); + assert_eq!( + value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("oauth-personal"), + "Google official Gemini should set oauth-personal selectedType in app settings" + ); + + let gemini_settings = home.join(".gemini").join("settings.json"); + assert!( + gemini_settings.exists(), + "Gemini settings.json should exist at {}", + gemini_settings.display() + ); + let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings"); + let gemini_value: serde_json::Value = + serde_json::from_str(&gemini_raw).expect("parse gemini settings"); + + assert_eq!( + gemini_value + .pointer("/security/auth/selectedType") + .and_then(|v| v.as_str()), + Some("oauth-personal"), + "Gemini settings json should also reflect oauth-personal" + ); +} + #[test] fn provider_service_switch_claude_updates_live_and_state() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -321,8 +505,8 @@ fn provider_service_delete_codex_removes_provider_and_files() { let sanitized = sanitize_provider_name("DeleteCodex"); let codex_dir = home.join(".codex"); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); - let auth_path = codex_dir.join(format!("auth-{}.json", sanitized)); - let cfg_path = codex_dir.join(format!("config-{}.toml", sanitized)); + let auth_path = codex_dir.join(format!("auth-{sanitized}.json")); + let cfg_path = codex_dir.join(format!("config-{sanitized}.toml")); std::fs::write(&auth_path, "{}").expect("seed auth file"); std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file"); @@ -384,7 +568,7 @@ fn provider_service_delete_claude_removes_provider_files() { let sanitized = sanitize_provider_name("DeleteClaude"); let claude_dir = home.join(".claude"); std::fs::create_dir_all(&claude_dir).expect("create claude dir"); - let by_name = claude_dir.join(format!("settings-{}.json", sanitized)); + let by_name = claude_dir.join(format!("settings-{sanitized}.json")); let by_id = claude_dir.join("settings-delete.json"); std::fs::write(&by_name, "{}").expect("seed settings by name"); std::fs::write(&by_id, "{}").expect("seed settings by id"); diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 21d947f..d8d2789 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -23,7 +23,7 @@ pub fn ensure_test_home() -> &'static Path { /// 清理测试目录中生成的配置文件与缓存。 pub fn reset_test_fs() { let home = ensure_test_home(); - for sub in [".claude", ".codex", ".cc-switch"] { + for sub in [".claude", ".codex", ".cc-switch", ".gemini"] { let path = home.join(sub); if path.exists() { if let Err(err) = std::fs::remove_dir_all(&path) { diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 76a2ef6..f7ac954 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -1,5 +1,5 @@ import type { AppId } from "@/lib/api"; -import { ClaudeIcon, CodexIcon } from "./BrandIcons"; +import { ClaudeIcon, CodexIcon, GeminiIcon } from "./BrandIcons"; interface AppSwitcherProps { activeApp: AppId; @@ -46,6 +46,26 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) { Codex + + ); } diff --git a/src/components/BrandIcons.tsx b/src/components/BrandIcons.tsx index b0cb5d4..db20277 100644 --- a/src/components/BrandIcons.tsx +++ b/src/components/BrandIcons.tsx @@ -32,3 +32,18 @@ export function CodexIcon({ size = 16, className = "" }: IconProps) { ); } + +export function GeminiIcon({ size = 16, className = "" }: IconProps) { + return ( + + + + ); +} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c22a7ad..82b6235 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -267,7 +267,8 @@ const UsageScriptModal: React.FC = ({ // 判断是否应该显示凭证配置区域 const shouldShowCredentialsConfig = - selectedTemplate === TEMPLATE_KEYS.GENERAL || selectedTemplate === TEMPLATE_KEYS.NEW_API; + selectedTemplate === TEMPLATE_KEYS.GENERAL || + selectedTemplate === TEMPLATE_KEYS.NEW_API; return ( !open && onClose()}> @@ -334,9 +335,7 @@ const UsageScriptModal: React.FC = ({ {selectedTemplate === TEMPLATE_KEYS.GENERAL && ( <>
- +
= ({ type="button" onClick={() => setShowApiKey(!showApiKey)} className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" - aria-label={showApiKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")} + aria-label={ + showApiKey + ? t("apiKeyInput.hide") + : t("apiKeyInput.show") + } > - {showApiKey ? : } + {showApiKey ? ( + + ) : ( + + )} )}
- + = ({ {selectedTemplate === TEMPLATE_KEYS.NEW_API && ( <>
- + = ({ type={showAccessToken ? "text" : "password"} value={script.accessToken || ""} onChange={(e) => - setScript({ ...script, accessToken: e.target.value }) + setScript({ + ...script, + accessToken: e.target.value, + }) } - placeholder={t("usageScript.accessTokenPlaceholder")} + placeholder={t( + "usageScript.accessTokenPlaceholder", + )} autoComplete="off" /> {script.accessToken && ( )}
@@ -448,9 +466,7 @@ const UsageScriptModal: React.FC = ({ {/* 脚本编辑器 */}
- + setScript({ ...script, code })} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 6f71598..53aa0e2 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -18,6 +18,7 @@ import { } from "@/components/providers/forms/ProviderForm"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; +import { geminiProviderPresets } from "@/config/geminiProviderPresets"; interface AddProviderDialogProps { open: boolean; @@ -96,6 +97,21 @@ export function AddProviderDialog({ preset.endpointCandidates.forEach(addUrl); } } + } else if (appId === "gemini") { + const presets = geminiProviderPresets; + const presetIndex = parseInt( + values.presetId.replace("gemini-", ""), + ); + if ( + !isNaN(presetIndex) && + presetIndex >= 0 && + presetIndex < presets.length + ) { + const preset = presets[presetIndex]; + if (Array.isArray(preset.endpointCandidates)) { + preset.endpointCandidates.forEach(addUrl); + } + } } } @@ -114,6 +130,11 @@ export function AddProviderDialog({ addUrl(baseUrlMatch[1]); } } + } else if (appId === "gemini") { + const env = parsedConfig.env as Record | undefined; + if (env?.GOOGLE_GEMINI_BASE_URL) { + addUrl(env.GOOGLE_GEMINI_BASE_URL); + } } const urls = Array.from(urlSet); @@ -144,7 +165,9 @@ export function AddProviderDialog({ const submitLabel = appId === "claude" ? t("provider.addClaudeProvider") - : t("provider.addCodexProvider"); + : appId === "codex" + ? t("provider.addCodexProvider") + : t("provider.addGeminiProvider"); return ( diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 2dca35c..fe5052d 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -40,7 +40,9 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => { const config = provider.settingsConfig; if (config && typeof config === "object") { - const envBase = (config as Record)?.env?.ANTHROPIC_BASE_URL; + const envBase = + (config as Record)?.env?.ANTHROPIC_BASE_URL || + (config as Record)?.env?.GOOGLE_GEMINI_BASE_URL; if (typeof envBase === "string" && envBase.trim()) { return envBase; } @@ -147,6 +149,17 @@ export function ProviderCard({

{provider.name}

+ {provider.category === "third_party" && + provider.meta?.isPartner && ( + + ⭐ + + )} {t("common.format", { defaultValue: "格式化" })} -

- {t("claudeConfig.fullSettingsHint", { - defaultValue: "请填写完整的 Claude Code 配置", - })} -

diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index eefca68..1ceb46e 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -18,6 +18,7 @@ import type { CustomEndpoint, EndpointCandidate } from "@/types"; const ENDPOINT_TIMEOUT_SECS = { codex: 12, claude: 8, + gemini: 8, // 新增 gemini } as const; interface TestResult { diff --git a/src/components/providers/forms/GeminiConfigEditor.tsx b/src/components/providers/forms/GeminiConfigEditor.tsx new file mode 100644 index 0000000..b9623b9 --- /dev/null +++ b/src/components/providers/forms/GeminiConfigEditor.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from "react-i18next"; +import { Label } from "@/components/ui/label"; +import { Wand2 } from "lucide-react"; +import { toast } from "sonner"; + +interface GeminiConfigEditorProps { + value: string; + onChange: (value: string) => void; +} + +export function GeminiConfigEditor({ + value, + onChange, +}: GeminiConfigEditorProps) { + const { t } = useTranslation(); + + // 将 JSON 格式转换为 .env 格式显示 + const jsonToEnv = (jsonString: string): string => { + try { + const config = JSON.parse(jsonString); + const env = config?.env || {}; + + const lines: string[] = []; + if (env.GOOGLE_GEMINI_BASE_URL) { + lines.push(`GOOGLE_GEMINI_BASE_URL=${env.GOOGLE_GEMINI_BASE_URL}`); + } + if (env.GEMINI_API_KEY) { + lines.push(`GEMINI_API_KEY=${env.GEMINI_API_KEY}`); + } + if (env.GEMINI_MODEL) { + lines.push(`GEMINI_MODEL=${env.GEMINI_MODEL}`); + } + + return lines.join("\n"); + } catch { + return ""; + } + }; + + // 将 .env 格式转换为 JSON 格式保存 + const envToJson = (envString: string): string => { + try { + const lines = envString.split("\n"); + const env: Record = {}; + + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + + const equalIndex = trimmed.indexOf("="); + if (equalIndex > 0) { + const key = trimmed.substring(0, equalIndex).trim(); + const value = trimmed.substring(equalIndex + 1).trim(); + env[key] = value; + } + }); + + return JSON.stringify({ env }, null, 2); + } catch { + return value; + } + }; + + const displayValue = jsonToEnv(value); + + const handleChange = (envString: string) => { + const jsonString = envToJson(envString); + onChange(jsonString); + }; + + const handleFormat = () => { + if (!value.trim()) return; + + try { + // 重新格式化 + const envString = jsonToEnv(value); + const formatted = envString + .split("\n") + .filter((l) => l.trim()) + .join("\n"); + const jsonString = envToJson(formatted); + onChange(jsonString); + toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error( + t("common.formatError", { + defaultValue: "格式化失败:{{error}}", + error: errorMessage, + }), + ); + } + }; + + return ( +
+
+ +
+