diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f30d0e..9896c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ For users upgrading from v2.x (Electron version): - The app will automatically migrate your existing provider configurations - Window position and size preferences have been reset to defaults +#### Backup on v1→v2 Migration (cc-switch internal config) +- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure. +- Backup location: `~/.cc-switch/config.v1.backup..json` +- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched. + ### 🛠️ Development - Added `pnpm typecheck` command for TypeScript validation - Added `pnpm format` and `pnpm format:check` for code formatting @@ -64,4 +69,4 @@ For users upgrading from v2.x (Electron version): ### Features - Basic provider management - Claude Code integration -- Configuration file handling \ No newline at end of file +- Configuration file handling diff --git a/README.md b/README.md index 01b319d..fe3fa7b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/) -一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。 +一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。 > **v3.0.0 重大更新**:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍! @@ -55,10 +55,19 @@ 1. 点击"添加供应商"添加你的 API 配置 2. 选择要使用的供应商,点击单选按钮切换 -3. 配置会自动保存到 Claude Code 的配置文件中 +3. 配置会自动保存到对应应用的配置文件中 4. 重启或者新打开终端以生效 5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录 +### Codex 说明 + +- 配置目录:`~/.codex/` + - 主配置文件:`auth.json`(必需)、`config.toml`(可为空) + - 供应商副本:`auth-.json`、`config-.toml` +- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY` +- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。 +- 导入默认:若 `~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商;`config.toml` 不存在时按空处理。 + ## 开发 ### 环境要求 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9315a36..68bb71d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -562,6 +562,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-opener", + "toml 0.8.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ffe61c2..dcf7bb0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri = { version = "2.8.2", features = [] } tauri-plugin-log = "2" tauri-plugin-opener = "2" dirs = "5.0" +toml = "0.8" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs new file mode 100644 index 0000000..e60bbc3 --- /dev/null +++ b/src-tauri/src/app_config.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; +use crate::provider::ProviderManager; + +/// 应用类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppType { + Claude, + Codex, +} + +impl AppType { + pub fn as_str(&self) -> &str { + match self { + AppType::Claude => "claude", + AppType::Codex => "codex", + } + } +} + +impl From<&str> for AppType { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "codex" => AppType::Codex, + _ => AppType::Claude, // 默认为 Claude + } + } +} + +/// 多应用配置结构(向后兼容) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiAppConfig { + #[serde(default = "default_version")] + pub version: u32, + #[serde(flatten)] + pub apps: HashMap, +} + +fn default_version() -> u32 { + 2 +} + +impl Default for MultiAppConfig { + fn default() -> Self { + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), ProviderManager::default()); + apps.insert("codex".to_string(), ProviderManager::default()); + + Self { version: 2, apps } + } +} + +impl MultiAppConfig { + /// 从文件加载配置(处理v1到v2的迁移) + pub fn load() -> Result { + let config_path = get_app_config_path(); + + if !config_path.exists() { + log::info!("配置文件不存在,创建新的多应用配置"); + return Ok(Self::default()); + } + + // 尝试读取文件 + let content = std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取配置文件失败: {}", e))?; + + // 检查是否是旧版本格式(v1) + if let Ok(v1_config) = serde_json::from_str::(&content) { + log::info!("检测到v1配置,自动迁移到v2"); + + // 迁移到新格式 + let mut apps = HashMap::new(); + apps.insert("claude".to_string(), v1_config); + apps.insert("codex".to_string(), ProviderManager::default()); + + let config = Self { version: 2, apps }; + + // 迁移前备份旧版(v1)配置文件 + let backup_dir = get_app_config_dir(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let backup_path = backup_dir.join(format!("config.v1.backup.{}.json", ts)); + + match copy_file(&config_path, &backup_path) { + Ok(()) => log::info!( + "已备份旧版配置文件: {} -> {}", + config_path.display(), + backup_path.display() + ), + Err(e) => log::warn!("备份旧版配置文件失败: {}", e), + } + + // 保存迁移后的配置 + config.save()?; + return Ok(config); + } + + // 尝试读取v2格式 + serde_json::from_str::(&content).map_err(|e| format!("解析配置文件失败: {}", e)) + } + + /// 保存配置到文件 + pub fn save(&self) -> Result<(), String> { + let config_path = get_app_config_path(); + write_json_file(&config_path, self) + } + + /// 获取指定应用的管理器 + pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> { + self.apps.get(app.as_str()) + } + + /// 获取指定应用的管理器(可变引用) + pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> { + self.apps.get_mut(app.as_str()) + } + + /// 确保应用存在 + pub fn ensure_app(&mut self, app: &AppType) { + if !self.apps.contains_key(app.as_str()) { + self.apps + .insert(app.as_str().to_string(), ProviderManager::default()); + } + } +} diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs new file mode 100644 index 0000000..f3cb151 --- /dev/null +++ b/src-tauri/src/codex_config.rs @@ -0,0 +1,172 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +use crate::config::{ + copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file, +}; + +/// 获取 Codex 配置目录路径 +pub fn get_codex_config_dir() -> PathBuf { + dirs::home_dir().expect("无法获取用户主目录").join(".codex") +} + +/// 获取 Codex auth.json 路径 +pub fn get_codex_auth_path() -> PathBuf { + get_codex_config_dir().join("auth.json") +} + +/// 获取 Codex config.toml 路径 +pub fn get_codex_config_path() -> PathBuf { + get_codex_config_dir().join("config.toml") +} + +/// 获取 Codex 供应商配置文件路径 +pub fn get_codex_provider_paths( + provider_id: &str, + provider_name: Option<&str>, +) -> (PathBuf, PathBuf) { + let base_name = provider_name + .map(|name| sanitize_provider_name(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)); + + (auth_path, config_path) +} + +/// 备份 Codex 当前配置 +pub fn backup_codex_config(provider_id: &str, provider_name: &str) -> Result<(), String> { + let auth_path = get_codex_auth_path(); + let config_path = get_codex_config_path(); + let (backup_auth_path, backup_config_path) = + get_codex_provider_paths(provider_id, Some(provider_name)); + + // 备份 auth.json + if auth_path.exists() { + copy_file(&auth_path, &backup_auth_path)?; + log::info!("已备份 Codex auth.json: {}", backup_auth_path.display()); + } + + // 备份 config.toml + if config_path.exists() { + copy_file(&config_path, &backup_config_path)?; + log::info!("已备份 Codex config.toml: {}", backup_config_path.display()); + } + + Ok(()) +} + +/// 保存 Codex 供应商配置副本 +pub fn save_codex_provider_config( + provider_id: &str, + provider_name: &str, + settings_config: &Value, +) -> Result<(), String> { + let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name)); + + // 保存 auth.json + if let Some(auth) = settings_config.get("auth") { + write_json_file(&auth_path, auth)?; + } + + // 保存 config.toml + if let Some(config) = settings_config.get("config") { + if let Some(config_str) = config.as_str() { + // 若非空则进行 TOML 语法校验 + if !config_str.trim().is_empty() { + toml::from_str::(config_str) + .map_err(|e| format!("config.toml 格式错误: {}", e))?; + } + fs::write(&config_path, config_str) + .map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?; + } + } + + Ok(()) +} + +/// 删除 Codex 供应商配置文件 +pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> { + let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name)); + + delete_file(&auth_path).ok(); + delete_file(&config_path).ok(); + + Ok(()) +} + +/// 从 Codex 供应商配置副本恢复到主配置 +pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> { + let (provider_auth_path, provider_config_path) = + get_codex_provider_paths(provider_id, Some(provider_name)); + let auth_path = get_codex_auth_path(); + let config_path = get_codex_config_path(); + + // 确保目录存在 + if let Some(parent) = auth_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建 Codex 目录失败: {}", e))?; + } + + // 复制 auth.json(必需) + if provider_auth_path.exists() { + copy_file(&provider_auth_path, &auth_path)?; + log::info!("已恢复 Codex auth.json"); + } else { + return Err(format!( + "供应商 auth.json 不存在: {}", + provider_auth_path.display() + )); + } + + // 复制 config.toml(可选,允许为空;不存在则创建空文件以保持一致性) + if provider_config_path.exists() { + copy_file(&provider_config_path, &config_path)?; + log::info!("已恢复 Codex config.toml"); + } else { + // 写入空文件 + fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?; + log::info!("供应商 config.toml 缺失,已创建空文件"); + } + + Ok(()) +} + +/// 导入当前 Codex 配置为默认供应商 +pub fn import_current_codex_config() -> Result { + let auth_path = get_codex_auth_path(); + let config_path = get_codex_config_path(); + + // 行为放宽:仅要求 auth.json 存在;config.toml 可缺失 + if !auth_path.exists() { + return Err("Codex 配置文件不存在".to_string()); + } + + // 读取 auth.json + let auth = read_json_file::(&auth_path)?; + + // 读取 config.toml(允许不存在或读取失败时为空) + let config_str = if config_path.exists() { + let s = fs::read_to_string(&config_path) + .map_err(|e| format!("读取 config.toml 失败: {}", e))?; + if !s.trim().is_empty() { + toml::from_str::(&s) + .map_err(|e| format!("config.toml 语法错误: {}", e))?; + } + s + } else { + String::new() + }; + + // 组合成完整配置 + let settings_config = serde_json::json!({ + "auth": auth, + "config": config_str + }); + + // 保存为默认供应商副本 + save_codex_provider_config("default", "default", &settings_config)?; + + Ok(settings_config) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4a7b88d..67c588b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,7 +1,11 @@ +#![allow(non_snake_case)] + use std::collections::HashMap; use tauri::State; use tauri_plugin_opener::OpenerExt; +use crate::app_config::AppType; +use crate::codex_config; use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default}; use crate::provider::Provider; use crate::store::AppState; @@ -10,38 +14,97 @@ use crate::store::AppState; #[tauri::command] pub async fn get_providers( state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, ) -> Result, String> { - let manager = state - .provider_manager + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + Ok(manager.get_all_providers().clone()) } /// 获取当前供应商ID #[tauri::command] -pub async fn get_current_provider(state: State<'_, AppState>) -> Result { - let manager = state - .provider_manager +pub async fn get_current_provider( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + Ok(manager.current.clone()) } /// 添加供应商 #[tauri::command] -pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Result { - let mut manager = state - .provider_manager +pub async fn add_provider( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + provider: Provider, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - manager.add_provider(provider)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // 根据应用类型保存配置文件 + match app_type { + AppType::Codex => { + // Codex: 保存两个文件 + codex_config::save_codex_provider_config( + &provider.id, + &provider.name, + &provider.settings_config, + )?; + } + AppType::Claude => { + // Claude: 使用原有逻辑 + use crate::config::{get_provider_config_path, write_json_file}; + let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); + write_json_file(&config_path, &provider.settings_config)?; + } + } + + manager.providers.insert(provider.id.clone(), provider); // 保存配置 - drop(manager); // 释放锁 + drop(config); // 释放锁 state.save()?; Ok(true) @@ -51,17 +114,69 @@ pub async fn add_provider(state: State<'_, AppState>, provider: Provider) -> Res #[tauri::command] pub async fn update_provider( state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, provider: Provider, ) -> Result { - let mut manager = state - .provider_manager + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - manager.update_provider(provider)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // 检查供应商是否存在 + if !manager.providers.contains_key(&provider.id) { + return Err(format!("供应商不存在: {}", provider.id)); + } + + // 如果名称改变了,需要处理配置文件 + if let Some(old_provider) = manager.providers.get(&provider.id) { + if old_provider.name != provider.name { + // 删除旧配置文件 + match app_type { + AppType::Codex => { + codex_config::delete_codex_provider_config(&provider.id, &old_provider.name) + .ok(); + } + AppType::Claude => { + use crate::config::{delete_file, get_provider_config_path}; + let old_config_path = + get_provider_config_path(&provider.id, Some(&old_provider.name)); + delete_file(&old_config_path).ok(); + } + } + } + } + + // 保存新配置文件 + match app_type { + AppType::Codex => { + codex_config::save_codex_provider_config( + &provider.id, + &provider.name, + &provider.settings_config, + )?; + } + AppType::Claude => { + use crate::config::{get_provider_config_path, write_json_file}; + let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); + write_json_file(&config_path, &provider.settings_config)?; + } + } + + manager.providers.insert(provider.id.clone(), provider); // 保存配置 - drop(manager); // 释放锁 + drop(config); // 释放锁 state.save()?; Ok(true) @@ -69,16 +184,56 @@ pub async fn update_provider( /// 删除供应商 #[tauri::command] -pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result { - let mut manager = state - .provider_manager +pub async fn delete_provider( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + id: String, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - manager.delete_provider(&id)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // 检查是否为当前供应商 + if manager.current == id { + return Err("不能删除当前正在使用的供应商".to_string()); + } + + // 获取供应商信息 + let provider = manager + .providers + .get(&id) + .ok_or_else(|| format!("供应商不存在: {}", id))? + .clone(); + + // 删除配置文件 + match app_type { + AppType::Codex => { + codex_config::delete_codex_provider_config(&id, &provider.name)?; + } + AppType::Claude => { + use crate::config::{delete_file, get_provider_config_path}; + let config_path = get_provider_config_path(&id, Some(&provider.name)); + delete_file(&config_path)?; + } + } + + // 从管理器删除 + manager.providers.remove(&id); // 保存配置 - drop(manager); // 释放锁 + drop(config); // 释放锁 state.save()?; Ok(true) @@ -86,16 +241,92 @@ pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result, id: String) -> Result { - let mut manager = state - .provider_manager +pub async fn switch_provider( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + id: String, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - manager.switch_provider(&id)?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // 检查供应商是否存在 + let provider = manager + .providers + .get(&id) + .ok_or_else(|| format!("供应商不存在: {}", id))? + .clone(); + + // 根据应用类型执行切换 + match app_type { + AppType::Codex => { + // 备份当前配置(如果存在) + if !manager.current.is_empty() { + if let Some(current_provider) = manager.providers.get(&manager.current) { + codex_config::backup_codex_config(&manager.current, ¤t_provider.name)?; + log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name); + } + } + + // 恢复目标供应商配置 + codex_config::restore_codex_provider_config(&id, &provider.name)?; + } + AppType::Claude => { + // 使用原有的 Claude 切换逻辑 + use crate::config::{ + backup_config, copy_file, get_claude_settings_path, get_provider_config_path, + }; + + let settings_path = get_claude_settings_path(); + let provider_config_path = get_provider_config_path(&id, Some(&provider.name)); + + // 检查供应商配置文件是否存在 + if !provider_config_path.exists() { + return Err(format!( + "供应商配置文件不存在: {}", + provider_config_path.display() + )); + } + + // 如果当前有配置,先备份到当前供应商 + if settings_path.exists() && !manager.current.is_empty() { + if let Some(current_provider) = manager.providers.get(&manager.current) { + let current_provider_path = + get_provider_config_path(&manager.current, Some(¤t_provider.name)); + backup_config(&settings_path, ¤t_provider_path)?; + log::info!("已备份当前供应商配置: {}", current_provider.name); + } + } + + // 确保主配置父目录存在 + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + + // 复制新供应商配置到主配置 + copy_file(&provider_config_path, &settings_path)?; + } + } + + // 更新当前供应商 + manager.current = id; + + log::info!("成功切换到供应商: {}", provider.name); // 保存配置 - drop(manager); // 释放锁 + drop(config); // 释放锁 state.save()?; Ok(true) @@ -103,20 +334,36 @@ pub async fn switch_provider(state: State<'_, AppState>, id: String) -> Result) -> Result { +pub async fn import_default_config( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + // 若已存在 default 供应商,则直接返回,避免重复导入 { - let manager = state - .provider_manager + let config = state + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - if manager.get_all_providers().contains_key("default") { - return Ok(true); + + if let Some(manager) = config.get_manager(&app_type) { + if manager.get_all_providers().contains_key("default") { + return Ok(true); + } } } - // 导入配置 - let settings_config = import_current_config_as_default()?; + // 根据应用类型导入配置 + let settings_config = match app_type { + AppType::Codex => codex_config::import_current_codex_config()?, + AppType::Claude => import_current_config_as_default()?, + }; // 创建默认供应商 let provider = Provider::with_id( @@ -127,12 +374,32 @@ pub async fn import_default_config(state: State<'_, AppState>) -> Result { + codex_config::save_codex_provider_config( + &provider.id, + &provider.name, + &provider.settings_config, + )?; + } + AppType::Claude => { + use crate::config::{get_provider_config_path, write_json_file}; + let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); + write_json_file(&config_path, &provider.settings_config)?; + } + } + + manager.providers.insert(provider.id.clone(), provider); // 如果没有当前供应商,设置为 default if manager.current.is_empty() { @@ -140,7 +407,7 @@ pub async fn import_default_config(state: State<'_, AppState>) -> Result Result { Ok(crate::config::get_claude_config_status()) } +/// 获取应用配置状态(通用) +/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串) +#[tauri::command] +pub async fn get_config_status( + app_type: Option, + app: Option, + appType: Option, +) -> Result { + let app = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + match app { + AppType::Claude => Ok(crate::config::get_claude_config_status()), + AppType::Codex => { + use crate::codex_config::{get_codex_auth_path, get_codex_config_dir}; + let auth_path = get_codex_auth_path(); + + // 放宽:只要 auth.json 存在即可认为已配置;config.toml 允许为空 + let exists = auth_path.exists(); + let path = get_codex_config_dir().to_string_lossy().to_string(); + + Ok(ConfigStatus { exists, path }) + } + } +} + /// 获取 Claude Code 配置文件路径 #[tauri::command] pub async fn get_claude_code_config_path() -> Result { @@ -159,9 +454,23 @@ pub async fn get_claude_code_config_path() -> Result { } /// 打开配置文件夹 +/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串) #[tauri::command] -pub async fn open_config_folder(app: tauri::AppHandle) -> Result { - let config_dir = crate::config::get_claude_config_dir(); +pub async fn open_config_folder( + handle: tauri::AppHandle, + app_type: Option, + app: Option, + appType: Option, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let config_dir = match app_type { + AppType::Claude => crate::config::get_claude_config_dir(), + AppType::Codex => crate::codex_config::get_codex_config_dir(), + }; // 确保目录存在 if !config_dir.exists() { @@ -169,7 +478,7 @@ pub async fn open_config_folder(app: tauri::AppHandle) -> Result { } // 使用 opener 插件打开文件夹 - app.opener() + handle.opener() .open_path(config_dir.to_string_lossy().to_string(), None::) .map_err(|e| format!("打开文件夹失败: {}", e))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b4402ab..82b7886 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod app_config; +mod codex_config; mod commands; mod config; mod provider; @@ -55,34 +57,51 @@ pub fn run() { // 如果没有供应商且存在 Claude Code 配置,自动导入 { - let manager = app_state.provider_manager.lock().unwrap(); - if manager.providers.is_empty() { - drop(manager); // 释放锁 + let mut config = app_state.config.lock().unwrap(); + // 检查 Claude 供应商 + let need_import = if let Some(claude_manager) = + config.get_manager(&app_config::AppType::Claude) + { + claude_manager.providers.is_empty() + } else { + // 确保 Claude 应用存在 + config.ensure_app(&app_config::AppType::Claude); + true + }; + + if need_import { let settings_path = config::get_claude_settings_path(); if settings_path.exists() { log::info!("检测到 Claude Code 配置,自动导入为默认供应商"); if let Ok(settings_config) = config::import_current_config_as_default() { - let mut manager = app_state.provider_manager.lock().unwrap(); - let provider = provider::Provider::with_id( - "default".to_string(), - "default".to_string(), - settings_config, - None, - ); + if let Some(manager) = + config.get_manager_mut(&app_config::AppType::Claude) + { + let provider = provider::Provider::with_id( + "default".to_string(), + "default".to_string(), + settings_config, + None, + ); - if manager.add_provider(provider).is_ok() { - manager.current = "default".to_string(); - drop(manager); - let _ = app_state.save(); - log::info!("成功导入默认供应商"); + if manager.add_provider(provider).is_ok() { + manager.current = "default".to_string(); + log::info!("成功导入默认供应商"); + } } } } } + + // 确保 Codex 应用存在 + config.ensure_app(&app_config::AppType::Codex); } + // 保存配置 + let _ = app_state.save(); + // 将同一个实例注入到全局状态,避免重复创建导致的不一致 app.manage(app_state); Ok(()) @@ -96,6 +115,7 @@ pub fn run() { commands::switch_provider, commands::import_default_config, commands::get_claude_config_status, + commands::get_config_status, commands::get_claude_code_config_path, commands::open_config_folder, commands::open_external, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 0287b37..a972339 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -1,12 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use std::path::Path; -use crate::config::{ - backup_config, copy_file, delete_file, get_claude_settings_path, get_provider_config_path, - read_json_file, write_json_file, -}; +use crate::config::{get_provider_config_path, write_json_file}; /// 供应商结构体 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -54,21 +50,6 @@ impl Default for ProviderManager { } impl ProviderManager { - /// 加载供应商列表 - pub fn load_from_file(path: &Path) -> Result { - if !path.exists() { - log::info!("配置文件不存在,创建新的供应商管理器"); - return Ok(Self::default()); - } - - read_json_file(path) - } - - /// 保存供应商列表 - pub fn save_to_file(&self, path: &Path) -> Result<(), String> { - write_json_file(path, self) - } - /// 添加供应商 pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> { // 保存供应商配置到独立文件 @@ -80,98 +61,6 @@ impl ProviderManager { Ok(()) } - /// 更新供应商 - pub fn update_provider(&mut self, provider: Provider) -> Result<(), String> { - // 检查供应商是否存在 - if !self.providers.contains_key(&provider.id) { - return Err(format!("供应商不存在: {}", provider.id)); - } - - // 如果名称改变了,需要处理配置文件 - if let Some(old_provider) = self.providers.get(&provider.id) { - if old_provider.name != provider.name { - // 删除旧配置文件 - let old_config_path = - get_provider_config_path(&provider.id, Some(&old_provider.name)); - delete_file(&old_config_path).ok(); // 忽略删除错误 - } - } - - // 保存新配置文件 - let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); - write_json_file(&config_path, &provider.settings_config)?; - - // 更新管理器 - self.providers.insert(provider.id.clone(), provider); - Ok(()) - } - - /// 删除供应商 - pub fn delete_provider(&mut self, provider_id: &str) -> Result<(), String> { - // 检查是否为当前供应商 - if self.current == provider_id { - return Err("不能删除当前正在使用的供应商".to_string()); - } - - // 获取供应商信息 - let provider = self - .providers - .get(provider_id) - .ok_or_else(|| format!("供应商不存在: {}", provider_id))?; - - // 删除配置文件 - let config_path = get_provider_config_path(provider_id, Some(&provider.name)); - delete_file(&config_path)?; - - // 从管理器删除 - self.providers.remove(provider_id); - Ok(()) - } - - /// 切换供应商 - pub fn switch_provider(&mut self, provider_id: &str) -> Result<(), String> { - // 检查供应商是否存在 - let provider = self - .providers - .get(provider_id) - .ok_or_else(|| format!("供应商不存在: {}", provider_id))?; - - let settings_path = get_claude_settings_path(); - let provider_config_path = get_provider_config_path(provider_id, Some(&provider.name)); - - // 检查供应商配置文件是否存在 - if !provider_config_path.exists() { - return Err(format!( - "供应商配置文件不存在: {}", - provider_config_path.display() - )); - } - - // 如果当前有配置,先备份到当前供应商 - if settings_path.exists() && !self.current.is_empty() { - if let Some(current_provider) = self.providers.get(&self.current) { - let current_provider_path = - get_provider_config_path(&self.current, Some(¤t_provider.name)); - backup_config(&settings_path, ¤t_provider_path)?; - log::info!("已备份当前供应商配置: {}", current_provider.name); - } - } - - // 确保主配置父目录存在 - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; - } - - // 复制新供应商配置到主配置 - copy_file(&provider_config_path, &settings_path)?; - - // 更新当前供应商 - self.current = provider_id.to_string(); - - log::info!("成功切换到供应商: {}", provider.name); - Ok(()) - } - /// 获取所有供应商 pub fn get_all_providers(&self) -> &HashMap { &self.providers diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 77382b8..194c33b 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,36 +1,31 @@ -use crate::config::get_app_config_path; -use crate::provider::ProviderManager; +use crate::app_config::MultiAppConfig; use std::sync::Mutex; /// 全局应用状态 pub struct AppState { - pub provider_manager: Mutex, + pub config: Mutex, } impl AppState { /// 创建新的应用状态 pub fn new() -> Self { - let config_path = get_app_config_path(); - let provider_manager = ProviderManager::load_from_file(&config_path).unwrap_or_else(|e| { + let config = MultiAppConfig::load().unwrap_or_else(|e| { log::warn!("加载配置失败: {}, 使用默认配置", e); - ProviderManager::default() + MultiAppConfig::default() }); Self { - provider_manager: Mutex::new(provider_manager), + config: Mutex::new(config), } } /// 保存配置到文件 pub fn save(&self) -> Result<(), String> { - let config_path = get_app_config_path(); - let manager = self - .provider_manager + let config = self + .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - manager.save_to_file(&config_path) + config.save() } - - // 保留按需扩展:若未来需要热加载,可在此实现 } diff --git a/src/App.css b/src/App.css index 8c79ef0..bb29173 100644 --- a/src/App.css +++ b/src/App.css @@ -5,25 +5,94 @@ } .app-header { - background: #3498db; + background: linear-gradient(180deg, #3498db 0%, #2d89c7 100%); color: white; - padding: 0.75rem 2rem; - display: flex; - justify-content: space-between; + padding: 0.35rem 2rem 0.45rem; + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto auto; + grid-template-areas: + ". title ." + "tabs . actions"; align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + row-gap: 0.6rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); user-select: none; - min-height: 3rem; +} + +.app-tabs { + grid-area: tabs; +} + +/* Segmented control */ +.segmented { + --seg-bg: rgba(255, 255, 255, 0.16); + --seg-thumb: #ffffff; + --seg-color: rgba(255, 255, 255, 0.85); + --seg-active: #2d89c7; + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + width: 280px; + background: var(--seg-bg); + border-radius: 999px; + padding: 4px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); + backdrop-filter: saturate(140%) blur(2px); +} + +.segmented-thumb { + position: absolute; + top: 4px; + left: 4px; + width: calc(50% - 4px); + height: calc(100% - 8px); + background: var(--seg-thumb); + border-radius: 999px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + transition: + transform 220ms ease, + width 220ms ease; + will-change: transform; +} + +.segmented-item { + position: relative; + z-index: 1; + background: transparent; + border: none; + border-radius: 999px; + padding: 6px 16px; /* 更紧凑的高度 */ + color: var(--seg-color); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.2px; + cursor: pointer; + transition: color 200ms ease; +} + +.segmented-item.active { + color: var(--seg-active); +} + +.segmented-item:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.8); + outline-offset: 2px; } .app-header h1 { font-size: 1.5rem; font-weight: 500; + margin: 0; + grid-area: title; + text-align: center; } .header-actions { display: flex; gap: 1rem; + grid-area: actions; + justify-self: end; } .refresh-btn, diff --git a/src/App.tsx b/src/App.tsx index 3f8975c..53f406c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,15 @@ import { useState, useEffect, useRef } from "react"; import { Provider } from "./types"; +import { AppType } from "./lib/tauri-api"; import ProviderList from "./components/ProviderList"; import AddProviderModal from "./components/AddProviderModal"; import EditProviderModal from "./components/EditProviderModal"; import { ConfirmDialog } from "./components/ConfirmDialog"; +import { AppSwitcher } from "./components/AppSwitcher"; import "./App.css"; function App() { + const [activeApp, setActiveApp] = useState("claude"); const [providers, setProviders] = useState>({}); const [currentProviderId, setCurrentProviderId] = useState(""); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -60,7 +63,7 @@ function App() { useEffect(() => { loadProviders(); loadConfigStatus(); - }, []); + }, [activeApp]); // 当切换应用时重新加载 // 清理定时器 useEffect(() => { @@ -72,8 +75,8 @@ function App() { }, []); const loadProviders = async () => { - const loadedProviders = await window.api.getProviders(); - const currentId = await window.api.getCurrentProvider(); + const loadedProviders = await window.api.getProviders(activeApp); + const currentId = await window.api.getCurrentProvider(activeApp); setProviders(loadedProviders); setCurrentProviderId(currentId); @@ -84,7 +87,7 @@ function App() { }; const loadConfigStatus = async () => { - const status = await window.api.getClaudeConfigStatus(); + const status = await window.api.getConfigStatus(activeApp); setConfigStatus({ exists: Boolean(status?.exists), path: String(status?.path || ""), @@ -101,14 +104,14 @@ function App() { ...provider, id: generateId(), }; - await window.api.addProvider(newProvider); + await window.api.addProvider(newProvider, activeApp); await loadProviders(); setIsAddModalOpen(false); }; const handleEditProvider = async (provider: Provider) => { try { - await window.api.updateProvider(provider); + await window.api.updateProvider(provider, activeApp); await loadProviders(); setEditingProviderId(null); // 显示编辑成功提示 @@ -127,7 +130,7 @@ function App() { title: "删除供应商", message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`, onConfirm: async () => { - await window.api.deleteProvider(id); + await window.api.deleteProvider(id, activeApp); await loadProviders(); setConfirmDialog(null); showNotification("供应商删除成功", "success"); @@ -136,12 +139,13 @@ function App() { }; const handleSwitchProvider = async (id: string) => { - const success = await window.api.switchProvider(id); + const success = await window.api.switchProvider(id, activeApp); if (success) { setCurrentProviderId(id); // 显示重启提示 + const appName = activeApp === "claude" ? "Claude Code" : "Codex"; showNotification( - "切换成功!请重启 Claude Code 终端以生效", + `切换成功!请重启 ${appName} 终端以生效`, "success", 2000, ); @@ -153,7 +157,7 @@ function App() { // 自动导入现有配置为"default"供应商 const handleAutoImportDefault = async () => { try { - const result = await window.api.importCurrentConfigAsDefault(); + const result = await window.api.importCurrentConfigAsDefault(activeApp); if (result.success) { await loadProviders(); @@ -171,13 +175,19 @@ function App() { }; const handleOpenConfigFolder = async () => { - await window.api.openConfigFolder(); + await window.api.openConfigFolder(activeApp); }; return (
-

