diff --git a/README.md b/README.md index fa43ab5..a737ed2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ - 供应商副本:`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` 不存在时按空处理。 +- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。 - 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。 ### Claude Code 说明 @@ -79,7 +79,7 @@ - 供应商副本:`settings-.json` - API Key 字段:`env.ANTHROPIC_AUTH_TOKEN` - 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。 -- 导入默认:若 `~/.claude/settings.json` 或 `~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本。 +- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。 - 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。 ### 迁移与备份 diff --git a/docs/encrypted-config-plan.md b/docs/encrypted-config-plan.md new file mode 100644 index 0000000..c3ff91b --- /dev/null +++ b/docs/encrypted-config-plan.md @@ -0,0 +1,193 @@ +# CC Switch 加密配置与切换重构方案(V1) + +## 1. 目标与范围 + +- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源(SSOT),改为“加密落盘”;切换时从解密后的内存配置写入目标应用主配置(Claude/Codex)。 +- 范围: + - 后端(Rust/Tauri)新增加密模块与读写改造。 + - 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。 + - 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。 + - 兼容旧明文配置(v1/v2),首次保存迁移为加密文件。 + +## 2. 背景现状(简述) + +- 当前: + - 全局配置:`~/.cc-switch/config.json`(v2:`MultiAppConfig`,含多个 `ProviderManager`)。 + - 切换:依赖“供应商副本文件”(Claude:`~/.claude/settings-.json`;Codex:`~/.codex/auth-.json`、`config-.toml`)→ 恢复到主配置。 + - 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。 +- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。 + +## 3. 总体方案 + +- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。 +- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。 +- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。 +- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`(v1/v2)与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive//`,生成 `manifest.json` 以便恢复;默认不做静默清理。 + +## 4. 密钥管理 + +- 存储:系统级凭据管家(keyring crate)。 + - Service:`cc-switch`;Account:`config-key-v1`;内容:Base64 编码的 32 字节随机密钥(AES-256)。 +- 首次运行:生成随机密钥,写入 Keychain。 +- 进程内缓存:启动加载后缓存密钥,避免重复 IO。 +- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。 +- 回退策略:Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。 + +## 5. 加密封装格式 + +- 文件:`~/.cc-switch/config.enc.json` +- 结构(JSON 封装,便于演进): + ```json + { + "v": 1, + "alg": "AES-256-GCM", + "nonce": "", + "ct": "" + } + ``` +- 明文:`serde_json::to_vec(MultiAppConfig)`;加密:AES-GCM(12 字节随机 nonce);每次保存生成新 nonce。 + +## 6. 模块与改造点 + +- 新增 `src-tauri/src/secure_store.rs`: + - `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。 + - `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)`;`decrypt_bytes(key, nonce, ciphertext)`。 + - `read_encrypted_config() -> Result`:读取 `config.enc.json`、解析封装、解密、反序列化。 + - `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。 +- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档): + - `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`(v1/v2)与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`),返回分组清单、大小、mtime;永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。 + - `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive//` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`(记录原路径、归档路径、大小、mtime、sha256、类别);同分区 `rename`,跨分区“copy + fsync + remove”。 + - `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。 + - 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。 + - 安全护栏:操作前后做 mtime/hash 复核(CAS);发生变化中止并提示“外部已修改”。 +- 调整 `src-tauri/src/app_config.rs`: + - `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文: + - 若检测到 v1(`ProviderManager`)→ 迁移到 v2(原有逻辑保留)。 + - `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup..json`(或保留为只读,视实现选择)。 +- 调整 `src-tauri/src/commands.rs::switch_provider`: + - Claude: + 1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON,写回 `manager.providers[manager.current].settings_config`。 + 2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。 + - Codex: + 1. 回填:读取 `~/.codex/auth.json`(JSON)与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。 + 2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML)。 + - 更新 `manager.current = id`,`state.save()` → 触发加密保存。 +- 保留/清理: + - 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。 + - 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。 + +## 7. 数据流与时序 + +- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。 +- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。 +- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。 +- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。 + +## 8. 迁移策略 + +- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。 +- 旧版支持: + - v1 明文(单 `ProviderManager`)→ 自动迁移为 v2(已有逻辑)。 + - v2 明文 → 直接加载。 +- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup..json`(或保留为只读)。 +- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。 +- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive//`,保留 `manifest.json` 以支持恢复。 + +## 9. 回滚策略 + +- 加密回滚:保留 `config.v1.backup..json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。 +- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。 + +## 10. 安全与性能 + +- 算法:AES-256-GCM(AEAD);随机 12 字节 nonce;每次保存新 nonce。 +- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 1–20ms,可缓存。 +- 可靠性:原子写入(临时文件 + rename);写入失败不破坏现有文件。 +- 可选增强:`zeroize` 清理密钥与明文;Claude 配置 JSON Schema 校验。 +- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。 + +## 11. API 与 UX 影响 + +- 前端 API:现有行为不变;新增清理相关命令(Tauri)供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive`(`purge_archived` 可选)。 +- UI 提示:在“配置文件位置”旁提示“已加密存储”。 +- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。 +- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。 + +## 12. 开发任务拆解(阶段一为本次交付) + +- 阶段一(核心改造 + 清理能力最小闭环) + - 新增模块 `secure_store.rs`:Keychain 与加解密工具函数。 + - 改造 `app_config.rs`:`load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。 + - 改造 `commands.rs::switch_provider`: + - 回填 live 配置 → 写入目标主配置(Claude/Codex)。 + - 去除对“副本恢复”的依赖(保留函数以便回退)。 + - 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。 + - 保持 `import_default_config`、`get_config_status` 行为不变。 +- 阶段二(清理与增强) + - 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。 + - Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。 +- 阶段三(安全升级) + - 密钥轮换;可选 passphrase(KDF: Argon2id + salt)。 + +## 14. 验收标准 + +- 功能: + - 无加密明文文件也能启动并正确读写; + - 切换成功将内存配置写入主配置; + - 外部手改在下一次切换前被回填保存; + - 旧配置自动迁移并生成加密文件; + - Keychain/解密异常时不损坏已有文件,给出可理解错误。 + - 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。 +- 质量: + - 关键路径加错误处理与日志; + - 写入采用原子替换; + - 代码变更集中、最小侵入,与现有风格一致。 + - 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。 + +## 15. 风险与对策 + +- Keychain 不可用或权限受限: + - 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。 +- 加密文件损坏: + - 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。 +- 与“副本文件”并存导致混淆: + - 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。 +- 清理误删或不可逆: + - 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。 + +## 16. 发布与回退 + +- 发布:随 Tauri 应用正常发布,无需前端变更。 +- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。 + +## 17. 旧文件清理与归档(新增) + +- 归档对象: + - `~/.cc-switch/config.json`(v1/v2,迁移成功后) + - `~/.claude/settings-*.json`(保留 `settings.json`) + - `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`) +- 归档位置与结构:`~/.cc-switch/archive//{cc-switch,claude,codex}/...` +- `manifest.json`:记录原路径、归档路径、大小、mtime、sha256、类别(v1/v2/claude/codex);用于恢复与可视化。 +- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。 +- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验;跨分区采用“copy+fsync+remove”;失败即时回滚并提示。 + +## 18. 变更点清单(代码) + +- 新增:`src-tauri/src/secure_store.rs` +- 修改: + - `src-tauri/src/app_config.rs`(load/save 加密化、迁移与原子写入) + - `src-tauri/src/commands.rs`(switch_provider 改为内存 → 主配置,并回填 live) + - `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件) +- 保持: + - `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动) + - 前端 `src/lib/tauri-api.ts` 与 UI 逻辑 + +## 19. 开放问题(待确认) + +- Keychain 失败时是否提供“本地明文密钥文件(600 权限)”的应急模式(当前建议:不提供,保持只读)。 +- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。 +- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30)。 + +--- + +以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需,我可以继续补充任务看板(Issue 列表)与实施顺序的 PR 规划。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 68bb71d..3073a89 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.0.0" +version = "3.1.1" dependencies = [ "dirs 5.0.1", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7591881..3fbbdaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,7 +5,7 @@ description = "Claude Code & Codex 供应商配置管理工具" authors = ["Jason Young"] license = "MIT" repository = "https://github.com/jasonyoung/cc-switch" -edition = "2024" +edition = "2021" rust-version = "1.85.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index e60bbc3..c6f1cc6 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -107,7 +107,16 @@ impl MultiAppConfig { /// 保存配置到文件 pub fn save(&self) -> Result<(), String> { let config_path = get_app_config_path(); - write_json_file(&config_path, self) + // 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容 + 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); + } + } + + write_json_file(&config_path, self)?; + Ok(()) } /// 获取指定应用的管理器 diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index f3cb151..f525156 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1,10 +1,12 @@ -use serde_json::Value; -use std::fs; +// unused imports removed use std::path::PathBuf; use crate::config::{ - copy_file, delete_file, read_json_file, sanitize_provider_name, write_json_file, + atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file, }; +use std::fs; +use std::path::Path; +use serde_json::Value; /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { @@ -36,57 +38,6 @@ pub fn get_codex_provider_paths( (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)); @@ -97,76 +48,92 @@ pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> R 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)); +//(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警) + +/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步 +pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> { 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))?; + std::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"); + // 读取旧内容用于回滚 + let old_auth = if auth_path.exists() { + Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?) } else { - return Err(format!( - "供应商 auth.json 不存在: {}", - provider_auth_path.display() - )); + None + }; + let _old_config = if config_path.exists() { + Some(fs::read(&config_path).map_err(|e| format!("读取旧 config.toml 失败: {}", e))?) + } else { + None + }; + + // 准备写入内容 + let cfg_text = match config_text_opt { + Some(s) => s.to_string(), + None => String::new(), + }; + if !cfg_text.trim().is_empty() { + toml::from_str::(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?; } - // 复制 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 缺失,已创建空文件"); + // 第一步:写 auth.json + write_json_file(&auth_path, auth)?; + + // 第二步:写 config.toml(失败则回滚 auth.json) + if let Err(e) = write_text_file(&config_path, &cfg_text) { + // 回滚 auth.json + if let Some(bytes) = old_auth { + let _ = atomic_write(&auth_path, &bytes); + } else { + let _ = delete_file(&auth_path); + } + return Err(e); } 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 +/// 读取 `~/.codex/config.toml`,若不存在返回空字符串 +pub fn read_codex_config_text() -> Result { + let path = get_codex_config_path(); + if path.exists() { + std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e)) } 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) + Ok(String::new()) + } +} + +/// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串 +pub fn read_config_text_from_path(path: &Path) -> Result { + if path.exists() { + std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e)) + } else { + Ok(String::new()) + } +} + +/// 对非空的 TOML 文本进行语法校验 +pub fn validate_config_toml(text: &str) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + toml::from_str::(text).map(|_| ()).map_err(|e| format!("config.toml 语法错误: {}", e)) +} + +/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空) +pub fn read_and_validate_codex_config_text() -> Result { + let s = read_codex_config_text()?; + validate_config_toml(&s)?; + Ok(s) +} + +/// 从指定路径读取并校验 config.toml,返回文本(可能为空) +pub fn read_and_validate_config_from_path(path: &Path) -> Result { + let s = read_config_text_from_path(path)?; + validate_config_toml(&s)?; + Ok(s) } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 67c588b..bfd706a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ 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::config::{ConfigStatus, get_claude_settings_path}; use crate::provider::Provider; use crate::store::AppState; @@ -74,37 +74,50 @@ pub async fn add_provider( .or_else(|| appType.as_deref().map(|s| s.into())) .unwrap_or(AppType::Claude); - let mut config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; + // 读取当前是否是激活供应商(短锁) + let is_current = { + let config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + manager.current == provider.id + }; - 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)?; + // 若目标为当前供应商,则先写 live,成功后再落盘配置 + if is_current { + match app_type { + AppType::Claude => { + let settings_path = crate::config::get_claude_settings_path(); + crate::config::write_json_file(&settings_path, &provider.settings_config)?; + } + AppType::Codex => { + let auth = provider + .settings_config + .get("auth") + .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?; + let cfg_text = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()); + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + } } } - manager.providers.insert(provider.id.clone(), provider); - - // 保存配置 - drop(config); // 释放锁 + // 更新内存并保存配置 + { + let mut config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + manager.providers.insert(provider.id.clone(), provider.clone()); + } state.save()?; Ok(true) @@ -124,59 +137,53 @@ pub async fn update_provider( .or_else(|| appType.as_deref().map(|s| s.into())) .unwrap_or(AppType::Claude); - let mut config = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - - // 检查供应商是否存在 - if !manager.providers.contains_key(&provider.id) { + // 读取校验 & 是否当前(短锁) + let (exists, is_current) = { + let config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + (manager.providers.contains_key(&provider.id), manager.current == provider.id) + }; + if !exists { 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(); - } + // 若更新的是当前供应商,先写 live 成功再保存 + if is_current { + match app_type { + AppType::Claude => { + let settings_path = crate::config::get_claude_settings_path(); + crate::config::write_json_file(&settings_path, &provider.settings_config)?; + } + AppType::Codex => { + let auth = provider + .settings_config + .get("auth") + .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?; + let cfg_text = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()); + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; } } } - // 保存新配置文件 - 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)?; - } + // 更新内存并保存 + { + let mut config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + manager.providers.insert(provider.id.clone(), provider.clone()); } - - manager.providers.insert(provider.id.clone(), provider); - - // 保存配置 - drop(config); // 释放锁 state.save()?; Ok(true) @@ -224,8 +231,11 @@ pub async fn delete_provider( } 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)?; + // 兼容历史两种命名:settings-{name}.json 与 settings-{id}.json + let by_name = get_provider_config_path(&id, Some(&provider.name)); + let by_id = get_provider_config_path(&id, None); + delete_file(&by_name)?; + delete_file(&by_id)?; } } @@ -269,54 +279,67 @@ pub async fn switch_provider( .ok_or_else(|| format!("供应商不存在: {}", id))? .clone(); - // 根据应用类型执行切换 + // SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置 match app_type { AppType::Codex => { - // 备份当前配置(如果存在) + use serde_json::Value; + + // 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config 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); + let auth_path = codex_config::get_codex_auth_path(); + let config_path = codex_config::get_codex_config_path(); + if auth_path.exists() { + let auth: Value = crate::config::read_json_file(&auth_path)?; + let config_str = if config_path.exists() { + std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取 config.toml 失败: {}", e))? + } else { + String::new() + }; + + let live = serde_json::json!({ + "auth": auth, + "config": config_str, + }); + + if let Some(cur) = manager.providers.get_mut(&manager.current) { + cur.settings_config = live; + } } } - // 恢复目标供应商配置 - codex_config::restore_codex_provider_config(&id, &provider.name)?; + // 切换:从目标供应商 settings_config 写入主配置(Codex 双文件原子+回滚) + let auth = provider + .settings_config + .get("auth") + .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?; + let cfg_text = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()); + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; } AppType::Claude => { - // 使用原有的 Claude 切换逻辑 - use crate::config::{ - backup_config, copy_file, get_claude_settings_path, get_provider_config_path, - }; + use crate::config::{read_json_file, write_json_file}; 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() - )); - } - - // 如果当前有配置,先备份到当前供应商 + // 回填:读取 live settings.json 写回当前供应商 settings_config 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 Ok(live) = read_json_file::(&settings_path) { + if let Some(cur) = manager.providers.get_mut(&manager.current) { + cur.settings_config = live; + } } } - // 确保主配置父目录存在 + // 切换:从目标供应商 settings_config 写入主配置 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)?; + // 不做归档,直接写入 + write_json_file(&settings_path, &provider.settings_config)?; } } @@ -345,7 +368,7 @@ pub async fn import_default_config( .or_else(|| appType.as_deref().map(|s| s.into())) .unwrap_or(AppType::Claude); - // 若已存在 default 供应商,则直接返回,避免重复导入 + // 仅当 providers 为空时才从 live 导入一条默认项 { let config = state .config @@ -353,19 +376,37 @@ pub async fn import_default_config( .map_err(|e| format!("获取锁失败: {}", e))?; if let Some(manager) = config.get_manager(&app_type) { - if manager.get_all_providers().contains_key("default") { + if !manager.get_all_providers().is_empty() { return Ok(true); } } } // 根据应用类型导入配置 + // 读取当前主配置为默认供应商(不再写入副本文件) let settings_config = match app_type { - AppType::Codex => codex_config::import_current_codex_config()?, - AppType::Claude => import_current_config_as_default()?, + AppType::Codex => { + let auth_path = codex_config::get_codex_auth_path(); + if !auth_path.exists() { + return Err("Codex 配置文件不存在".to_string()); + } + let auth: serde_json::Value = crate::config::read_json_file::(&auth_path)?; + let config_str = match crate::codex_config::read_and_validate_codex_config_text() { + Ok(s) => s, + Err(e) => return Err(e), + }; + serde_json::json!({ "auth": auth, "config": config_str }) + } + AppType::Claude => { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Err("Claude Code 配置文件不存在".to_string()); + } + crate::config::read_json_file::(&settings_path)? + } }; - // 创建默认供应商 + // 创建默认供应商(仅首次初始化) let provider = Provider::with_id( "default".to_string(), "default".to_string(), @@ -383,28 +424,9 @@ pub async fn import_default_config( .get_manager_mut(&app_type) .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - // 根据应用类型保存配置文件 - 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); - - // 如果没有当前供应商,设置为 default - if manager.current.is_empty() { - manager.current = "default".to_string(); - } + // 设置当前供应商为默认项 + manager.current = "default".to_string(); // 保存配置 drop(config); // 释放锁 diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e3766b7..5c18ce5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +// unused import removed use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; /// 获取 Claude Code 配置目录路径 @@ -38,6 +39,56 @@ pub fn get_app_config_path() -> PathBuf { get_app_config_dir().join("config.json") } +/// 归档根目录 ~/.cc-switch/archive +pub fn get_archive_root() -> PathBuf { + get_app_config_dir().join("archive") +} + +fn ensure_unique_path(dest: PathBuf) -> PathBuf { + if !dest.exists() { + return dest; + } + let file_name = dest + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".into()); + let ext = dest + .extension() + .map(|s| format!(".{}", s.to_string_lossy())) + .unwrap_or_default(); + let parent = dest.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + for i in 2..1000 { + let mut candidate = parent.clone(); + candidate.push(format!("{}-{}{}", file_name, i, ext)); + if !candidate.exists() { + return candidate; + } + } + dest +} + +/// 将现有文件归档到 `~/.cc-switch/archive///` 下,返回归档路径 +pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result, String> { + if !src.exists() { + return Ok(None); + } + let mut dest_dir = get_archive_root(); + dest_dir.push(ts.to_string()); + dest_dir.push(category); + fs::create_dir_all(&dest_dir).map_err(|e| format!("创建归档目录失败: {}", e))?; + + let file_name = src + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".into()); + let mut dest = dest_dir.join(file_name); + dest = ensure_unique_path(dest); + + copy_file(src, &dest)?; + Ok(Some(dest)) +} + + /// 清理供应商名称,确保文件名安全 pub fn sanitize_provider_name(name: &str) -> String { name.chars() @@ -79,7 +130,54 @@ pub fn write_json_file(path: &Path, data: &T) -> Result<(), String let json = serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?; - fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e)) + atomic_write(path, json.as_bytes()) +} + +/// 原子写入文本文件(用于 TOML/纯文本) +pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + atomic_write(path, data.as_bytes()) +} + +/// 原子写入:写入临时文件后 rename 替换,避免半写状态 +pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + + let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?; + let mut tmp = parent.to_path_buf(); + let file_name = path + .file_name() + .ok_or_else(|| "无效的文件名".to_string())? + .to_string_lossy() + .to_string(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + tmp.push(format!("{}.tmp.{}", file_name, ts)); + + { + let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?; + f.write_all(data) + .map_err(|e| format!("写入临时文件失败: {}", e))?; + f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(path) { + let perm = meta.permissions().mode(); + let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); + } + } + + fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?; + Ok(()) } /// 复制文件 @@ -112,30 +210,4 @@ pub fn get_claude_config_status() -> ConfigStatus { } } -/// 备份配置文件 -pub fn backup_config(from: &Path, to: &Path) -> Result<(), String> { - if from.exists() { - copy_file(from, to)?; - log::info!("已备份配置文件: {} -> {}", from.display(), to.display()); - } - Ok(()) -} - -/// 导入当前 Claude Code 配置为默认供应商 -pub fn import_current_config_as_default() -> Result { - let settings_path = get_claude_settings_path(); - - if !settings_path.exists() { - return Err("Claude Code 配置文件不存在".to_string()); - } - - // 读取当前配置 - let settings_config: Value = read_json_file(&settings_path)?; - - // 保存为 default 供应商 - let default_provider_path = get_provider_config_path("default", Some("default")); - write_json_file(&default_provider_path, &settings_config)?; - - log::info!("已导入当前配置为默认供应商"); - Ok(settings_config) -} +//(移除未使用的备份/导入函数,避免 dead_code 告警) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 82b7886..077b029 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod config; mod provider; mod store; +mod migration; use store::AppState; use tauri::Manager; @@ -55,48 +56,16 @@ pub fn run() { // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) let app_state = AppState::new(); - // 如果没有供应商且存在 Claude Code 配置,自动导入 + // 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档 { - 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() { - 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(); - log::info!("成功导入默认供应商"); - } - } - } - } + let mut config_guard = app_state.config.lock().unwrap(); + let migrated = migration::migrate_copies_into_config(&mut *config_guard)?; + if migrated { + log::info!("已将副本文件导入到 config.json,并完成归档"); } - - // 确保 Codex 应用存在 - config.ensure_app(&app_config::AppType::Codex); + // 确保两个 App 条目存在 + config_guard.ensure_app(&app_config::AppType::Claude); + config_guard.ensure_app(&app_config::AppType::Codex); } // 保存配置 diff --git a/src-tauri/src/migration.rs b/src-tauri/src/migration.rs new file mode 100644 index 0000000..c440383 --- /dev/null +++ b/src-tauri/src/migration.rs @@ -0,0 +1,435 @@ +use crate::app_config::{AppType, MultiAppConfig}; +use crate::config::{ + archive_file, delete_file, get_app_config_dir, get_app_config_path, get_claude_config_dir, +}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +fn now_ts() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn get_marker_path() -> PathBuf { + get_app_config_dir().join("migrated.copies.v1") +} + +fn sanitized_id(base: &str) -> String { + crate::config::sanitize_provider_name(base) +} + +fn next_unique_id(existing: &HashSet, base: &str) -> String { + let base = sanitized_id(base); + if !existing.contains(&base) { + return base; + } + for i in 2..1000 { + let candidate = format!("{}-{}", base, i); + if !existing.contains(&candidate) { + return candidate; + } + } + format!("{}-dup", base) +} + +fn extract_claude_api_key(value: &Value) -> Option { + value + .get("env") + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn extract_codex_api_key(value: &Value) -> Option { + value + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY").or_else(|| auth.get("openai_api_key"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn norm_name(s: &str) -> String { + s.trim().to_lowercase() +} + +// 去重策略:name + 原始 key 直接比较(不做哈希) + +fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> { + let mut items = Vec::new(); + let dir = get_claude_config_dir(); + if !dir.exists() { + return items; + } + if let Ok(rd) = fs::read_dir(&dir) { + for e in rd.flatten() { + let p = e.path(); + let fname = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + if fname == "settings.json" || fname == "claude.json" { + continue; + } + if !fname.starts_with("settings-") || !fname.ends_with(".json") { + continue; + } + let name = fname.trim_start_matches("settings-").trim_end_matches(".json"); + if let Ok(val) = crate::config::read_json_file::(&p) { + items.push((name.to_string(), p, val)); + } + } + } + items +} + +fn scan_codex_copies() -> Vec<(String, Option, Option, Value)> { + let mut by_name: HashMap, Option)> = HashMap::new(); + let dir = crate::codex_config::get_codex_config_dir(); + if !dir.exists() { + return Vec::new(); + } + if let Ok(rd) = fs::read_dir(&dir) { + for e in rd.flatten() { + let p = e.path(); + let fname = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + if fname.starts_with("auth-") && fname.ends_with(".json") { + let name = fname.trim_start_matches("auth-").trim_end_matches(".json"); + let entry = by_name.entry(name.to_string()).or_default(); + entry.0 = Some(p); + } else if fname.starts_with("config-") && fname.ends_with(".toml") { + let name = fname.trim_start_matches("config-").trim_end_matches(".toml"); + let entry = by_name.entry(name.to_string()).or_default(); + entry.1 = Some(p); + } + } + } + + let mut items = Vec::new(); + for (name, (auth_path, config_path)) in by_name { + if let Some(authp) = auth_path { + if let Ok(auth) = crate::config::read_json_file::(&authp) { + let config_str = if let Some(cfgp) = &config_path { + match crate::codex_config::read_and_validate_config_from_path(cfgp) { + Ok(s) => s, + Err(e) => { + log::warn!("跳过无效 Codex config-{}.toml: {}", name, e); + String::new() + } + } + } else { + String::new() + }; + let settings = serde_json::json!({ + "auth": auth, + "config": config_str, + }); + items.push((name, Some(authp), config_path, settings)); + } + } + } + items +} + +pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result { + // 如果已迁移过则跳过 + let marker = get_marker_path(); + if marker.exists() { + return Ok(false); + } + + let claude_items = scan_claude_copies(); + let codex_items = scan_codex_copies(); + if claude_items.is_empty() && codex_items.is_empty() { + // 即便没有可迁移项,也写入标记避免每次扫描 + fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?; + return Ok(false); + } + + // 备份旧的 config.json + let ts = now_ts(); + let app_cfg_path = get_app_config_path(); + if app_cfg_path.exists() { + let _ = archive_file(ts, "cc-switch", &app_cfg_path); + } + + // 读取 live:Claude(settings.json / claude.json) + let live_claude: Option<(String, Value)> = { + let settings_path = crate::config::get_claude_settings_path(); + if settings_path.exists() { + match crate::config::read_json_file::(&settings_path) { + Ok(val) => Some(("default".to_string(), val)), + Err(e) => { + log::warn!("读取 Claude live 配置失败: {}", e); + None + } + } + } else { + None + } + }; + + // 合并:Claude(优先 live,然后副本) - 去重键: name + apiKey(直接比较) + config.ensure_app(&AppType::Claude); + let manager = config.get_manager_mut(&AppType::Claude).unwrap(); + let mut ids: HashSet = manager.providers.keys().cloned().collect(); + let mut live_claude_id: Option = None; + + if let Some((name, value)) = &live_claude { + let cand_key = extract_claude_api_key(value); + let exist_id = manager + .providers + .iter() + .find_map(|(id, p)| { + let pk = extract_claude_api_key(&p.settings_config); + if norm_name(&p.name) == norm_name(name) && pk == cand_key { + Some(id.clone()) + } else { + None + } + }); + if let Some(exist_id) = exist_id { + if let Some(prov) = manager.providers.get_mut(&exist_id) { + log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name); + prov.settings_config = value.clone(); + live_claude_id = Some(exist_id); + } + } else { + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id.clone(), + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + live_claude_id = Some(id); + } + } + for (name, path, value) in claude_items.iter() { + let cand_key = extract_claude_api_key(value); + let exist_id = manager + .providers + .iter() + .find_map(|(id, p)| { + let pk = extract_claude_api_key(&p.settings_config); + if norm_name(&p.name) == norm_name(name) && pk == cand_key { + Some(id.clone()) + } else { + None + } + }); + if let Some(exist_id) = exist_id { + if let Some(prov) = manager.providers.get_mut(&exist_id) { + log::info!("覆盖 Claude 供应商 '{}' 来自 {} (by name+key)", name, path.display()); + prov.settings_config = value.clone(); + } + } else { + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id.clone(), + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + } + } + + // 读取 live:Codex(auth.json 必需,config.toml 可空) + let live_codex: Option<(String, Value)> = { + let auth_path = crate::codex_config::get_codex_auth_path(); + if auth_path.exists() { + match crate::config::read_json_file::(&auth_path) { + Ok(auth) => { + let cfg = match crate::codex_config::read_and_validate_codex_config_text() { + Ok(s) => s, + Err(e) => { + log::warn!("读取/校验 Codex live config.toml 失败: {}", e); + String::new() + } + }; + Some(("default".to_string(), serde_json::json!({"auth": auth, "config": cfg}))) + } + Err(e) => { + log::warn!("读取 Codex live auth.json 失败: {}", e); + None + } + } + } else { + None + } + }; + + // 合并:Codex(优先 live,然后副本) - 去重键: name + OPENAI_API_KEY(直接比较) + config.ensure_app(&AppType::Codex); + let manager = config.get_manager_mut(&AppType::Codex).unwrap(); + let mut ids: HashSet = manager.providers.keys().cloned().collect(); + let mut live_codex_id: Option = None; + + if let Some((name, value)) = &live_codex { + let cand_key = extract_codex_api_key(value); + let exist_id = manager + .providers + .iter() + .find_map(|(id, p)| { + let pk = extract_codex_api_key(&p.settings_config); + if norm_name(&p.name) == norm_name(name) && pk == cand_key { + Some(id.clone()) + } else { + None + } + }); + if let Some(exist_id) = exist_id { + if let Some(prov) = manager.providers.get_mut(&exist_id) { + log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name); + prov.settings_config = value.clone(); + live_codex_id = Some(exist_id); + } + } else { + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id.clone(), + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + live_codex_id = Some(id); + } + } + for (name, authp, cfgp, value) in codex_items.iter() { + let cand_key = extract_codex_api_key(value); + let exist_id = manager + .providers + .iter() + .find_map(|(id, p)| { + let pk = extract_codex_api_key(&p.settings_config); + if norm_name(&p.name) == norm_name(name) && pk == cand_key { + Some(id.clone()) + } else { + None + } + }); + if let Some(exist_id) = exist_id { + if let Some(prov) = manager.providers.get_mut(&exist_id) { + log::info!("覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)", name, authp, cfgp); + prov.settings_config = value.clone(); + } + } else { + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id.clone(), + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + } + } + + // 若当前为空,将 live 导入项设为当前 + { + let manager = config.get_manager_mut(&AppType::Claude).unwrap(); + if manager.current.is_empty() { + if let Some(id) = live_claude_id { + manager.current = id; + } + } + } + { + let manager = config.get_manager_mut(&AppType::Codex).unwrap(); + if manager.current.is_empty() { + if let Some(id) = live_codex_id { + manager.current = id; + } + } + } + + // 归档副本文件 + for (_, p, _) in claude_items.into_iter() { + match archive_file(ts, "claude", &p) { + Ok(Some(_)) => { + let _ = delete_file(&p); + } + _ => { + // 归档失败则不要删除原文件,保守处理 + } + } + } + for (_, ap, cp, _) in codex_items.into_iter() { + if let Some(ap) = ap { + match archive_file(ts, "codex", &ap) { + Ok(Some(_)) => { let _ = delete_file(&ap); } + _ => {} + } + } + if let Some(cp) = cp { + match archive_file(ts, "codex", &cp) { + Ok(Some(_)) => { let _ = delete_file(&cp); } + _ => {} + } + } + } + + // 标记完成 + // 仅在迁移阶段执行一次全量去重(忽略大小写的名称 + API Key) + let removed = dedupe_config(config); + if removed > 0 { + log::info!("迁移阶段已去重重复供应商 {} 个", removed); + } + + fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?; + Ok(true) +} + +/// 启动时对现有配置做一次去重:按名称(忽略大小写)+API Key +pub fn dedupe_config(config: &mut MultiAppConfig) -> usize { + use std::collections::HashMap as Map; + + fn dedupe_one( + mgr: &mut crate::provider::ProviderManager, + extract_key: &dyn Fn(&Value) -> Option, + ) -> usize { + let mut keep: Map = Map::new(); // key -> id 保留 + let mut remove: Vec = Vec::new(); + for (id, p) in mgr.providers.iter() { + let k = format!("{}|{}", norm_name(&p.name), extract_key(&p.settings_config).unwrap_or_default()); + if let Some(exist_id) = keep.get(&k) { + // 若当前是正在使用的,则用当前替换之前的,反之丢弃当前 + if *id == mgr.current { + // 替换:把原先的标记为删除,改保留为当前 + remove.push(exist_id.clone()); + keep.insert(k, id.clone()); + } else { + remove.push(id.clone()); + } + } else { + keep.insert(k, id.clone()); + } + } + for id in remove.iter() { + mgr.providers.remove(id); + } + remove.len() + } + + let mut removed = 0; + if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Claude) { + removed += dedupe_one(mgr, &extract_claude_api_key); + } + if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Codex) { + removed += dedupe_one(mgr, &extract_codex_api_key); + } + removed +} diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index a972339..924cfbf 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use crate::config::{get_provider_config_path, write_json_file}; +// SSOT 模式:不再写供应商副本文件 /// 供应商结构体 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,17 +50,6 @@ impl Default for ProviderManager { } impl ProviderManager { - /// 添加供应商 - pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> { - // 保存供应商配置到独立文件 - 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 get_all_providers(&self) -> &HashMap { &self.providers diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d784b49..46f2ad0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,6 +12,7 @@ "app": { "windows": [ { + "label": "main", "title": "", "width": 900, "height": 650, @@ -23,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:" + "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 53f406c..1010920 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,7 +80,7 @@ function App() { setProviders(loadedProviders); setCurrentProviderId(currentId); - // 如果供应商列表为空,尝试自动导入现有配置为"default"供应商 + // 如果供应商列表为空,尝试自动从 live 导入一条默认供应商 if (Object.keys(loadedProviders).length === 0) { await handleAutoImportDefault(); } @@ -154,18 +154,14 @@ function App() { } }; - // 自动导入现有配置为"default"供应商 + // 自动从 live 导入一条默认供应商(仅首次初始化时) const handleAutoImportDefault = async () => { try { const result = await window.api.importCurrentConfigAsDefault(activeApp); if (result.success) { await loadProviders(); - showNotification( - "已自动导入现有配置为 default 供应商", - "success", - 3000, - ); + showNotification("已从现有配置创建默认供应商", "success", 3000); } // 如果导入失败(比如没有现有配置),静默处理,不显示错误 } catch (error) { @@ -183,10 +179,7 @@ function App() {

