Merge pull request #10 from farion1231/feat/ssot

feat(ssot): 首次迁移副本入库 + 切换回填不丢数据 + Codex 原子写入回滚
This commit is contained in:
Jason Young
2025-09-05 21:42:11 +08:00
committed by GitHub
17 changed files with 1027 additions and 357 deletions

View File

@@ -69,7 +69,7 @@
- 供应商副本:`auth-<name>.json``config-<name>.toml` - 供应商副本:`auth-<name>.json``config-<name>.toml`
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY` - API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml` - 切换策略:将选中供应商的副本覆盖到主配置(`auth.json``config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`
- 导入默认:`~/.codex/auth.json` 存在,会将当前主配置导入为 `default` 供应商`config.toml` 不存在时按空处理。 - 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前`config.toml` 不存在时按空处理。
- 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。 - 官方登录可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
### Claude Code 说明 ### Claude Code 说明
@@ -79,7 +79,7 @@
- 供应商副本:`settings-<name>.json` - 供应商副本:`settings-<name>.json`
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN` - API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。 - 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
- 导入默认:`~/.claude/settings.json``~/.claude/claude.json` 存在,会将当前主配置导入为 `default` 供应商副本 - 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前
- 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。 - 官方登录可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
### 迁移与备份 ### 迁移与备份

View File

@@ -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-<name>.json`Codex`~/.codex/auth-<name>.json``config-<name>.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/<ts>/`,生成 `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": "<base64-nonce>",
"ct": "<base64-ciphertext>"
}
```
- 明文:`serde_json::to_vec(MultiAppConfig)`加密AES-GCM12 字节随机 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<MultiAppConfig, String>`:读取 `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/<ts>/` 下对应子目录(`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.<ts>.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.<ts>.json`(或保留为只读)。
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
## 9. 回滚策略
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
## 10. 安全与性能
- 算法AES-256-GCMAEAD随机 12 字节 nonce每次保存新 nonce。
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 120ms可缓存。
- 可靠性:原子写入(临时文件 + 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 校验;导出明文快照;只读模式显式开关。
- 阶段三(安全升级)
- 密钥轮换;可选 passphraseKDF: 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/<timestamp>/{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 规划。

2
src-tauri/Cargo.lock generated
View File

@@ -550,7 +550,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.0.0" version = "3.1.1"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"log", "log",

View File

@@ -5,7 +5,7 @@ description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
repository = "https://github.com/jasonyoung/cc-switch" repository = "https://github.com/jasonyoung/cc-switch"
edition = "2024" edition = "2021"
rust-version = "1.85.0" rust-version = "1.85.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -107,7 +107,16 @@ impl MultiAppConfig {
/// 保存配置到文件 /// 保存配置到文件
pub fn save(&self) -> Result<(), String> { pub fn save(&self) -> Result<(), String> {
let config_path = get_app_config_path(); 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(())
} }
/// 获取指定应用的管理器 /// 获取指定应用的管理器

View File

@@ -1,10 +1,12 @@
use serde_json::Value; // unused imports removed
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::{ 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 配置目录路径 /// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf { pub fn get_codex_config_dir() -> PathBuf {
@@ -36,57 +38,6 @@ pub fn get_codex_provider_paths(
(auth_path, config_path) (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::<toml::Table>(config_str)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
fs::write(&config_path, config_str)
.map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?;
}
}
Ok(())
}
/// 删除 Codex 供应商配置文件 /// 删除 Codex 供应商配置文件
pub fn delete_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> { 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)); 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(()) Ok(())
} }
/// 从 Codex 供应商配置副本恢复到主配置 //(移除未使用的备份/保存/恢复/导入函数,避免 dead_code 告警)
pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> Result<(), String> {
let (provider_auth_path, provider_config_path) = /// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
get_codex_provider_paths(provider_id, Some(provider_name)); pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> Result<(), String> {
let auth_path = get_codex_auth_path(); let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path(); let config_path = get_codex_config_path();
// 确保目录存在
if let Some(parent) = auth_path.parent() { 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() { let old_auth = if auth_path.exists() {
copy_file(&provider_auth_path, &auth_path)?; Some(fs::read(&auth_path).map_err(|e| format!("读取旧 auth.json 失败: {}", e))?)
log::info!("已恢复 Codex auth.json");
} else { } else {
return Err(format!( None
"供应商 auth.json 不存在: {}", };
provider_auth_path.display() 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::<toml::Table>(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?;
} }
// 复制 config.toml可选允许为空不存在则创建空文件以保持一致性 // 第一步:写 auth.json
if provider_config_path.exists() { write_json_file(&auth_path, auth)?;
copy_file(&provider_config_path, &config_path)?;
log::info!("已恢复 Codex config.toml"); // 第二步:写 config.toml失败则回滚 auth.json
} else { if let Err(e) = write_text_file(&config_path, &cfg_text) {
// 写入空文件 // 回滚 auth.json
fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?; if let Some(bytes) = old_auth {
log::info!("供应商 config.toml 缺失,已创建空文件"); let _ = atomic_write(&auth_path, &bytes);
} else {
let _ = delete_file(&auth_path);
}
return Err(e);
} }
Ok(()) Ok(())
} }
/// 导入当前 Codex 配置为默认供应商 /// 读取 `~/.codex/config.toml`,若不存在返回空字符串
pub fn import_current_codex_config() -> Result<Value, String> { pub fn read_codex_config_text() -> Result<String, String> {
let auth_path = get_codex_auth_path(); let path = get_codex_config_path();
let config_path = get_codex_config_path(); if path.exists() {
std::fs::read_to_string(&path).map_err(|e| format!("读取 config.toml 失败: {}", e))
// 行为放宽:仅要求 auth.json 存在config.toml 可缺失
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
// 读取 auth.json
let auth = read_json_file::<Value>(&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::<toml::Table>(&s)
.map_err(|e| format!("config.toml 语法错误: {}", e))?;
}
s
} else { } else {
String::new() Ok(String::new())
}; }
}
// 组合成完整配置
let settings_config = serde_json::json!({ /// 从给定路径读取 config.toml 文本(路径存在时);路径不存在则返回空字符串
"auth": auth, pub fn read_config_text_from_path(path: &Path) -> Result<String, String> {
"config": config_str if path.exists() {
}); std::fs::read_to_string(path).map_err(|e| format!("读取 {} 失败: {}", path.display(), e))
} else {
// 保存为默认供应商副本 Ok(String::new())
save_codex_provider_config("default", "default", &settings_config)?; }
}
Ok(settings_config)
/// 对非空的 TOML 文本进行语法校验
pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text).map(|_| ()).map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
pub fn read_and_validate_codex_config_text() -> Result<String, String> {
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<String, String> {
let s = read_config_text_from_path(path)?;
validate_config_toml(&s)?;
Ok(s)
} }

View File

@@ -6,7 +6,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::codex_config; 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::provider::Provider;
use crate::store::AppState; use crate::store::AppState;
@@ -74,37 +74,50 @@ pub async fn add_provider(
.or_else(|| appType.as_deref().map(|s| s.into())) .or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude); .unwrap_or(AppType::Claude);
let mut config = state // 读取当前是否是激活供应商(短锁)
.config let is_current = {
.lock() let config = state
.map_err(|e| format!("获取锁失败: {}", e))?; .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 // 若目标为当前供应商,则先写 live成功后再落盘配置
.get_manager_mut(&app_type) if is_current {
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; match app_type {
AppType::Claude => {
// 根据应用类型保存配置文件 let settings_path = crate::config::get_claude_settings_path();
match app_type { crate::config::write_json_file(&settings_path, &provider.settings_config)?;
AppType::Codex => { }
// Codex: 保存两个文件 AppType::Codex => {
codex_config::save_codex_provider_config( let auth = provider
&provider.id, .settings_config
&provider.name, .get("auth")
&provider.settings_config, .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
)?; let cfg_text = provider
} .settings_config
AppType::Claude => { .get("config")
// Claude: 使用原有逻辑 .and_then(|v| v.as_str());
use crate::config::{get_provider_config_path, write_json_file}; crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
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); // 更新内存并保存配置
{
// 保存配置 let mut config = state
drop(config); // 释放锁 .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()?; state.save()?;
Ok(true) Ok(true)
@@ -124,59 +137,53 @@ pub async fn update_provider(
.or_else(|| appType.as_deref().map(|s| s.into())) .or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude); .unwrap_or(AppType::Claude);
let mut config = state // 读取校验 & 是否当前(短锁)
.config let (exists, is_current) = {
.lock() let config = state
.map_err(|e| format!("获取锁失败: {}", e))?; .config
.lock()
let manager = config .map_err(|e| format!("获取锁失败: {}", e))?;
.get_manager_mut(&app_type) let manager = config
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; .get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在 (manager.providers.contains_key(&provider.id), manager.current == provider.id)
if !manager.providers.contains_key(&provider.id) { };
if !exists {
return Err(format!("供应商不存在: {}", provider.id)); return Err(format!("供应商不存在: {}", provider.id));
} }
// 如果名称改变了,需要处理配置文件 // 若更新的是当前供应商,先写 live 成功再保存
if let Some(old_provider) = manager.providers.get(&provider.id) { if is_current {
if old_provider.name != provider.name { match app_type {
// 删除旧配置文件 AppType::Claude => {
match app_type { let settings_path = crate::config::get_claude_settings_path();
AppType::Codex => { crate::config::write_json_file(&settings_path, &provider.settings_config)?;
codex_config::delete_codex_provider_config(&provider.id, &old_provider.name) }
.ok(); AppType::Codex => {
} let auth = provider
AppType::Claude => { .settings_config
use crate::config::{delete_file, get_provider_config_path}; .get("auth")
let old_config_path = .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
get_provider_config_path(&provider.id, Some(&old_provider.name)); let cfg_text = provider
delete_file(&old_config_path).ok(); .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 => { let mut config = state
codex_config::save_codex_provider_config( .config
&provider.id, .lock()
&provider.name, .map_err(|e| format!("获取锁失败: {}", e))?;
&provider.settings_config, let manager = config
)?; .get_manager_mut(&app_type)
} .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
AppType::Claude => { manager.providers.insert(provider.id.clone(), provider.clone());
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(config); // 释放锁
state.save()?; state.save()?;
Ok(true) Ok(true)
@@ -224,8 +231,11 @@ pub async fn delete_provider(
} }
AppType::Claude => { AppType::Claude => {
use crate::config::{delete_file, get_provider_config_path}; use crate::config::{delete_file, get_provider_config_path};
let config_path = get_provider_config_path(&id, Some(&provider.name)); // 兼容历史两种命名settings-{name}.json 与 settings-{id}.json
delete_file(&config_path)?; 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))? .ok_or_else(|| format!("供应商不存在: {}", id))?
.clone(); .clone();
// 根据应用类型执行切换 // SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type { match app_type {
AppType::Codex => { AppType::Codex => {
// 备份当前配置(如果存在) use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !manager.current.is_empty() { if !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) { let auth_path = codex_config::get_codex_auth_path();
codex_config::backup_codex_config(&manager.current, &current_provider.name)?; let config_path = codex_config::get_codex_config_path();
log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name); 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;
}
} }
} }
// 恢复目标供应商配置 // 切换:从目标供应商 settings_config 写入主配置Codex 双文件原子+回滚)
codex_config::restore_codex_provider_config(&id, &provider.name)?; 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 => { AppType::Claude => {
// 使用原有的 Claude 切换逻辑 use crate::config::{read_json_file, write_json_file};
use crate::config::{
backup_config, copy_file, get_claude_settings_path, get_provider_config_path,
};
let settings_path = get_claude_settings_path(); let settings_path = get_claude_settings_path();
let provider_config_path = get_provider_config_path(&id, Some(&provider.name));
// 检查供应商配置文件是否存在 // 回填:读取 live settings.json 写回当前供应商 settings_config
if !provider_config_path.exists() {
return Err(format!(
"供应商配置文件不存在: {}",
provider_config_path.display()
));
}
// 如果当前有配置,先备份到当前供应商
if settings_path.exists() && !manager.current.is_empty() { if settings_path.exists() && !manager.current.is_empty() {
if let Some(current_provider) = manager.providers.get(&manager.current) { if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
let current_provider_path = if let Some(cur) = manager.providers.get_mut(&manager.current) {
get_provider_config_path(&manager.current, Some(&current_provider.name)); cur.settings_config = live;
backup_config(&settings_path, &current_provider_path)?; }
log::info!("已备份当前供应商配置: {}", current_provider.name);
} }
} }
// 确保主配置父目录存在 // 切换:从目标供应商 settings_config 写入主配置
if let Some(parent) = settings_path.parent() { if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; 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())) .or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude); .unwrap_or(AppType::Claude);
// 若已存在 default 供应商,则直接返回,避免重复导入 // 仅当 providers 为空时才从 live 导入一条默认项
{ {
let config = state let config = state
.config .config
@@ -353,19 +376,37 @@ pub async fn import_default_config(
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
if let Some(manager) = config.get_manager(&app_type) { 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); return Ok(true);
} }
} }
} }
// 根据应用类型导入配置 // 根据应用类型导入配置
// 读取当前主配置为默认供应商(不再写入副本文件)
let settings_config = match app_type { let settings_config = match app_type {
AppType::Codex => codex_config::import_current_codex_config()?, AppType::Codex => {
AppType::Claude => import_current_config_as_default()?, 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::<serde_json::Value>(&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::<serde_json::Value>(&settings_path)?
}
}; };
// 创建默认供应商 // 创建默认供应商(仅首次初始化)
let provider = Provider::with_id( let provider = Provider::with_id(
"default".to_string(), "default".to_string(),
"default".to_string(), "default".to_string(),
@@ -383,28 +424,9 @@ pub async fn import_default_config(
.get_manager_mut(&app_type) .get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", 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); manager.providers.insert(provider.id.clone(), provider);
// 设置当前供应商为默认项
// 如果没有当前供应商,设置为 default manager.current = "default".to_string();
if manager.current.is_empty() {
manager.current = "default".to_string();
}
// 保存配置 // 保存配置
drop(config); // 释放锁 drop(config); // 释放锁

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; // unused import removed
use std::fs; use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// 获取 Claude Code 配置目录路径 /// 获取 Claude Code 配置目录路径
@@ -38,6 +39,56 @@ pub fn get_app_config_path() -> PathBuf {
get_app_config_dir().join("config.json") 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/<ts>/<category>/` 下,返回归档路径
pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBuf>, 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 { pub fn sanitize_provider_name(name: &str) -> String {
name.chars() name.chars()
@@ -79,7 +130,54 @@ pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), String
let json = let json =
serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?; 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 {
} }
} }
/// 备份配置文件 //(移除未使用的备份/导入函数,避免 dead_code 告警)
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<Value, String> {
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)
}

