* 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 <farion1231@gmail.com>
245 lines
7.4 KiB
Rust
245 lines
7.4 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::{OnceLock, RwLock};
|
|
|
|
use crate::error::AppError;
|
|
|
|
/// 自定义端点配置
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CustomEndpoint {
|
|
pub url: String,
|
|
pub added_at: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_used: Option<i64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SecuritySettings {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub auth: Option<SecurityAuthSettings>,
|
|
}
|
|
|
|
/// 应用设置结构,允许覆盖默认配置目录
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AppSettings {
|
|
#[serde(default = "default_show_in_tray")]
|
|
pub show_in_tray: bool,
|
|
#[serde(default = "default_minimize_to_tray_on_close")]
|
|
pub minimize_to_tray_on_close: bool,
|
|
/// 是否启用 Claude 插件联动
|
|
#[serde(default)]
|
|
pub enable_claude_plugin_integration: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub claude_config_dir: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub codex_config_dir: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub gemini_config_dir: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub language: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub security: Option<SecuritySettings>,
|
|
/// Claude 自定义端点列表
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
|
/// Codex 自定义端点列表
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
|
|
}
|
|
|
|
fn default_show_in_tray() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_minimize_to_tray_on_close() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Default for AppSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
show_in_tray: true,
|
|
minimize_to_tray_on_close: true,
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppSettings {
|
|
fn settings_path() -> PathBuf {
|
|
// settings.json 必须使用固定路径,不能被 app_config_dir 覆盖
|
|
// 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中
|
|
dirs::home_dir()
|
|
.expect("无法获取用户主目录")
|
|
.join(".cc-switch")
|
|
.join("settings.json")
|
|
}
|
|
|
|
fn normalize_paths(&mut self) {
|
|
self.claude_config_dir = self
|
|
.claude_config_dir
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string());
|
|
|
|
self.codex_config_dir = self
|
|
.codex_config_dir
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.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()
|
|
.map(|s| s.trim())
|
|
.filter(|s| matches!(*s, "en" | "zh"))
|
|
.map(|s| s.to_string());
|
|
}
|
|
|
|
pub fn load() -> Self {
|
|
let path = Self::settings_path();
|
|
if let Ok(content) = fs::read_to_string(&path) {
|
|
match serde_json::from_str::<AppSettings>(&content) {
|
|
Ok(mut settings) => {
|
|
settings.normalize_paths();
|
|
settings
|
|
}
|
|
Err(err) => {
|
|
log::warn!(
|
|
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
|
|
path.display(),
|
|
err
|
|
);
|
|
Self::default()
|
|
}
|
|
}
|
|
} else {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
pub fn save(&self) -> Result<(), AppError> {
|
|
let mut normalized = self.clone();
|
|
normalized.normalize_paths();
|
|
let path = Self::settings_path();
|
|
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
}
|
|
|
|
let json = serde_json::to_string_pretty(&normalized)
|
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
|
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn settings_store() -> &'static RwLock<AppSettings> {
|
|
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
|
|
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
|
|
}
|
|
|
|
fn resolve_override_path(raw: &str) -> PathBuf {
|
|
if raw == "~" {
|
|
if let Some(home) = dirs::home_dir() {
|
|
return home;
|
|
}
|
|
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
|
if let Some(home) = dirs::home_dir() {
|
|
return home.join(stripped);
|
|
}
|
|
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
|
if let Some(home) = dirs::home_dir() {
|
|
return home.join(stripped);
|
|
}
|
|
}
|
|
|
|
PathBuf::from(raw)
|
|
}
|
|
|
|
pub fn get_settings() -> AppSettings {
|
|
settings_store().read().expect("读取设置锁失败").clone()
|
|
}
|
|
|
|
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
|
new_settings.normalize_paths();
|
|
new_settings.save()?;
|
|
|
|
let mut guard = settings_store().write().expect("写入设置锁失败");
|
|
*guard = new_settings;
|
|
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<PathBuf> {
|
|
let settings = settings_store().read().ok()?;
|
|
settings
|
|
.claude_config_dir
|
|
.as_ref()
|
|
.map(|p| resolve_override_path(p))
|
|
}
|
|
|
|
pub fn get_codex_override_dir() -> Option<PathBuf> {
|
|
let settings = settings_store().read().ok()?;
|
|
settings
|
|
.codex_config_dir
|
|
.as_ref()
|
|
.map(|p| resolve_override_path(p))
|
|
}
|
|
|
|
pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
|
let settings = settings_store().read().ok()?;
|
|
settings
|
|
.gemini_config_dir
|
|
.as_ref()
|
|
.map(|p| resolve_override_path(p))
|
|
}
|