CC Switch

- +
); -} \ No newline at end of file +} diff --git a/src/components/ProviderForm.tsx b/src/components/ProviderForm.tsx index 1288f86..6c188d0 100644 --- a/src/components/ProviderForm.tsx +++ b/src/components/ProviderForm.tsx @@ -74,7 +74,7 @@ const ProviderForm: React.FC = ({ const [disableCoAuthored, setDisableCoAuthored] = useState(false); // -1 表示自定义,null 表示未选择,>= 0 表示预设索引 const [selectedPreset, setSelectedPreset] = useState( - showPresets ? -1 : null + showPresets ? -1 : null, ); const [apiKey, setApiKey] = useState(""); @@ -302,7 +302,7 @@ const ProviderForm: React.FC = ({ // 根据当前配置决定是否展示 API Key 输入框 // 自定义模式(-1)不显示独立的 API Key 输入框 const showApiKey = - (selectedPreset !== null && selectedPreset !== -1) || + (selectedPreset !== null && selectedPreset !== -1) || (!showPresets && hasApiKeyField(formData.settingsConfig)); // 判断当前选中的预设是否是官方 @@ -322,7 +322,7 @@ const ProviderForm: React.FC = ({ }; // 自定义模式(-1)不显示独立的 API Key 输入框 const showCodexApiKey = - (selectedCodexPreset !== null && selectedCodexPreset !== -1) || + (selectedCodexPreset !== null && selectedCodexPreset !== -1) || (!showPresets && getCodexAuthApiKey(codexAuth) !== ""); const isCodexOfficialPreset = selectedCodexPreset !== null && @@ -409,14 +409,20 @@ const ProviderForm: React.FC = ({ })} {selectedPreset === -1 && ( - + 手动配置供应商,需要填写完整的配置信息 )} {selectedPreset !== -1 && selectedPreset !== null && ( - - {isOfficialPreset - ? "Claude 官方登录,不需要填写 API Key" + + {isOfficialPreset + ? "Claude 官方登录,不需要填写 API Key" : "使用预设配置,只需填写 API Key"} )} @@ -450,14 +456,20 @@ const ProviderForm: React.FC = ({ ))} {selectedCodexPreset === -1 && ( - + 手动配置供应商,需要填写完整的配置信息 )} {selectedCodexPreset !== -1 && selectedCodexPreset !== null && ( - - {isCodexOfficialPreset - ? "Codex 官方登录,不需要填写 API Key" + + {isCodexOfficialPreset + ? "Codex 官方登录,不需要填写 API Key" : "使用预设配置,只需填写 API Key"} )} @@ -525,7 +537,9 @@ const ProviderForm: React.FC = ({ } disabled={isCodexOfficialPreset} required={ - selectedCodexPreset !== null && selectedCodexPreset >= 0 && !isCodexOfficialPreset + selectedCodexPreset !== null && + selectedCodexPreset >= 0 && + !isCodexOfficialPreset } autoComplete="off" style={ diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 2a76ebf..09c60f4 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -94,7 +94,6 @@ const ProviderList: React.FC = ({