Merge pull request #10 from farion1231/feat/ssot
feat(ssot): 首次迁移副本入库 + 切换回填不丢数据 + Codex 原子写入回滚
This commit is contained in:
@@ -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` 完成登录。
|
||||||
|
|
||||||
### 迁移与备份
|
### 迁移与备份
|
||||||
|
|||||||
193
docs/encrypted-config-plan.md
Normal file
193
docs/encrypted-config-plan.md
Normal 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-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<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-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/<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
2
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定应用的管理器
|
/// 获取指定应用的管理器
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
// 回填:读取 live(auth.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, ¤t_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(¤t_provider.name));
|
cur.settings_config = live;
|
||||||
backup_config(&settings_path, ¤t_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); // 释放锁
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
435
src-tauri/src/migration.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 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::<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 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::<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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -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)}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user