View File

@@ -4,6 +4,7 @@ mod commands;
mod config; mod config;
mod provider; mod provider;
mod store; mod store;
mod migration;
use store::AppState; use store::AppState;
use tauri::Manager; use tauri::Manager;
@@ -55,48 +56,16 @@ pub fn run() {
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage
let app_state = AppState::new(); let app_state = AppState::new();
// 如果没有供应商且存在 Claude Code 配置,自动导入 // 首次启动迁移:扫描副本文件,合并到 config.json并归档副本旧 config.json 先归档
{ {
let mut config = app_state.config.lock().unwrap(); let mut config_guard = app_state.config.lock().unwrap();
let migrated = migration::migrate_copies_into_config(&mut *config_guard)?;
// 检查 Claude 供应商 if migrated {
let need_import = if let Some(claude_manager) = log::info!("已将副本文件导入到 config.json并完成归档");
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!("成功导入默认供应商");
}
}
}
}
} }
// 确保两个 App 条目存在
// 确保 Codex 应用存在 config_guard.ensure_app(&app_config::AppType::Claude);
config.ensure_app(&app_config::AppType::Codex); config_guard.ensure_app(&app_config::AppType::Codex);
} }
// 保存配置 // 保存配置

435
src-tauri/src/migration.rs Normal file
View File