Claude Code 供应商切换器

+

CC Switch

+
+ +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/EditProviderModal.tsx b/src/components/EditProviderModal.tsx index 9743e4e..5c3d4ef 100644 --- a/src/components/EditProviderModal.tsx +++ b/src/components/EditProviderModal.tsx @@ -1,14 +1,17 @@ import React from "react"; import { Provider } from "../types"; +import { AppType } from "../lib/tauri-api"; import ProviderForm from "./ProviderForm"; interface EditProviderModalProps { + appType: AppType; provider: Provider; onSave: (provider: Provider) => void; onClose: () => void; } const EditProviderModal: React.FC = ({ + appType, provider, onSave, onClose, @@ -22,6 +25,7 @@ const EditProviderModal: React.FC = ({ return ( = ({ + appType = "claude", title, submitText, initialData, @@ -28,6 +32,9 @@ const ProviderForm: React.FC = ({ onSubmit, onClose, }) => { + // 对于 Codex,需要分离 auth 和 config + const isCodex = appType === "codex"; + const [formData, setFormData] = useState({ name: initialData?.name || "", websiteUrl: initialData?.websiteUrl || "", @@ -35,6 +42,33 @@ const ProviderForm: React.FC = ({ ? JSON.stringify(initialData.settingsConfig, null, 2) : "", }); + + // Codex 特有的状态 + const [codexAuth, setCodexAuth] = useState(""); + const [codexConfig, setCodexConfig] = useState(""); + const [codexApiKey, setCodexApiKey] = useState(""); + const [selectedCodexPreset, setSelectedCodexPreset] = useState( + null, + ); + + // 初始化 Codex 配置 + useEffect(() => { + if (isCodex && initialData) { + const config = initialData.settingsConfig; + if (typeof config === "object" && config !== null) { + setCodexAuth(JSON.stringify(config.auth || {}, null, 2)); + setCodexConfig(config.config || ""); + try { + const auth = config.auth || {}; + if (auth && typeof auth.OPENAI_API_KEY === "string") { + setCodexApiKey(auth.OPENAI_API_KEY); + } + } catch { + // ignore + } + } + } + }, [isCodex, initialData]); const [error, setError] = useState(""); const [disableCoAuthored, setDisableCoAuthored] = useState(false); const [selectedPreset, setSelectedPreset] = useState(null); @@ -58,18 +92,55 @@ const ProviderForm: React.FC = ({ return; } - if (!formData.settingsConfig.trim()) { - setError("请填写配置内容"); - return; - } - let settingsConfig: Record; - try { - settingsConfig = JSON.parse(formData.settingsConfig); - } catch (err) { - setError("配置JSON格式错误,请检查语法"); - return; + if (isCodex) { + // Codex: 仅要求 auth.json 必填;config.toml 可为空 + if (!codexAuth.trim()) { + setError("请填写 auth.json 配置"); + return; + } + + try { + const authJson = JSON.parse(codexAuth); + + // 非官方预设强制要求 OPENAI_API_KEY + if (selectedCodexPreset !== null) { + const preset = codexProviderPresets[selectedCodexPreset]; + const isOfficial = Boolean(preset?.isOfficial); + if (!isOfficial) { + const key = + typeof authJson.OPENAI_API_KEY === "string" + ? authJson.OPENAI_API_KEY.trim() + : ""; + if (!key) { + setError("请填写 OPENAI_API_KEY"); + return; + } + } + } + + settingsConfig = { + auth: authJson, + config: codexConfig ?? "", + }; + } catch (err) { + setError("auth.json 格式错误,请检查JSON语法"); + return; + } + } else { + // Claude: 原有逻辑 + if (!formData.settingsConfig.trim()) { + setError("请填写配置内容"); + return; + } + + try { + settingsConfig = JSON.parse(formData.settingsConfig); + } catch (err) { + setError("配置JSON格式错误,请检查语法"); + return; + } } onSubmit({ @@ -145,6 +216,27 @@ const ProviderForm: React.FC = ({ setDisableCoAuthored(hasCoAuthoredDisabled); }; + // Codex: 应用预设 + const applyCodexPreset = ( + preset: (typeof codexProviderPresets)[0], + index: number, + ) => { + const authString = JSON.stringify(preset.auth || {}, null, 2); + setCodexAuth(authString); + setCodexConfig(preset.config || ""); + + setFormData({ + name: preset.name, + websiteUrl: preset.websiteUrl, + settingsConfig: formData.settingsConfig, + }); + + setSelectedCodexPreset(index); + + // 清空 API Key,让用户重新输入 + setCodexApiKey(""); + }; + // 处理 API Key 输入并自动更新配置 const handleApiKeyChange = (key: string) => { setApiKey(key); @@ -166,6 +258,18 @@ const ProviderForm: React.FC = ({ setDisableCoAuthored(hasCoAuthoredDisabled); }; + // Codex: 处理 API Key 输入并写回 auth.json + const handleCodexApiKeyChange = (key: string) => { + setCodexApiKey(key); + try { + const auth = JSON.parse(codexAuth || "{}"); + auth.OPENAI_API_KEY = key.trim(); + setCodexAuth(JSON.stringify(auth, null, 2)); + } catch { + // ignore + } + }; + // 根据当前配置决定是否展示 API Key 输入框 const showApiKey = selectedPreset !== null || hasApiKeyField(formData.settingsConfig); @@ -175,6 +279,21 @@ const ProviderForm: React.FC = ({ selectedPreset !== null && providerPresets[selectedPreset]?.isOfficial === true; + // Codex: 控制显示 API Key 与官方标记 + const getCodexAuthApiKey = (authString: string): string => { + try { + const auth = JSON.parse(authString || "{}"); + return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : ""; + } catch { + return ""; + } + }; + const showCodexApiKey = + selectedCodexPreset !== null || getCodexAuthApiKey(codexAuth) !== ""; + const isCodexOfficialPreset = + selectedCodexPreset !== null && + codexProviderPresets[selectedCodexPreset]?.isOfficial === true; + // 初始时从配置中同步 API Key(编辑模式) useEffect(() => { if (initialData) { @@ -226,7 +345,7 @@ const ProviderForm: React.FC = ({
{error &&
{error}
} - {showPresets && ( + {showPresets && !isCodex && (
@@ -248,6 +367,26 @@ const ProviderForm: React.FC = ({
)} + {showPresets && isCodex && ( +
+ +
+ {codexProviderPresets.map((preset, index) => ( + + ))} +
+
+ )} +
= ({ />
-
- - handleApiKeyChange(e.target.value)} - placeholder={ - isOfficialPreset - ? "官方登录无需填写 API Key,直接保存即可" - : "只需要填这里,下方配置会自动填充" - } - disabled={isOfficialPreset} - autoComplete="off" - style={ - isOfficialPreset - ? { - backgroundColor: "#f5f5f5", - cursor: "not-allowed", - color: "#999", - } - : {} - } - /> -
+ {!isCodex && ( +
+ + handleApiKeyChange(e.target.value)} + placeholder={ + isOfficialPreset + ? "官方登录无需填写 API Key,直接保存即可" + : "只需要填这里,下方配置会自动填充" + } + disabled={isOfficialPreset} + autoComplete="off" + style={ + isOfficialPreset + ? { + backgroundColor: "#f5f5f5", + cursor: "not-allowed", + color: "#999", + } + : {} + } + /> +
+ )} + + {isCodex && ( +
+ + handleCodexApiKeyChange(e.target.value)} + placeholder={ + isCodexOfficialPreset + ? "官方无需填写 API Key,直接保存即可" + : "只需要填这里,下方 auth.json 会自动填充" + } + disabled={isCodexOfficialPreset} + required={ + selectedCodexPreset !== null && !isCodexOfficialPreset + } + autoComplete="off" + style={ + isCodexOfficialPreset + ? { + backgroundColor: "#f5f5f5", + cursor: "not-allowed", + color: "#999", + } + : {} + } + /> +
+ )}
@@ -303,39 +477,90 @@ const ProviderForm: React.FC = ({ />
-
-
- -