@@ -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<String>, 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<String> {
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<String> {
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::<Value>(&p) {
items.push((name.to_string(), p, val));
}
}
}
items
}
fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)> {
let mut by_name: HashMap<String, (Option<PathBuf>, Option<PathBuf>)> = 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::<Value>(&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<bool, String> {
// 如果已迁移过则跳过
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);
}
// 读取 liveClaudesettings.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::<Value>(&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<String> = manager.providers.keys().cloned().collect();
let mut live_claude_id: Option<String> = 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);
}
}
// 读取 liveCodexauth.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::<Value>(&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<String> = manager.providers.keys().cloned().collect();
let mut live_codex_id: Option<String> = 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<String>,
) -> usize {
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
let mut remove: Vec<String> = 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
}

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use crate::config::{get_provider_config_path, write_json_file}; // SSOT 模式:不再写供应商副本文件
/// 供应商结构体 /// 供应商结构体
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -50,17 +50,6 @@ impl Default for ProviderManager {
} }
impl 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<String, Provider> { pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
&self.providers &self.providers

View File

@@ -12,6 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"label": "main",
"title": "", "title": "",
"width": 900, "width": 900,
"height": 650, "height": 650,
@@ -23,7 +24,7 @@
} }
], ],
"security": { "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": { "bundle": {

View File

@@ -80,7 +80,7 @@ function App() {
setProviders(loadedProviders); setProviders(loadedProviders);
setCurrentProviderId(currentId); setCurrentProviderId(currentId);
// 如果供应商列表为空,尝试自动导入现有配置为"default"供应商 // 如果供应商列表为空,尝试自动从 live 导入一条默认供应商
if (Object.keys(loadedProviders).length === 0) { if (Object.keys(loadedProviders).length === 0) {
await handleAutoImportDefault(); await handleAutoImportDefault();
} }
@@ -154,18 +154,14 @@ function App() {
} }
}; };
// 自动导入现有配置为"default"供应商 // 自动从 live 导入一条默认供应商(仅首次初始化时)
const handleAutoImportDefault = async () => { const handleAutoImportDefault = async () => {
try { try {
const result = await window.api.importCurrentConfigAsDefault(activeApp); const result = await window.api.importCurrentConfigAsDefault(activeApp);
if (result.success) { if (result.success) {
await loadProviders(); await loadProviders();
showNotification( showNotification("已从现有配置创建默认供应商", "success", 3000);
"已自动导入现有配置为 default 供应商",
"success",
3000,
);
} }
// 如果导入失败(比如没有现有配置),静默处理,不显示错误 // 如果导入失败(比如没有现有配置),静默处理,不显示错误
} catch (error) { } catch (error) {
@@ -183,10 +179,7 @@ function App() {
<header className="app-header"> <header className="app-header">
<h1>CC Switch</h1> <h1>CC Switch</h1>
<div className="app-tabs"> <div className="app-tabs">
<AppSwitcher <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
activeApp={activeApp}
onSwitch={setActiveApp}
/>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<button className="add-btn" onClick={() => setIsAddModalOpen(true)}> <button className="add-btn" onClick={() => setIsAddModalOpen(true)}>

View File

@@ -34,7 +34,7 @@
.switcher-pill.active { .switcher-pill.active {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
color: white; color: white;
box-shadow: box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1), inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1); 0 1px 0 rgba(255, 255, 255, 0.1);
} }
@@ -61,6 +61,13 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; } 0%,
50% { transform: scale(1.2); opacity: 0.8; } 100% {
} transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}

View File

@@ -33,4 +33,4 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
</button> </button>
</div> </div>
); );
} }

View File

@@ -74,7 +74,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [disableCoAuthored, setDisableCoAuthored] = useState(false); const [disableCoAuthored, setDisableCoAuthored] = useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引 // -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>( const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null showPresets ? -1 : null,
); );
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
@@ -302,7 +302,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 根据当前配置决定是否展示 API Key 输入框 // 根据当前配置决定是否展示 API Key 输入框
// 自定义模式(-1)不显示独立的 API Key 输入框 // 自定义模式(-1)不显示独立的 API Key 输入框
const showApiKey = const showApiKey =
(selectedPreset !== null && selectedPreset !== -1) || (selectedPreset !== null && selectedPreset !== -1) ||
(!showPresets && hasApiKeyField(formData.settingsConfig)); (!showPresets && hasApiKeyField(formData.settingsConfig));
// 判断当前选中的预设是否是官方 // 判断当前选中的预设是否是官方
@@ -322,7 +322,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}; };
// 自定义模式(-1)不显示独立的 API Key 输入框 // 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey = const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) || (selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== ""); (!showPresets && getCodexAuthApiKey(codexAuth) !== "");
const isCodexOfficialPreset = const isCodexOfficialPreset =
selectedCodexPreset !== null && selectedCodexPreset !== null &&
@@ -409,14 +409,20 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
})} })}
</div> </div>
{selectedPreset === -1 && ( {selectedPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}> <small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
</small> </small>
)} )}
{selectedPreset !== -1 && selectedPreset !== null && ( {selectedPreset !== -1 && selectedPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}> <small
{isOfficialPreset className="field-hint"
? "Claude 官方登录,不需要填写 API Key" style={{ marginTop: "8px", display: "block" }}
>
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"} : "使用预设配置,只需填写 API Key"}
</small> </small>
)} )}
@@ -450,14 +456,20 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
))} ))}
</div> </div>
{selectedCodexPreset === -1 && ( {selectedCodexPreset === -1 && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}> <small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
</small> </small>
)} )}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && ( {selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<small className="field-hint" style={{ marginTop: "8px", display: "block" }}> <small
{isCodexOfficialPreset className="field-hint"
? "Codex 官方登录,不需要填写 API Key" style={{ marginTop: "8px", display: "block" }}
>
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"} : "使用预设配置,只需填写 API Key"}
</small> </small>
)} )}
@@ -525,7 +537,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} }
disabled={isCodexOfficialPreset} disabled={isCodexOfficialPreset}
required={ required={
selectedCodexPreset !== null && selectedCodexPreset >= 0 && !isCodexOfficialPreset selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
} }
autoComplete="off" autoComplete="off"
style={ style={

View File

@@ -94,7 +94,6 @@ const ProviderList: React.FC<ProviderListProps> = ({
<button <button
className="edit-btn" className="edit-btn"
onClick={() => onEdit(provider.id)} onClick={() => onEdit(provider.id)}
disabled={isCurrent}
> >
</button> </button>